Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5215560ede | |||
| afc64b83c1 | |||
| 6b463c2b1a | |||
| c7d1627e18 | |||
| 2a3ad5e78f | |||
| 44c439e2f9 | |||
| 614d25c189 | |||
| c9fe2bd9fe | |||
| d7d8fd5339 | |||
| c926613ee0 |
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/target
|
||||
**/.turbo
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
+36
-6
@@ -1,17 +1,47 @@
|
||||
# Database
|
||||
# =========================================
|
||||
# GamePanel Environment Configuration
|
||||
# =========================================
|
||||
# Copy this file to .env and update values
|
||||
# cp .env.example .env
|
||||
|
||||
# --- Database ---
|
||||
DATABASE_URL=postgresql://gamepanel:gamepanel@localhost:5432/gamepanel
|
||||
DB_USER=gamepanel
|
||||
DB_PASSWORD=gamepanel
|
||||
DB_NAME=gamepanel
|
||||
DB_PORT=5432
|
||||
|
||||
# API
|
||||
# --- Redis ---
|
||||
REDIS_URL=redis://:gamepanel@localhost:6379
|
||||
REDIS_PASSWORD=gamepanel
|
||||
REDIS_PORT=6379
|
||||
|
||||
# --- API ---
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
API_PORT=3000
|
||||
NODE_ENV=development
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=change-me-in-production
|
||||
JWT_REFRESH_SECRET=change-me-in-production-refresh
|
||||
# --- JWT (CHANGE IN PRODUCTION!) ---
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||
JWT_SECRET=CHANGE_ME_GENERATE_A_SECURE_64_BYTE_HEX_STRING
|
||||
JWT_REFRESH_SECRET=CHANGE_ME_GENERATE_ANOTHER_SECURE_64_BYTE_HEX_STRING
|
||||
|
||||
# Daemon
|
||||
# --- Rate Limiting ---
|
||||
RATE_LIMIT_MAX=100
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
|
||||
# --- Web ---
|
||||
WEB_PORT=80
|
||||
|
||||
# --- Daemon ---
|
||||
DAEMON_CONFIG=/etc/gamepanel/config.yml
|
||||
DAEMON_GRPC_PORT=50051
|
||||
|
||||
# --- CDN (Plugin Artifacts) ---
|
||||
CDN_BASE_URL=https://cdn.hibna.com.tr
|
||||
CDN_API_KEY=
|
||||
CDN_PLUGIN_BUCKET=gamepanel-plugin-artifacts
|
||||
CDN_PLUGIN_ARTIFACT_TTL_SECONDS=900
|
||||
CDN_WEBHOOK_SECRET=
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
PNPM_VERSION: "9.15.4"
|
||||
RUST_TOOLCHAIN: "1.83"
|
||||
|
||||
jobs:
|
||||
# --- Lint + TypeScript Check ---
|
||||
lint:
|
||||
name: Lint & Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: TypeScript check (shared)
|
||||
run: pnpm --filter @source/shared build
|
||||
|
||||
- name: TypeScript check (database)
|
||||
run: pnpm --filter @source/database build
|
||||
|
||||
- name: TypeScript check (API)
|
||||
run: pnpm --filter @source/api build
|
||||
|
||||
- name: TypeScript check (Web)
|
||||
run: pnpm --filter @source/web build
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
|
||||
# --- Rust Daemon ---
|
||||
daemon:
|
||||
name: Daemon Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install protoc
|
||||
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: apps/daemon
|
||||
|
||||
- name: Check
|
||||
working-directory: apps/daemon
|
||||
run: cargo check
|
||||
|
||||
- name: Test
|
||||
working-directory: apps/daemon
|
||||
run: cargo test
|
||||
|
||||
- name: Clippy
|
||||
working-directory: apps/daemon
|
||||
run: cargo clippy -- -D warnings || true
|
||||
|
||||
# --- Docker Build Test ---
|
||||
docker:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, daemon]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build API image
|
||||
run: docker build -f apps/api/Dockerfile -t gamepanel-api:ci .
|
||||
|
||||
- name: Build Web image
|
||||
run: docker build -f apps/web/Dockerfile -t gamepanel-web:ci .
|
||||
|
||||
- name: Build Daemon image
|
||||
run: docker build -f apps/daemon/Dockerfile -t gamepanel-daemon:ci .
|
||||
+3
-1
@@ -7,6 +7,7 @@ dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
daemon-dev.yml
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -22,7 +23,8 @@ Thumbs.db
|
||||
apps/daemon/target/
|
||||
|
||||
# Database
|
||||
packages/database/drizzle/
|
||||
packages/database/drizzle/*
|
||||
!packages/database/drizzle/0007_satisfactory_game.sql
|
||||
|
||||
# Common JS/TS
|
||||
coverage/
|
||||
|
||||
+630
@@ -0,0 +1,630 @@
|
||||
# Installation Guide
|
||||
|
||||
This guide covers three deployment methods:
|
||||
1. **Development Setup** — for local development
|
||||
2. **Docker Production** — single-command deployment with Docker Compose
|
||||
3. **Manual Production** — step-by-step on Ubuntu 22.04+
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### All Methods
|
||||
- Git
|
||||
- A PostgreSQL 16+ database (or use the included Docker Compose)
|
||||
|
||||
### Development
|
||||
- **Node.js** 20+ ([nodejs.org](https://nodejs.org))
|
||||
- **pnpm** 9.15+ (`corepack enable && corepack prepare pnpm@9.15.4 --activate`)
|
||||
- **Rust** 1.83+ ([rustup.rs](https://rustup.rs))
|
||||
- **protoc** (Protocol Buffers compiler) — required for the daemon's gRPC build
|
||||
- **Docker** — for running PostgreSQL and Redis locally
|
||||
|
||||
### Docker Production
|
||||
- **Docker** 24+ with Docker Compose v2
|
||||
- At least **2 GB RAM** and **10 GB disk** for the panel itself
|
||||
- Additional resources for game servers on daemon nodes
|
||||
|
||||
---
|
||||
|
||||
## 1. Development Setup
|
||||
|
||||
### 1.1 Clone and Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/source-gamepanel.git
|
||||
cd source-gamepanel
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 1.2 Environment Configuration
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set at minimum:
|
||||
|
||||
```env
|
||||
# Generate secure secrets:
|
||||
# node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||
JWT_SECRET=<your-64-byte-hex>
|
||||
JWT_REFRESH_SECRET=<another-64-byte-hex>
|
||||
|
||||
# Database (defaults work with docker-compose.dev.yml)
|
||||
DATABASE_URL=postgresql://gamepanel:gamepanel@localhost:5432/gamepanel
|
||||
```
|
||||
|
||||
### 1.3 Start Infrastructure
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL + Redis
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
### 1.4 Database Setup
|
||||
|
||||
```bash
|
||||
# Generate migration files (if schema changed)
|
||||
pnpm db:generate
|
||||
|
||||
# Apply migrations to create all tables
|
||||
pnpm db:migrate
|
||||
|
||||
# Seed admin user and default games
|
||||
pnpm db:seed
|
||||
```
|
||||
|
||||
After seeding, you'll have:
|
||||
- **Admin account**: `admin@gamepanel.local` / `admin123`
|
||||
- **Games**: Minecraft Java, CS2, Minecraft Bedrock, Terraria, Rust
|
||||
|
||||
### 1.5 Start Development Servers
|
||||
|
||||
```bash
|
||||
# Start API (port 3000) + Web (port 5173) via Turborepo
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The web dev server proxies `/api` and `/socket.io` requests to the API automatically.
|
||||
|
||||
Open **http://localhost:5173** in your browser.
|
||||
|
||||
### 1.6 Daemon (Optional)
|
||||
|
||||
The Rust daemon manages Docker containers on game server nodes. For development you can run it locally:
|
||||
|
||||
```bash
|
||||
# Ensure protoc is installed
|
||||
protoc --version # Should show libprotoc 3.x or higher
|
||||
|
||||
# If not installed:
|
||||
# Ubuntu: sudo apt install protobuf-compiler
|
||||
# macOS: brew install protobuf
|
||||
# Windows: choco install protoc (or download from GitHub releases)
|
||||
|
||||
cd apps/daemon
|
||||
cargo run
|
||||
```
|
||||
|
||||
The daemon reads its config from `/etc/gamepanel/config.yml` or the path in `DAEMON_CONFIG` env var. For development, it falls back to defaults (API at localhost:3000, dev token).
|
||||
|
||||
### 1.7 Useful Commands
|
||||
|
||||
```bash
|
||||
pnpm build # Build all packages
|
||||
pnpm lint # ESLint across all packages
|
||||
pnpm format # Prettier format
|
||||
pnpm format:check # Check formatting without modifying
|
||||
pnpm db:studio # Open Drizzle Studio (visual DB browser)
|
||||
|
||||
# Daemon
|
||||
cd apps/daemon
|
||||
cargo test # Run unit tests (3 tests: Minecraft parser, CS2 parser)
|
||||
cargo clippy # Rust linter
|
||||
cargo build --release # Production build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Docker Production Deployment
|
||||
|
||||
### 2.1 Prepare Environment
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/source-gamepanel.git
|
||||
cd source-gamepanel
|
||||
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with production values:
|
||||
|
||||
```env
|
||||
# REQUIRED — Generate unique secrets for each!
|
||||
JWT_SECRET=<generate-with-openssl-rand-hex-64>
|
||||
JWT_REFRESH_SECRET=<generate-another-secret>
|
||||
|
||||
# Database
|
||||
DB_USER=gamepanel
|
||||
DB_PASSWORD=<strong-random-password>
|
||||
DB_NAME=gamepanel
|
||||
|
||||
# Redis
|
||||
REDIS_PASSWORD=<strong-random-password>
|
||||
|
||||
# Networking
|
||||
CORS_ORIGIN=https://panel.yourdomain.com
|
||||
WEB_PORT=80
|
||||
API_PORT=3000
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_MAX=100
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
```
|
||||
|
||||
### 2.2 Configure Daemon
|
||||
|
||||
Edit `daemon-config.yml`:
|
||||
|
||||
```yaml
|
||||
api_url: "http://api:3000"
|
||||
node_token: "<generate-a-secure-token>"
|
||||
grpc_port: 50051
|
||||
data_path: "/var/lib/gamepanel/servers"
|
||||
backup_path: "/var/lib/gamepanel/backups"
|
||||
docker:
|
||||
socket: "/var/run/docker.sock"
|
||||
network: "gamepanel_nw"
|
||||
network_subnet: "172.18.0.0/16"
|
||||
```
|
||||
|
||||
### 2.3 Build and Start
|
||||
|
||||
```bash
|
||||
# Build and start all services
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
This starts 5 services:
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| `postgres` | 5432 | PostgreSQL database |
|
||||
| `redis` | 6379 | Rate limiting & cache |
|
||||
| `api` | 3000 | Fastify REST API |
|
||||
| `web` | 80 | nginx + React SPA |
|
||||
| `daemon` | 50051 | Rust gRPC daemon |
|
||||
|
||||
### 2.4 Initialize Database
|
||||
|
||||
```bash
|
||||
# Run migrations
|
||||
docker compose exec api node -e "
|
||||
import('drizzle-kit').then(m => console.log('Use drizzle-kit migrate'))
|
||||
"
|
||||
|
||||
# Or use the pnpm scripts with the container's DATABASE_URL
|
||||
docker compose exec api sh -c 'cd /app && node apps/api/dist/index.js'
|
||||
```
|
||||
|
||||
For the initial setup, the easiest approach is:
|
||||
|
||||
```bash
|
||||
# Run migrations from your host machine pointed at the Docker PostgreSQL
|
||||
DATABASE_URL=postgresql://gamepanel:<your-password>@localhost:5432/gamepanel pnpm db:migrate
|
||||
DATABASE_URL=postgresql://gamepanel:<your-password>@localhost:5432/gamepanel pnpm db:seed
|
||||
```
|
||||
|
||||
### 2.5 Verify
|
||||
|
||||
```bash
|
||||
# Check all services are healthy
|
||||
docker compose ps
|
||||
|
||||
# Test API health
|
||||
curl http://localhost:3000/api/health
|
||||
# {"status":"ok","timestamp":"2025-..."}
|
||||
|
||||
# Test web
|
||||
curl -s http://localhost | head -5
|
||||
# <!DOCTYPE html>...
|
||||
```
|
||||
|
||||
### 2.6 Monitoring
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f api
|
||||
docker compose logs -f daemon
|
||||
docker compose logs -f web
|
||||
|
||||
# Restart a service
|
||||
docker compose restart api
|
||||
|
||||
# Update to latest
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Manual Production Setup (Ubuntu 22.04+)
|
||||
|
||||
### 3.1 System Dependencies
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Node.js 20
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# pnpm
|
||||
corepack enable
|
||||
corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
# PostgreSQL 16
|
||||
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
|
||||
sudo apt update
|
||||
sudo apt install -y postgresql-16
|
||||
|
||||
# Redis
|
||||
sudo apt install -y redis-server
|
||||
|
||||
# Docker (for game containers)
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Rust (for daemon)
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source "$HOME/.cargo/env"
|
||||
|
||||
# protoc (for gRPC)
|
||||
sudo apt install -y protobuf-compiler
|
||||
|
||||
# nginx (reverse proxy)
|
||||
sudo apt install -y nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
### 3.2 Database Setup
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql << 'EOF'
|
||||
CREATE USER gamepanel WITH PASSWORD 'your-strong-password';
|
||||
CREATE DATABASE gamepanel OWNER gamepanel;
|
||||
GRANT ALL PRIVILEGES ON DATABASE gamepanel TO gamepanel;
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3.3 Redis Configuration
|
||||
|
||||
```bash
|
||||
sudo sed -i 's/# requirepass foobared/requirepass your-redis-password/' /etc/redis/redis.conf
|
||||
sudo systemctl restart redis-server
|
||||
```
|
||||
|
||||
### 3.4 Application Setup
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
cd /opt
|
||||
sudo git clone https://github.com/your-org/source-gamepanel.git
|
||||
sudo chown -R $USER:$USER source-gamepanel
|
||||
cd source-gamepanel
|
||||
|
||||
# Install
|
||||
pnpm install
|
||||
|
||||
# Environment
|
||||
cp .env.example .env
|
||||
nano .env # Set all production values
|
||||
|
||||
# Build
|
||||
pnpm build
|
||||
|
||||
# Database
|
||||
pnpm db:migrate
|
||||
pnpm db:seed
|
||||
|
||||
# Build daemon
|
||||
cd apps/daemon
|
||||
cargo build --release
|
||||
sudo cp target/release/gamepanel-daemon /usr/local/bin/
|
||||
```
|
||||
|
||||
### 3.5 Daemon Configuration
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/gamepanel /var/lib/gamepanel/{servers,backups}
|
||||
|
||||
sudo tee /etc/gamepanel/config.yml << 'EOF'
|
||||
api_url: "http://127.0.0.1:3000"
|
||||
node_token: "generate-a-secure-token-here"
|
||||
grpc_port: 50051
|
||||
data_path: "/var/lib/gamepanel/servers"
|
||||
backup_path: "/var/lib/gamepanel/backups"
|
||||
docker:
|
||||
socket: "/var/run/docker.sock"
|
||||
network: "gamepanel_nw"
|
||||
network_subnet: "172.18.0.0/16"
|
||||
EOF
|
||||
```
|
||||
|
||||
### 3.6 Systemd Services
|
||||
|
||||
**API Service:**
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/gamepanel-api.service << 'EOF'
|
||||
[Unit]
|
||||
Description=GamePanel API
|
||||
After=network.target postgresql.service redis-server.service
|
||||
Requires=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=gamepanel
|
||||
WorkingDirectory=/opt/source-gamepanel
|
||||
ExecStart=/usr/bin/node apps/api/dist/index.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
EnvironmentFile=/opt/source-gamepanel/.env
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
```
|
||||
|
||||
**Daemon Service:**
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/gamepanel-daemon.service << 'EOF'
|
||||
[Unit]
|
||||
Description=GamePanel Daemon
|
||||
After=network.target docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/gamepanel-daemon
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=DAEMON_CONFIG=/etc/gamepanel/config.yml
|
||||
Environment=RUST_LOG=info
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
```
|
||||
|
||||
**Enable and start:**
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now gamepanel-api
|
||||
sudo systemctl enable --now gamepanel-daemon
|
||||
```
|
||||
|
||||
### 3.7 Web Build + nginx
|
||||
|
||||
```bash
|
||||
# Build the SPA
|
||||
cd /opt/source-gamepanel/apps/web
|
||||
pnpm build # outputs to dist/
|
||||
|
||||
# Copy to nginx
|
||||
sudo mkdir -p /var/www/gamepanel
|
||||
sudo cp -r dist/* /var/www/gamepanel/
|
||||
```
|
||||
|
||||
**nginx site config:**
|
||||
|
||||
```bash
|
||||
sudo tee /etc/nginx/sites-available/gamepanel << 'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name panel.yourdomain.com;
|
||||
root /var/www/gamepanel;
|
||||
index index.html;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Socket.IO
|
||||
location /socket.io/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Static assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo ln -sf /etc/nginx/sites-available/gamepanel /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 3.8 TLS with Let's Encrypt
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d panel.yourdomain.com
|
||||
```
|
||||
|
||||
Certbot will automatically configure nginx for HTTPS and set up auto-renewal.
|
||||
|
||||
### 3.9 Firewall
|
||||
|
||||
```bash
|
||||
sudo ufw allow 22/tcp # SSH
|
||||
sudo ufw allow 80/tcp # HTTP
|
||||
sudo ufw allow 443/tcp # HTTPS
|
||||
sudo ufw allow 50051/tcp # gRPC (daemon)
|
||||
# Open game server port ranges as needed:
|
||||
sudo ufw allow 25565/tcp # Minecraft
|
||||
sudo ufw allow 27015/tcp # CS2
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-Installation
|
||||
|
||||
### First Login
|
||||
|
||||
1. Open your panel URL in a browser
|
||||
2. Login with: `admin@gamepanel.local` / `admin123`
|
||||
3. **Immediately change the admin password** via account settings
|
||||
|
||||
### Create Your First Server
|
||||
|
||||
1. **Create an Organization** — Click "New Organization" on the home page
|
||||
2. **Add a Node** — Go to Nodes, add your daemon node (FQDN + ports)
|
||||
3. **Add Allocations** — Assign IP:port pairs to the node
|
||||
4. **Create a Server** — Use the creation wizard: pick a game, node, and resources
|
||||
5. **Start the Server** — Use the power controls on the console page
|
||||
|
||||
### Adding a Remote Daemon Node
|
||||
|
||||
On the remote machine:
|
||||
|
||||
```bash
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# Install the daemon binary
|
||||
scp user@panel-server:/usr/local/bin/gamepanel-daemon /usr/local/bin/
|
||||
|
||||
# Configure
|
||||
mkdir -p /etc/gamepanel /var/lib/gamepanel/{servers,backups}
|
||||
|
||||
cat > /etc/gamepanel/config.yml << EOF
|
||||
api_url: "https://panel.yourdomain.com"
|
||||
node_token: "<token-from-panel>"
|
||||
grpc_port: 50051
|
||||
EOF
|
||||
|
||||
# Create systemd service (same as above)
|
||||
# Start it
|
||||
systemctl enable --now gamepanel-daemon
|
||||
```
|
||||
|
||||
Then add the node in the panel with the remote machine's FQDN.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API won't start
|
||||
- Check `DATABASE_URL` is correct and PostgreSQL is running
|
||||
- Ensure migrations have been applied: `pnpm db:migrate`
|
||||
- Check logs: `journalctl -u gamepanel-api -f` or `docker compose logs api`
|
||||
|
||||
### Daemon can't connect
|
||||
- Verify `api_url` in daemon config points to the API
|
||||
- Check `node_token` matches what's stored in the panel's nodes table
|
||||
- Ensure the daemon's gRPC port (50051) is open
|
||||
|
||||
### Web shows blank page
|
||||
- Build the SPA: `pnpm --filter @source/web build`
|
||||
- Check nginx config: `sudo nginx -t`
|
||||
- Verify API proxy is working: `curl http://localhost:3000/api/health`
|
||||
|
||||
### Docker permission denied
|
||||
- Ensure the daemon user is in the `docker` group: `usermod -aG docker <user>`
|
||||
- Or run the daemon with appropriate privileges
|
||||
|
||||
### protoc not found (daemon build)
|
||||
- Ubuntu: `sudo apt install protobuf-compiler`
|
||||
- macOS: `brew install protobuf`
|
||||
- Or download from [github.com/protocolbuffers/protobuf/releases](https://github.com/protocolbuffers/protobuf/releases)
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
cd /opt/source-gamepanel
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
cd /opt/source-gamepanel
|
||||
git pull
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm db:migrate
|
||||
|
||||
# Rebuild daemon
|
||||
cd apps/daemon && cargo build --release
|
||||
sudo cp target/release/gamepanel-daemon /usr/local/bin/
|
||||
|
||||
# Rebuild web
|
||||
cd ../web && pnpm build
|
||||
sudo cp -r dist/* /var/www/gamepanel/
|
||||
|
||||
# Restart services
|
||||
sudo systemctl restart gamepanel-api gamepanel-daemon
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | — | PostgreSQL connection string |
|
||||
| `DB_USER` | `gamepanel` | PostgreSQL username (Docker) |
|
||||
| `DB_PASSWORD` | `gamepanel` | PostgreSQL password (Docker) |
|
||||
| `DB_NAME` | `gamepanel` | Database name (Docker) |
|
||||
| `DB_PORT` | `5432` | PostgreSQL exposed port |
|
||||
| `REDIS_URL` | — | Redis connection string |
|
||||
| `REDIS_PASSWORD` | `gamepanel` | Redis password |
|
||||
| `PORT` | `3000` | API listen port |
|
||||
| `HOST` | `0.0.0.0` | API listen host |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
| `JWT_SECRET` | — | **Required.** Access token signing key |
|
||||
| `JWT_REFRESH_SECRET` | — | **Required.** Refresh token signing key |
|
||||
| `CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin |
|
||||
| `RATE_LIMIT_MAX` | `100` | Max requests per window |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit window (ms) |
|
||||
| `WEB_PORT` | `80` | Web nginx exposed port |
|
||||
| `API_PORT` | `3000` | API exposed port (Docker) |
|
||||
| `DAEMON_CONFIG` | `/etc/gamepanel/config.yml` | Daemon config file path |
|
||||
| `DAEMON_GRPC_PORT` | `50051` | Daemon gRPC exposed port |
|
||||
@@ -0,0 +1,300 @@
|
||||
# GamePanel
|
||||
|
||||
Modern, open-source game server management panel built with a multi-tenant SaaS architecture. Inspired by Pterodactyl, enhanced with features like plugin management, visual task scheduler, live player tracking, and an in-browser config editor.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Core
|
||||
- **Multi-Tenant Organizations** — Isolated environments with role-based access control (Admin / User + custom JSONB permissions)
|
||||
- **Docker Container Management** — Full lifecycle: create, start, stop, restart, kill, delete
|
||||
- **Multi-Node Architecture** — Distribute game servers across multiple daemon nodes with health monitoring
|
||||
- **Live Console** — xterm.js terminal with Socket.IO streaming, command history support
|
||||
- **File Manager** — Browse, view, edit, create, and delete server files with path jail security
|
||||
- **Server Creation Wizard** — 3-step guided flow: Basic Info, Node & Allocation, Resources
|
||||
|
||||
### Game-Specific
|
||||
- **Config Editor** — Tab-based UI with parsers for `.properties`, `.json`, `.yaml`, and Source Engine `.cfg` formats
|
||||
- **Plugin Management** — Spiget API integration for Minecraft, manual install for other games, toggle/uninstall
|
||||
- **Player Tracking** — Live player list via RCON protocol (Minecraft `list`, CS2 `status`)
|
||||
|
||||
### Advanced
|
||||
- **Scheduled Tasks** — Visual scheduler with interval, daily, weekly, and cron expression support
|
||||
- **Backup System** — Create, restore, lock/unlock, delete backups with CDN storage integration
|
||||
- **Audit Logging** — Track all actions across the panel with user, server, and IP metadata
|
||||
|
||||
### Operations
|
||||
- **Rate Limiting** — Configurable per-window request limits
|
||||
- **Security Headers** — Helmet.js with CSP, XSS protection, content-type sniffing prevention
|
||||
- **Health Checks** — Built-in endpoints for all services
|
||||
- **CI/CD** — GitHub Actions pipeline for lint, test, and Docker build
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser ─── HTTPS + Socket.IO ──→ Web (React SPA / nginx)
|
||||
│
|
||||
REST + WS
|
||||
│
|
||||
API (Fastify + JWT)
|
||||
│ │
|
||||
PostgreSQL gRPC (protobuf)
|
||||
│
|
||||
Daemon (Rust + tonic) × N nodes
|
||||
│
|
||||
Docker API
|
||||
│
|
||||
Game Containers
|
||||
```
|
||||
|
||||
The API acts as a **gateway** between the frontend and daemon nodes. The frontend never communicates directly with daemons.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Monorepo | Turborepo + pnpm |
|
||||
| Frontend | React 19 + Vite 6 + Tailwind CSS 3 + shadcn/ui |
|
||||
| Backend API | Fastify 5 + TypeBox validation |
|
||||
| Daemon | Rust + tonic gRPC + bollard (Docker) + tokio |
|
||||
| Database | PostgreSQL 16 + Drizzle ORM |
|
||||
| Auth | JWT (access + refresh) + Argon2id |
|
||||
| Realtime | Socket.IO (frontend ↔ API) |
|
||||
| Panel ↔ Daemon | gRPC with protobuf |
|
||||
| Containers | Docker |
|
||||
| CI/CD | GitHub Actions |
|
||||
|
||||
---
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
source-gamepanel/
|
||||
├── apps/
|
||||
│ ├── api/ # Fastify REST API
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── index.ts # App entry, plugin registration
|
||||
│ │ │ ├── plugins/ # DB, auth plugins
|
||||
│ │ │ ├── lib/ # Errors, JWT, permissions, pagination,
|
||||
│ │ │ │ config parsers, Spiget client, schedule utils
|
||||
│ │ │ └── routes/
|
||||
│ │ │ ├── auth/ # Register, login, refresh, logout, me
|
||||
│ │ │ ├── organizations/ # CRUD + members
|
||||
│ │ │ ├── nodes/ # CRUD + allocations
|
||||
│ │ │ ├── servers/ # CRUD + power, config, plugins, backups, schedules
|
||||
│ │ │ └── admin/ # Users, games, audit logs (super admin)
|
||||
│ │ └── Dockerfile
|
||||
│ │
|
||||
│ ├── web/ # React SPA
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── ui/ # 13 shadcn/ui components
|
||||
│ │ │ │ ├── layout/ # AppLayout, ServerLayout, Sidebar, Header
|
||||
│ │ │ │ ├── server/ # PowerControls
|
||||
│ │ │ │ └── error-boundary.tsx
|
||||
│ │ │ ├── pages/
|
||||
│ │ │ │ ├── auth/ # Login, Register
|
||||
│ │ │ │ ├── dashboard/ # Stats + server list
|
||||
│ │ │ │ ├── server/ # Console, Files, Config, Plugins,
|
||||
│ │ │ │ │ Backups, Schedules, Players, Settings
|
||||
│ │ │ │ ├── servers/ # Create wizard
|
||||
│ │ │ │ ├── nodes/ # List + detail (health dashboard)
|
||||
│ │ │ │ ├── organizations/ # Org list + create
|
||||
│ │ │ │ ├── admin/ # Users, Games, Audit logs
|
||||
│ │ │ │ └── settings/ # Members
|
||||
│ │ │ ├── lib/ # API client, socket, utils
|
||||
│ │ │ ├── stores/ # Zustand auth store
|
||||
│ │ │ └── hooks/ # Theme hook
|
||||
│ │ ├── nginx.conf
|
||||
│ │ └── Dockerfile
|
||||
│ │
|
||||
│ └── daemon/ # Rust daemon
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # gRPC server, heartbeat, scheduler init
|
||||
│ │ ├── config.rs # YAML config loader
|
||||
│ │ ├── auth.rs # gRPC token interceptor
|
||||
│ │ ├── grpc/ # Service implementations
|
||||
│ │ ├── docker/ # Container lifecycle (bollard)
|
||||
│ │ ├── server/ # State machine, manager
|
||||
│ │ ├── filesystem/ # Path jail, CRUD operations
|
||||
│ │ ├── game/ # RCON client, Minecraft, CS2 modules
|
||||
│ │ ├── scheduler/ # Task polling + execution
|
||||
│ │ └── backup/ # tar.gz, CDN upload/download, restore
|
||||
│ ├── Cargo.toml
|
||||
│ └── Dockerfile
|
||||
│
|
||||
├── packages/
|
||||
│ ├── database/ # Drizzle schema + migrations + seed
|
||||
│ │ └── src/schema/ # 10 tables: users, orgs, nodes, servers,
|
||||
│ │ allocations, games, backups, plugins,
|
||||
│ │ schedules, audit_logs
|
||||
│ ├── shared/ # Types, permissions, roles
|
||||
│ ├── proto/ # daemon.proto (gRPC service definition)
|
||||
│ └── ui/ # Base UI utilities (cn, cva)
|
||||
│
|
||||
├── docker-compose.yml # Full production stack
|
||||
├── docker-compose.dev.yml # Dev: PostgreSQL + Redis only
|
||||
├── daemon-config.yml # Daemon configuration template
|
||||
├── .env.example # Environment variables reference
|
||||
├── .github/workflows/ci.yml # CI/CD pipeline
|
||||
├── turbo.json
|
||||
└── pnpm-workspace.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Games
|
||||
|
||||
| Game | Docker Image | Default Port | Config Format | Plugin Support |
|
||||
|------|-------------|-------------|---------------|---------------|
|
||||
| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual |
|
||||
| Counter-Strike 2 | `cm2network/cs2` | 27015 | Source `.cfg` (keyvalue) | Manual |
|
||||
| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — |
|
||||
| Terraria | `ryshe/terraria` | 7777 | keyvalue | — |
|
||||
| Rust | `didstopia/rust-server` | 28015 | — | — |
|
||||
| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — |
|
||||
| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — |
|
||||
|
||||
Many games can be added with a database seed entry alone. Some images still need small daemon-side tweaks for mount paths, port protocols, or config parsing.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/auth/register` | Create account |
|
||||
| POST | `/api/auth/login` | Login (returns JWT + refresh cookie) |
|
||||
| POST | `/api/auth/refresh` | Refresh access token |
|
||||
| POST | `/api/auth/logout` | Invalidate session |
|
||||
| GET | `/api/auth/me` | Current user profile |
|
||||
|
||||
### Organizations
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/organizations` | List user's orgs |
|
||||
| POST | `/api/organizations` | Create org |
|
||||
| GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD |
|
||||
| GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management |
|
||||
|
||||
### Servers
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET/POST | `.../servers` | List / create |
|
||||
| GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD |
|
||||
| POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) |
|
||||
| GET/PUT | `.../servers/:serverId/config` | Config read/write |
|
||||
| GET/POST/DELETE | `.../servers/:serverId/plugins` | Plugin management |
|
||||
| GET/POST/DELETE | `.../servers/:serverId/backups` | Backup management |
|
||||
| POST | `.../servers/:serverId/backups/:id/restore` | Restore backup |
|
||||
| GET/POST/PATCH/DELETE | `.../servers/:serverId/schedules` | Scheduled tasks |
|
||||
|
||||
### Admin (Super Admin only)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/admin/users` | All users |
|
||||
| GET/POST | `/api/admin/games` | Game management |
|
||||
| GET | `/api/admin/audit-logs` | Audit trail |
|
||||
|
||||
---
|
||||
|
||||
## Permission System
|
||||
|
||||
Dot-notation permissions with hybrid RBAC (role defaults + per-user JSONB overrides):
|
||||
|
||||
```
|
||||
server.create server.read server.update server.delete
|
||||
console.read console.write
|
||||
files.read files.write files.delete files.archive
|
||||
backup.read backup.create backup.restore backup.delete backup.manage
|
||||
schedule.read schedule.manage
|
||||
plugin.read plugin.manage
|
||||
config.read config.write
|
||||
power.start power.stop power.restart power.kill
|
||||
node.read node.manage
|
||||
org.settings org.members
|
||||
subuser.read subuser.manage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/your-org/source-gamepanel.git
|
||||
cd source-gamepanel
|
||||
|
||||
# Environment
|
||||
cp .env.example .env
|
||||
# Edit .env — set JWT_SECRET and JWT_REFRESH_SECRET
|
||||
|
||||
# Start infrastructure
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run migrations and seed
|
||||
pnpm db:migrate
|
||||
pnpm db:seed
|
||||
|
||||
# Start development
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173` — login with `admin@gamepanel.local` / `admin123`.
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
```bash
|
||||
# Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with production values (strong JWT secrets, real DB passwords)
|
||||
|
||||
# Deploy full stack
|
||||
docker compose up -d --build
|
||||
|
||||
# Run migrations inside the API container
|
||||
docker compose exec api node -e "..."
|
||||
# Or connect to the DB directly and run drizzle-kit migrate
|
||||
```
|
||||
|
||||
The web service is exposed on port 80 with nginx handling SPA routing and API proxying.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm dev # Start all services (API + Web + DB)
|
||||
pnpm build # Build all packages
|
||||
pnpm lint # Lint all packages
|
||||
pnpm format # Format with Prettier
|
||||
pnpm db:studio # Open Drizzle Studio (DB browser)
|
||||
pnpm db:generate # Generate migration files
|
||||
pnpm db:migrate # Apply migrations
|
||||
pnpm db:seed # Seed admin user + games
|
||||
|
||||
# Daemon (separate terminal)
|
||||
cd apps/daemon
|
||||
cargo run # Requires protoc installed
|
||||
cargo test # Run unit tests
|
||||
cargo clippy # Lint Rust code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is private. All rights reserved.
|
||||
@@ -0,0 +1,47 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/database/package.json packages/database/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# --- Build ---
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY . .
|
||||
RUN pnpm --filter @source/shared build && \
|
||||
pnpm --filter @source/database build && \
|
||||
pnpm --filter @source/api build
|
||||
|
||||
# --- Production ---
|
||||
FROM node:20-alpine AS production
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=build /app/apps/api/package.json ./apps/api/
|
||||
COPY --from=build /app/packages/database/dist ./packages/database/dist
|
||||
COPY --from=build /app/packages/database/package.json ./packages/database/
|
||||
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
||||
COPY --from=build /app/packages/shared/package.json ./packages/shared/
|
||||
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY pnpm-workspace.yaml package.json ./
|
||||
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
+14
-1
@@ -12,19 +12,32 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/jwt": "^9.0.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@grpc/grpc-js": "^1.14.0",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@sinclair/typebox": "^0.34.0",
|
||||
"@source/cdn": "1.4.0",
|
||||
"@source/database": "workspace:*",
|
||||
"@source/proto": "workspace:*",
|
||||
"@source/shared": "workspace:*",
|
||||
"argon2": "^0.41.0",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"fastify": "^5.2.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"socket.io": "^4.8.0"
|
||||
"socket.io": "^4.8.0",
|
||||
"tar-stream": "^3.1.7",
|
||||
"unzipper": "^0.12.3",
|
||||
"yazl": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/yazl": "^3.3.0",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
|
||||
+32
-3
@@ -1,13 +1,19 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import cookie from '@fastify/cookie';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import dbPlugin from './plugins/db.js';
|
||||
import authPlugin from './plugins/auth.js';
|
||||
import socketPlugin from './plugins/socket.js';
|
||||
import authRoutes from './routes/auth/index.js';
|
||||
import organizationRoutes from './routes/organizations/index.js';
|
||||
import internalRoutes from './routes/internal/index.js';
|
||||
import daemonNodeRoutes from './routes/nodes/daemon.js';
|
||||
import nodeRoutes from './routes/nodes/index.js';
|
||||
import serverRoutes from './routes/servers/index.js';
|
||||
import adminRoutes from './routes/admin/index.js';
|
||||
import gameRoutes from './routes/games/index.js';
|
||||
import { AppError } from './lib/errors.js';
|
||||
|
||||
const app = Fastify({
|
||||
@@ -19,15 +25,25 @@ const app = Fastify({
|
||||
},
|
||||
});
|
||||
|
||||
// Plugins
|
||||
// Security plugins
|
||||
await app.register(helmet, {
|
||||
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? undefined : false,
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.register(rateLimit, {
|
||||
max: Number(process.env.RATE_LIMIT_MAX) || 100,
|
||||
timeWindow: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000,
|
||||
});
|
||||
|
||||
await app.register(cookie);
|
||||
await app.register(dbPlugin);
|
||||
await app.register(authPlugin);
|
||||
await app.register(socketPlugin);
|
||||
|
||||
// Error handler
|
||||
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
|
||||
@@ -47,10 +63,20 @@ app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number;
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limit errors
|
||||
if (error.statusCode === 429) {
|
||||
return reply.code(429).send({
|
||||
error: 'Too Many Requests',
|
||||
message: 'Rate limit exceeded, please try again later',
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.code(500).send({
|
||||
return reply.code(error.statusCode ?? 500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'An unexpected error occurred',
|
||||
message: process.env.NODE_ENV === 'production'
|
||||
? 'An unexpected error occurred'
|
||||
: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +88,9 @@ app.get('/api/health', async () => {
|
||||
await app.register(authRoutes, { prefix: '/api/auth' });
|
||||
await app.register(organizationRoutes, { prefix: '/api/organizations' });
|
||||
await app.register(adminRoutes, { prefix: '/api/admin' });
|
||||
await app.register(gameRoutes, { prefix: '/api/games' });
|
||||
await app.register(daemonNodeRoutes, { prefix: '/api/nodes' });
|
||||
await app.register(internalRoutes, { prefix: '/api/internal' });
|
||||
|
||||
// Nested org routes: nodes and servers are scoped to an org
|
||||
await app.register(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { CdnClient, CdnError, type FileInfo } from '@source/cdn';
|
||||
import { AppError } from './errors.js';
|
||||
|
||||
const DEFAULT_PLUGIN_BUCKET = 'gamepanel-plugin-artifacts';
|
||||
const DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS = 900;
|
||||
const ARTIFACT_POINTER_PREFIX = 'cdn://file/';
|
||||
|
||||
let cachedClient: CdnClient | null = null;
|
||||
let cachedFingerprint: string | null = null;
|
||||
|
||||
function envValue(name: string): string | null {
|
||||
const value = process.env[name];
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
|
||||
const baseUrl = envValue('CDN_BASE_URL');
|
||||
const apiKey = envValue('CDN_API_KEY');
|
||||
if (!baseUrl || !apiKey) return null;
|
||||
return { baseUrl, apiKey };
|
||||
}
|
||||
|
||||
function getArtifactAccessTtlSeconds(): number {
|
||||
const raw = Number(process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS);
|
||||
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
|
||||
return Math.floor(raw);
|
||||
}
|
||||
|
||||
function getOrCreateClient(): CdnClient | null {
|
||||
const config = getCdnConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const fingerprint = `${config.baseUrl}::${config.apiKey}`;
|
||||
if (cachedClient && cachedFingerprint === fingerprint) return cachedClient;
|
||||
|
||||
cachedClient = new CdnClient({
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
timeoutMs: 45_000,
|
||||
retry: {
|
||||
retries: 2,
|
||||
retryDelayMs: 250,
|
||||
maxRetryDelayMs: 2_000,
|
||||
},
|
||||
});
|
||||
cachedFingerprint = fingerprint;
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
function requireClient(): CdnClient {
|
||||
const client = getOrCreateClient();
|
||||
if (!client) {
|
||||
throw new AppError(
|
||||
500,
|
||||
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
|
||||
'CDN_NOT_CONFIGURED',
|
||||
);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function toCdnAppError(error: unknown, fallbackMessage: string, fallbackCode: string): AppError {
|
||||
if (error instanceof AppError) return error;
|
||||
if (error instanceof CdnError) {
|
||||
return new AppError(502, `CDN error: ${error.message}`, fallbackCode);
|
||||
}
|
||||
return new AppError(502, fallbackMessage, fallbackCode);
|
||||
}
|
||||
|
||||
export function getPluginBucketName(): string {
|
||||
return envValue('CDN_PLUGIN_BUCKET') ?? DEFAULT_PLUGIN_BUCKET;
|
||||
}
|
||||
|
||||
export async function ensurePrivatePluginBucket(): Promise<string> {
|
||||
const client = requireClient();
|
||||
const bucketName = getPluginBucketName();
|
||||
|
||||
try {
|
||||
const bucket = await client.getBucket(bucketName);
|
||||
if (bucket.isPublic) {
|
||||
await client.updateBucket(bucketName, { isPublic: false });
|
||||
}
|
||||
return bucketName;
|
||||
} catch (error) {
|
||||
if (error instanceof CdnError && error.statusCode === 404) {
|
||||
try {
|
||||
await client.createBucket(bucketName, {
|
||||
description: 'GamePanel plugin artifacts',
|
||||
isPublic: false,
|
||||
});
|
||||
return bucketName;
|
||||
} catch (createError) {
|
||||
throw toCdnAppError(
|
||||
createError,
|
||||
'Failed to create CDN plugin bucket',
|
||||
'CDN_BUCKET_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw toCdnAppError(
|
||||
error,
|
||||
'Failed to fetch CDN plugin bucket',
|
||||
'CDN_BUCKET_READ_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCdnArtifactPointer(fileId: string): string {
|
||||
return `${ARTIFACT_POINTER_PREFIX}${fileId}`;
|
||||
}
|
||||
|
||||
export function parseCdnArtifactPointer(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith(ARTIFACT_POINTER_PREFIX)) {
|
||||
const id = trimmed.slice(ARTIFACT_POINTER_PREFIX.length).trim();
|
||||
return id.length > 0 ? id : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === 'cdn:' && parsed.hostname === 'file') {
|
||||
const candidate = parsed.pathname.replace(/^\/+/, '').trim();
|
||||
return candidate.length > 0 ? candidate : null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function uploadPluginArtifact(
|
||||
content: Uint8Array,
|
||||
filename: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<{ bucket: string; file: FileInfo; artifactPointer: string }> {
|
||||
const client = requireClient();
|
||||
const bucket = await ensurePrivatePluginBucket();
|
||||
|
||||
try {
|
||||
const file = await client.upload(content, {
|
||||
bucket,
|
||||
filename,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
bucket,
|
||||
file,
|
||||
artifactPointer: buildCdnArtifactPointer(file.id),
|
||||
};
|
||||
} catch (error) {
|
||||
throw toCdnAppError(error, 'Failed to upload artifact to CDN', 'CDN_UPLOAD_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<string> {
|
||||
const fileId = parseCdnArtifactPointer(artifactUrl);
|
||||
if (!fileId) return artifactUrl;
|
||||
|
||||
const client = requireClient();
|
||||
const config = getCdnConfig();
|
||||
const ttl = getArtifactAccessTtlSeconds();
|
||||
|
||||
try {
|
||||
const access = await client.getFileAccessUrl(fileId, ttl);
|
||||
if (!access.url || typeof access.url !== 'string') {
|
||||
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
|
||||
}
|
||||
|
||||
const resolvedUrl = access.url.trim();
|
||||
if (!resolvedUrl) {
|
||||
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(resolvedUrl)) {
|
||||
return resolvedUrl;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
throw new AppError(
|
||||
500,
|
||||
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
|
||||
'CDN_NOT_CONFIGURED',
|
||||
);
|
||||
}
|
||||
|
||||
return new URL(resolvedUrl, config.baseUrl).toString();
|
||||
} catch (error) {
|
||||
throw toCdnAppError(
|
||||
error,
|
||||
'Failed to get temporary CDN access URL',
|
||||
'CDN_ACCESS_URL_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ function parseKeyValue(content: string): ConfigEntry[] {
|
||||
const entries: ConfigEntry[] = [];
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Match: key "value" or key value
|
||||
const match = trimmed.match(/^(\S+)\s+"([^"]*)"/) || trimmed.match(/^(\S+)\s+(.*)/);
|
||||
@@ -210,7 +210,7 @@ function serializeKeyValue(entries: ConfigEntry[], originalContent?: string): st
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) {
|
||||
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
|
||||
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
|
||||
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
|
||||
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
|
||||
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
|
||||
|
||||
hostname "GamePanel CS2 Server" // Set server hostname
|
||||
sv_cheats 0 // Enable or disable cheats
|
||||
sv_hibernate_when_empty 0 // Disable server hibernation
|
||||
|
||||
// Passwords
|
||||
|
||||
rcon_password "" // Set rcon password
|
||||
sv_password "" // Set server password
|
||||
|
||||
// CSTV
|
||||
|
||||
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
|
||||
|
||||
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
|
||||
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
|
||||
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
|
||||
tv_chatgroupsize 0 // Set the default chat group size
|
||||
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
|
||||
tv_debug 0 // CSTV debug info.
|
||||
tv_delay 0 // CSTV broadcast delay in seconds
|
||||
tv_delaymapchange 1 // Delays map change until broadcast is complete
|
||||
tv_deltacache 2 // Enable delta entity bit stream cache
|
||||
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
|
||||
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
|
||||
tv_maxclients 10 // Maximum client number on CSTV server.
|
||||
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
|
||||
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
|
||||
tv_overridemaster 0 // Overrides the CSTV master root address.
|
||||
tv_port 27020 // Host SourceTV port
|
||||
tv_password "changeme" // CSTV password for clients
|
||||
tv_relaypassword "changeme" // CSTV password for relay proxies
|
||||
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
|
||||
tv_timeout 60 // CSTV connection timeout in seconds.
|
||||
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
|
||||
tv_transmitall 1 // Transmit all entities (not only director view)
|
||||
|
||||
// Logs
|
||||
|
||||
log on // Turns logging 'on' or 'off', defaults to 'on'
|
||||
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
|
||||
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
|
||||
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
|
||||
`;
|
||||
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
|
||||
// CS2 Server Config
|
||||
// ============================================
|
||||
|
||||
// ---- Sunucu Bilgileri ----
|
||||
hostname "SourceGamePanel CS2 Server"
|
||||
sv_password ""
|
||||
rcon_password "changeme"
|
||||
sv_cheats 0
|
||||
|
||||
// ---- Topluluk Sunucu Gorunurlugu ----
|
||||
sv_region 3
|
||||
sv_tags "competitive,community"
|
||||
sv_lan 0
|
||||
sv_steamgroup ""
|
||||
sv_steamgroup_exclusive 0
|
||||
|
||||
// ---- Performans ----
|
||||
sv_maxrate 0
|
||||
sv_minrate 64000
|
||||
sv_max_queries_sec 5
|
||||
sv_max_queries_window 30
|
||||
sv_parallel_sendsnapshot 1
|
||||
net_maxroutable 1200
|
||||
|
||||
// ---- Baglanti ----
|
||||
sv_maxclients 16
|
||||
sv_timeout 60
|
||||
|
||||
// ---- GOTV (Tamamen Kapali) ----
|
||||
tv_enable 0
|
||||
tv_autorecord 0
|
||||
tv_delay 0
|
||||
tv_maxclients 0
|
||||
tv_port 0
|
||||
|
||||
// ---- Loglama ----
|
||||
log on
|
||||
mp_logmoney 0
|
||||
mp_logdetail 0
|
||||
mp_logdetail_items 0
|
||||
sv_logfile 1
|
||||
|
||||
// ---- Genel Oyun Ayarlari ----
|
||||
mp_autokick 0
|
||||
sv_allow_votes 0
|
||||
sv_alltalk 0
|
||||
sv_deadtalk 1
|
||||
sv_voiceenable 1
|
||||
`;
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
const normalized = path
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/{2,}/g, '/');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('Server responded with NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeComparableContent(content: string): string {
|
||||
return content.replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
export function isManagedCs2ServerConfigPath(gameSlug: string, path: string): boolean {
|
||||
return (
|
||||
gameSlug.trim().toLowerCase() === 'cs2' &&
|
||||
normalizePath(path) === CS2_SERVER_CFG_PATH
|
||||
);
|
||||
}
|
||||
|
||||
export async function readManagedCs2ServerConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const persisted = await daemonReadFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH);
|
||||
return persisted.data.toString('utf8');
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const current = await daemonReadFile(node, serverUuid, CS2_SERVER_CFG_PATH);
|
||||
const content = current.data.toString('utf8');
|
||||
const nextContent =
|
||||
normalizeComparableContent(content) === normalizeComparableContent(LEGACY_IMAGE_CS2_SERVER_CFG)
|
||||
? DEFAULT_CS2_SERVER_CFG
|
||||
: content;
|
||||
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, nextContent);
|
||||
return nextContent;
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG);
|
||||
return DEFAULT_CS2_SERVER_CFG;
|
||||
}
|
||||
|
||||
export async function writeManagedCs2ServerConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
content: string | Buffer,
|
||||
): Promise<void> {
|
||||
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, content);
|
||||
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
|
||||
}
|
||||
|
||||
export async function reapplyManagedCs2ServerConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<void> {
|
||||
const content = await readManagedCs2ServerConfig(node, serverUuid);
|
||||
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
import grpc from '@grpc/grpc-js';
|
||||
import protoLoader from '@grpc/proto-loader';
|
||||
import type { PowerAction } from '@source/shared';
|
||||
import { PROTO_PATH } from '@source/proto';
|
||||
|
||||
export interface DaemonNodeConnection {
|
||||
fqdn: string;
|
||||
grpcPort: number;
|
||||
daemonToken: string;
|
||||
}
|
||||
|
||||
export interface DaemonPortMapping {
|
||||
host_port: number;
|
||||
container_port: number;
|
||||
protocol: 'tcp' | 'udp';
|
||||
}
|
||||
|
||||
export interface DaemonCreateServerRequest {
|
||||
uuid: string;
|
||||
docker_image: string;
|
||||
memory_limit: number;
|
||||
disk_limit: number;
|
||||
cpu_limit: number;
|
||||
startup_command: string;
|
||||
environment: Record<string, string>;
|
||||
ports: DaemonPortMapping[];
|
||||
install_plugin_urls: string[];
|
||||
}
|
||||
|
||||
export interface DaemonUpdateServerRequest {
|
||||
uuid: string;
|
||||
docker_image: string;
|
||||
memory_limit: number;
|
||||
disk_limit: number;
|
||||
cpu_limit: number;
|
||||
startup_command: string;
|
||||
environment: Record<string, string>;
|
||||
ports: DaemonPortMapping[];
|
||||
}
|
||||
|
||||
interface DaemonServerResponse {
|
||||
uuid: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface DaemonManagedDatabaseCredentialsRaw {
|
||||
database_name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
phpmyadmin_url: string;
|
||||
}
|
||||
|
||||
interface DaemonNodeStatusRaw {
|
||||
version: string;
|
||||
is_healthy: boolean;
|
||||
uptime_seconds: number;
|
||||
active_servers: number;
|
||||
}
|
||||
|
||||
interface DaemonNodeStatsRaw {
|
||||
cpu_percent: number;
|
||||
memory_used: number;
|
||||
memory_total: number;
|
||||
disk_used: number;
|
||||
disk_total: number;
|
||||
}
|
||||
|
||||
interface DaemonStatusResponse {
|
||||
uuid: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface EmptyResponse {
|
||||
[key: string]: never;
|
||||
}
|
||||
|
||||
interface DaemonFileListResponseRaw {
|
||||
files: {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
size: number;
|
||||
modified_at: number;
|
||||
mime_type: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DaemonFileContentRaw {
|
||||
data: Uint8Array | Buffer;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
interface DaemonPlayerListRaw {
|
||||
players: {
|
||||
name: string;
|
||||
uuid: string;
|
||||
connected_at: number;
|
||||
}[];
|
||||
max_players: number;
|
||||
}
|
||||
|
||||
interface DaemonBackupResponseRaw {
|
||||
backup_id: string;
|
||||
size_bytes: number;
|
||||
checksum: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonConsoleOutput {
|
||||
uuid: string;
|
||||
line: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface DaemonConsoleStreamHandle {
|
||||
stream: grpc.ClientReadableStream<DaemonConsoleOutput>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface DaemonFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface DaemonPlayersResponse {
|
||||
players: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
connectedAt: number;
|
||||
}>;
|
||||
maxPlayers: number;
|
||||
}
|
||||
|
||||
export interface DaemonBackupResponse {
|
||||
backupId: string;
|
||||
sizeBytes: number;
|
||||
checksum: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonManagedDatabaseCredentials {
|
||||
databaseName: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
phpMyAdminUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DaemonNodeStatus {
|
||||
version: string;
|
||||
isHealthy: boolean;
|
||||
uptimeSeconds: number;
|
||||
activeServers: number;
|
||||
}
|
||||
|
||||
export interface DaemonNodeStats {
|
||||
cpuPercent: number;
|
||||
memoryUsed: number;
|
||||
memoryTotal: number;
|
||||
diskUsed: number;
|
||||
diskTotal: number;
|
||||
}
|
||||
|
||||
type UnaryCallback<TResponse> = (error: grpc.ServiceError | null, response: TResponse) => void;
|
||||
|
||||
interface DaemonServiceClient extends grpc.Client {
|
||||
getNodeStatus(
|
||||
request: EmptyResponse,
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonNodeStatusRaw>,
|
||||
): void;
|
||||
streamNodeStats(
|
||||
request: EmptyResponse,
|
||||
metadata: grpc.Metadata,
|
||||
): grpc.ClientReadableStream<DaemonNodeStatsRaw>;
|
||||
createServer(
|
||||
request: DaemonCreateServerRequest,
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonServerResponse>,
|
||||
): void;
|
||||
updateServer(
|
||||
request: DaemonUpdateServerRequest,
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonServerResponse>,
|
||||
): void;
|
||||
deleteServer(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
createDatabase(
|
||||
request: { server_uuid: string; name: string; password?: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonManagedDatabaseCredentialsRaw>,
|
||||
): void;
|
||||
importDatabaseSql(
|
||||
request: { database_name: string; sql: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
updateDatabasePassword(
|
||||
request: { username: string; password: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
deleteDatabase(
|
||||
request: { database_name: string; username: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
setPowerState(
|
||||
request: { uuid: string; action: number },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
getServerStatus(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonStatusResponse>,
|
||||
): void;
|
||||
streamConsole(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
): grpc.ClientReadableStream<DaemonConsoleOutput>;
|
||||
sendCommand(
|
||||
request: { uuid: string; command: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
listFiles(
|
||||
request: { uuid: string; path: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonFileListResponseRaw>,
|
||||
): void;
|
||||
readFile(
|
||||
request: { uuid: string; path: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonFileContentRaw>,
|
||||
): void;
|
||||
writeFile(
|
||||
request: { uuid: string; path: string; data: Uint8Array | Buffer },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
deleteFiles(
|
||||
request: { uuid: string; paths: string[] },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
createBackup(
|
||||
request: { server_uuid: string; backup_id: string; cdn_upload_url?: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonBackupResponseRaw>,
|
||||
): void;
|
||||
restoreBackup(
|
||||
request: { server_uuid: string; backup_id: string; cdn_download_url?: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
deleteBackup(
|
||||
request: { server_uuid: string; backup_id: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
getActivePlayers(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonPlayerListRaw>,
|
||||
): void;
|
||||
}
|
||||
|
||||
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
|
||||
keepCase: true,
|
||||
longs: Number,
|
||||
enums: Number,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
});
|
||||
|
||||
const loaded = grpc.loadPackageDefinition(packageDefinition) as {
|
||||
gamepanel?: {
|
||||
daemon?: {
|
||||
DaemonService?: grpc.ServiceClientConstructor;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const DaemonServiceCtor = loaded.gamepanel?.daemon?.DaemonService;
|
||||
if (!DaemonServiceCtor) {
|
||||
throw new Error('Failed to load DaemonService gRPC definition');
|
||||
}
|
||||
const DaemonService = DaemonServiceCtor;
|
||||
|
||||
const POWER_ACTIONS: Record<PowerAction, number> = {
|
||||
start: 0,
|
||||
stop: 1,
|
||||
restart: 2,
|
||||
kill: 3,
|
||||
};
|
||||
|
||||
const MAX_GRPC_MESSAGE_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
function buildGrpcTarget(fqdn: string, grpcPort: number): string {
|
||||
const trimmed = fqdn.trim();
|
||||
if (!trimmed) throw new Error('Node FQDN is empty');
|
||||
|
||||
let host = trimmed;
|
||||
if (trimmed.includes('://')) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
host = parsed.hostname || parsed.host;
|
||||
if (!host) throw new Error('Node FQDN has no hostname');
|
||||
} catch {
|
||||
// Fall through to raw handling below.
|
||||
}
|
||||
}
|
||||
|
||||
const withoutPath = host.replace(/\/.*$/, '');
|
||||
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
|
||||
const innerHost = withoutPath.replace(/^\[/, '').replace(/\](?::\d+)?$/, '');
|
||||
return `[${innerHost}]:${grpcPort}`;
|
||||
}
|
||||
if (/^[^:]+:\d+$/.test(withoutPath)) {
|
||||
const hostOnly = withoutPath.replace(/:\d+$/, '');
|
||||
return `${hostOnly}:${grpcPort}`;
|
||||
}
|
||||
if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`;
|
||||
return `${withoutPath}:${grpcPort}`;
|
||||
}
|
||||
|
||||
function getMetadata(daemonToken: string): grpc.Metadata {
|
||||
const metadata = new grpc.Metadata();
|
||||
metadata.set('authorization', `Bearer ${daemonToken}`);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function createClient(node: DaemonNodeConnection): DaemonServiceClient {
|
||||
const target = buildGrpcTarget(node.fqdn, node.grpcPort);
|
||||
return new DaemonService(target, grpc.credentials.createInsecure(), {
|
||||
'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES,
|
||||
'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES,
|
||||
}) as unknown as DaemonServiceClient;
|
||||
}
|
||||
|
||||
function waitForReady(client: grpc.Client, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.waitForReady(Date.now() + timeoutMs, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function callUnary<TResponse>(
|
||||
invoke: (callback: UnaryCallback<TResponse>) => void,
|
||||
timeoutMs: number,
|
||||
): Promise<TResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let completed = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
reject(new Error(`gRPC request timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
invoke((error, response) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readFirstStreamMessage<TMessage>(
|
||||
stream: grpc.ClientReadableStream<TMessage>,
|
||||
timeoutMs: number,
|
||||
): Promise<TMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let completed = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
reject(new Error(`gRPC stream timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const onData = (message: TMessage) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(message);
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('gRPC stream ended before first message'));
|
||||
};
|
||||
|
||||
stream.on('data', onData);
|
||||
stream.on('error', onError);
|
||||
stream.on('end', onEnd);
|
||||
});
|
||||
}
|
||||
|
||||
function toBuffer(data: Uint8Array | Buffer): Buffer {
|
||||
if (Buffer.isBuffer(data)) return data;
|
||||
return Buffer.from(data);
|
||||
}
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
|
||||
const DEFAULT_RPC_TIMEOUT_MS = 20_000;
|
||||
const POWER_RPC_TIMEOUT_MS = 45_000;
|
||||
|
||||
interface DaemonRequestTimeoutOptions {
|
||||
connectTimeoutMs?: number;
|
||||
rpcTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function daemonGetNodeStatus(node: DaemonNodeConnection): Promise<DaemonNodeStatus> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonNodeStatusRaw>(
|
||||
(callback) => client.getNodeStatus({}, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
version: response.version,
|
||||
isHealthy: response.is_healthy,
|
||||
uptimeSeconds: Number(response.uptime_seconds),
|
||||
activeServers: Number(response.active_servers),
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonGetNodeStats(node: DaemonNodeConnection): Promise<DaemonNodeStats> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const stream = client.streamNodeStats({}, getMetadata(node.daemonToken));
|
||||
const response = await readFirstStreamMessage(stream, DEFAULT_RPC_TIMEOUT_MS);
|
||||
|
||||
return {
|
||||
cpuPercent: Number(response.cpu_percent),
|
||||
memoryUsed: Number(response.memory_used),
|
||||
memoryTotal: Number(response.memory_total),
|
||||
diskUsed: Number(response.disk_used),
|
||||
diskTotal: Number(response.disk_total),
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonCreateServer(
|
||||
node: DaemonNodeConnection,
|
||||
request: DaemonCreateServerRequest,
|
||||
): Promise<DaemonServerResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonServerResponse>(
|
||||
(callback) => client.createServer(request, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteServer(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonUpdateServer(
|
||||
node: DaemonNodeConnection,
|
||||
request: DaemonUpdateServerRequest,
|
||||
): Promise<DaemonServerResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonServerResponse>(
|
||||
(callback) => client.updateServer(request, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonCreateDatabase(
|
||||
node: DaemonNodeConnection,
|
||||
request: { serverUuid: string; name: string; password?: string },
|
||||
): Promise<DaemonManagedDatabaseCredentials> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonManagedDatabaseCredentialsRaw>(
|
||||
(callback) =>
|
||||
client.createDatabase(
|
||||
{
|
||||
server_uuid: request.serverUuid,
|
||||
name: request.name,
|
||||
password: request.password ?? '',
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
databaseName: response.database_name,
|
||||
username: response.username,
|
||||
password: response.password,
|
||||
host: response.host,
|
||||
port: Number(response.port),
|
||||
phpMyAdminUrl: response.phpmyadmin_url.trim() ? response.phpmyadmin_url : null,
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonUpdateDatabasePassword(
|
||||
node: DaemonNodeConnection,
|
||||
request: { username: string; password: string },
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.updateDatabasePassword(
|
||||
{
|
||||
username: request.username,
|
||||
password: request.password,
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonImportDatabaseSql(
|
||||
node: DaemonNodeConnection,
|
||||
request: { databaseName: string; sql: string },
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.importDatabaseSql(
|
||||
{
|
||||
database_name: request.databaseName,
|
||||
sql: request.sql,
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteDatabase(
|
||||
node: DaemonNodeConnection,
|
||||
request: { databaseName: string; username: string },
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.deleteDatabase(
|
||||
{
|
||||
database_name: request.databaseName,
|
||||
username: request.username,
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonSetPowerState(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
action: PowerAction,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.setPowerState(
|
||||
{ uuid: serverUuid, action: POWER_ACTIONS[action] },
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
POWER_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonGetServerStatus(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
timeouts: DaemonRequestTimeoutOptions = {},
|
||||
): Promise<DaemonStatusResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonStatusResponse>(
|
||||
(callback) =>
|
||||
client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonOpenConsoleStream(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<DaemonConsoleStreamHandle> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const stream = client.streamConsole({ uuid: serverUuid }, getMetadata(node.daemonToken));
|
||||
|
||||
const close = () => {
|
||||
try {
|
||||
stream.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
client.close();
|
||||
};
|
||||
|
||||
stream.on('end', () => client.close());
|
||||
stream.on('error', () => client.close());
|
||||
|
||||
return { stream, close };
|
||||
} catch (error) {
|
||||
client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonSendCommand(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonListFiles(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
path: string,
|
||||
): Promise<DaemonFileEntry[]> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonFileListResponseRaw>(
|
||||
(callback) =>
|
||||
client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return response.files.map((file) => ({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isDirectory: file.is_directory,
|
||||
size: Number(file.size),
|
||||
modifiedAt: Number(file.modified_at),
|
||||
mimeType: file.mime_type,
|
||||
}));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonReadFile(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
path: string,
|
||||
): Promise<{ data: Buffer; mimeType: string }> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonFileContentRaw>(
|
||||
(callback) =>
|
||||
client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
data: toBuffer(response.data),
|
||||
mimeType: response.mime_type,
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonWriteFile(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
path: string,
|
||||
data: string | Buffer,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.writeFile(
|
||||
{
|
||||
uuid: serverUuid,
|
||||
path,
|
||||
data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data,
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteFiles(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
paths: string[],
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonCreateBackup(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
backupId: string,
|
||||
): Promise<DaemonBackupResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonBackupResponseRaw>(
|
||||
(callback) =>
|
||||
client.createBackup(
|
||||
{ server_uuid: serverUuid, backup_id: backupId },
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
backupId: response.backup_id,
|
||||
sizeBytes: Number(response.size_bytes),
|
||||
checksum: response.checksum,
|
||||
success: response.success,
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonRestoreBackup(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
backupId: string,
|
||||
cdnPath?: string | null,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.restoreBackup(
|
||||
{
|
||||
server_uuid: serverUuid,
|
||||
backup_id: backupId,
|
||||
cdn_download_url: cdnPath ?? '',
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteBackup(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
backupId: string,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.deleteBackup(
|
||||
{ server_uuid: serverUuid, backup_id: backupId },
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonGetActivePlayers(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<DaemonPlayersResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonPlayerListRaw>(
|
||||
(callback) =>
|
||||
client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
players: response.players.map((player) => ({
|
||||
name: player.name,
|
||||
id: player.uuid,
|
||||
connectedAt: Number(player.connected_at),
|
||||
})),
|
||||
maxPlayers: Number(response.max_players),
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import * as tar from 'tar-stream';
|
||||
import type { Headers } from 'tar-stream';
|
||||
import * as unzipper from 'unzipper';
|
||||
import { serverDatabases, servers } from '@source/database';
|
||||
import {
|
||||
daemonCreateDatabase,
|
||||
daemonDeleteDatabase,
|
||||
daemonDeleteFiles,
|
||||
daemonImportDatabaseSql,
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
|
||||
const GITHUB_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const URL_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const QBCORE_DATABASE_NAME = 'qbcore';
|
||||
const FIVE_M_QBCORE_MARKER_PATH = '/.gamepanel/fivem-qbcore.json';
|
||||
const FIVE_M_INTERNAL_PORT = 30120;
|
||||
const QBCORE_SQL_URL =
|
||||
'https://raw.githubusercontent.com/qbcore-framework/txAdminRecipe/main/qbcore.sql';
|
||||
const OXMYSQL_ZIP_URL =
|
||||
'https://github.com/overextended/oxmysql/releases/download/v2.12.0/oxmysql.zip';
|
||||
const MENUV_ZIP_URL = 'https://github.com/ThymonA/menuv/releases/download/v1.4.1/menuv_v1.4.1.zip';
|
||||
|
||||
interface ExtractedFile {
|
||||
path: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
interface ManagedServerDatabaseRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
databaseName: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
phpMyAdminUrl: string | null;
|
||||
}
|
||||
|
||||
interface FivemProvisionContext {
|
||||
node: DaemonNodeConnection;
|
||||
serverDescription?: string | null;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
serverUuid: string;
|
||||
}
|
||||
|
||||
interface GitHubArchiveResource {
|
||||
destination: string;
|
||||
owner: string;
|
||||
ref: string;
|
||||
repo: string;
|
||||
subpath?: string;
|
||||
}
|
||||
|
||||
interface RemoteArchiveResource {
|
||||
collapseTopLevelDirectory?: boolean;
|
||||
destination: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const FIVEM_GITHUB_RESOURCES: GitHubArchiveResource[] = [
|
||||
{
|
||||
owner: 'citizenfx',
|
||||
repo: 'cfx-server-data',
|
||||
ref: 'master',
|
||||
destination: '/resources/[cfx-default]',
|
||||
subpath: 'resources',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'bob74_ipl',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/bob74_ipl',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'safecracker',
|
||||
ref: 'main',
|
||||
destination: '/resources/[standalone]/safecracker',
|
||||
},
|
||||
{
|
||||
owner: 'citizenfx',
|
||||
repo: 'screenshot-basic',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/screenshot-basic',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'progressbar',
|
||||
ref: 'main',
|
||||
destination: '/resources/[standalone]/progressbar',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'interact-sound',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/interact-sound',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'connectqueue',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/connectqueue',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'PolyZone',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/PolyZone',
|
||||
},
|
||||
{
|
||||
owner: 'AvarianKnight',
|
||||
repo: 'pma-voice',
|
||||
ref: 'main',
|
||||
destination: '/resources/[voice]/pma-voice',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'qb-radio',
|
||||
ref: 'main',
|
||||
destination: '/resources/[voice]/qb-radio',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'hospital_map',
|
||||
ref: 'main',
|
||||
destination: '/resources/[defaultmaps]/hospital_map',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'dealer_map',
|
||||
ref: 'main',
|
||||
destination: '/resources/[defaultmaps]/dealer_map',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'prison_map',
|
||||
ref: 'main',
|
||||
destination: '/resources/[defaultmaps]/prison_map',
|
||||
},
|
||||
...[
|
||||
'qb-core',
|
||||
'qb-scoreboard',
|
||||
'qb-adminmenu',
|
||||
'qb-multicharacter',
|
||||
'qb-target',
|
||||
'qb-vehiclesales',
|
||||
'qb-vehicleshop',
|
||||
'qb-houserobbery',
|
||||
'qb-prison',
|
||||
'qb-hud',
|
||||
'qb-management',
|
||||
'qb-weed',
|
||||
'qb-lapraces',
|
||||
'qb-inventory',
|
||||
'qb-houses',
|
||||
'qb-garages',
|
||||
'qb-ambulancejob',
|
||||
'qb-radialmenu',
|
||||
'qb-crypto',
|
||||
'qb-weathersync',
|
||||
'qb-policejob',
|
||||
'qb-apartments',
|
||||
'qb-vehiclekeys',
|
||||
'qb-mechanicjob',
|
||||
'qb-phone',
|
||||
'qb-vineyard',
|
||||
'qb-weapons',
|
||||
'qb-scrapyard',
|
||||
'qb-towjob',
|
||||
'qb-streetraces',
|
||||
'qb-storerobbery',
|
||||
'qb-spawn',
|
||||
'qb-smallresources',
|
||||
'qb-recyclejob',
|
||||
'qb-crafting',
|
||||
'qb-diving',
|
||||
'qb-cityhall',
|
||||
'qb-truckrobbery',
|
||||
'qb-pawnshop',
|
||||
'qb-minigames',
|
||||
'qb-taxijob',
|
||||
'qb-busjob',
|
||||
'qb-newsjob',
|
||||
'qb-fuel',
|
||||
'qb-jewelery',
|
||||
'qb-bankrobbery',
|
||||
'qb-banking',
|
||||
'qb-clothing',
|
||||
'qb-hotdogjob',
|
||||
'qb-doorlock',
|
||||
'qb-garbagejob',
|
||||
'qb-drugs',
|
||||
'qb-shops',
|
||||
'qb-interior',
|
||||
'qb-menu',
|
||||
'qb-input',
|
||||
'qb-loading',
|
||||
].map((repo) => ({
|
||||
owner: 'qbcore-framework',
|
||||
repo,
|
||||
ref: 'main',
|
||||
destination: `/resources/[qb]/${repo}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const FIVEM_REMOTE_ARCHIVES: RemoteArchiveResource[] = [
|
||||
{
|
||||
url: OXMYSQL_ZIP_URL,
|
||||
destination: '/resources/[standalone]/oxmysql',
|
||||
collapseTopLevelDirectory: true,
|
||||
},
|
||||
{
|
||||
url: MENUV_ZIP_URL,
|
||||
destination: '/resources/[standalone]/menuv',
|
||||
collapseTopLevelDirectory: true,
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePathSegments(path: string): string[] {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||
}
|
||||
|
||||
function normalizeArchivePath(path: string): string | null {
|
||||
const segments = normalizePathSegments(path);
|
||||
if (segments.length === 0) return null;
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function joinServerPath(base: string, relative: string): string {
|
||||
const baseSegments = normalizePathSegments(base);
|
||||
const relativeSegments = normalizePathSegments(relative);
|
||||
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function stripSharedTopLevelDirectory(files: ExtractedFile[]): ExtractedFile[] {
|
||||
if (files.length === 0) return files;
|
||||
|
||||
const firstSegments = new Set<string>();
|
||||
for (const file of files) {
|
||||
const [first] = normalizePathSegments(file.path);
|
||||
if (!first) return files;
|
||||
firstSegments.add(first);
|
||||
if (firstSegments.size > 1) {
|
||||
return files;
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
.map((file) => {
|
||||
const segments = normalizePathSegments(file.path).slice(1);
|
||||
if (segments.length === 0) return null;
|
||||
return {
|
||||
path: segments.join('/'),
|
||||
data: file.data,
|
||||
};
|
||||
})
|
||||
.filter((file): file is ExtractedFile => file !== null);
|
||||
}
|
||||
|
||||
function filterFilesBySubpath(files: ExtractedFile[], subpath: string): ExtractedFile[] {
|
||||
const prefix = normalizePathSegments(subpath).join('/');
|
||||
if (!prefix) return files;
|
||||
|
||||
const normalizedPrefix = `${prefix}/`;
|
||||
return files
|
||||
.map((file) => {
|
||||
if (file.path === prefix) return null;
|
||||
if (!file.path.startsWith(normalizedPrefix)) return null;
|
||||
return {
|
||||
path: file.path.slice(normalizedPrefix.length),
|
||||
data: file.data,
|
||||
};
|
||||
})
|
||||
.filter((file): file is ExtractedFile => file !== null && file.path.length > 0);
|
||||
}
|
||||
|
||||
async function downloadBinary(
|
||||
url: string,
|
||||
maxBytes: number,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<Buffer> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
...headers,
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed (${response.status}): ${url}`);
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length') ?? '0');
|
||||
if (contentLength > maxBytes) {
|
||||
throw new Error(`Download exceeds size limit (${contentLength} > ${maxBytes})`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) {
|
||||
throw new Error(`Downloaded archive is empty: ${url}`);
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new Error(`Download exceeds size limit (${buffer.length} > ${maxBytes})`);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function downloadText(url: string, headers: Record<string, string> = {}): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
...headers,
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Text download failed (${response.status}): ${url}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
throw new Error(`Downloaded text is empty: ${url}`);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
async function extractZipFiles(buffer: Buffer): Promise<ExtractedFile[]> {
|
||||
const archive = await unzipper.Open.buffer(buffer);
|
||||
const files: ExtractedFile[] = [];
|
||||
|
||||
for (const entry of archive.files) {
|
||||
if (entry.type !== 'File') continue;
|
||||
const normalized = normalizeArchivePath(entry.path);
|
||||
if (!normalized) continue;
|
||||
|
||||
files.push({
|
||||
path: normalized,
|
||||
data: await entry.buffer(),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractTarFiles(buffer: Buffer): Promise<ExtractedFile[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const extract = tar.extract();
|
||||
const files: ExtractedFile[] = [];
|
||||
|
||||
extract.on('entry', (header: Headers, stream, next) => {
|
||||
const type = header.type ?? 'file';
|
||||
const normalized = normalizeArchivePath(header.name);
|
||||
const isFileType = type === 'file' || type === 'contiguous-file';
|
||||
|
||||
if (!isFileType || !normalized) {
|
||||
stream.resume();
|
||||
stream.on('end', next);
|
||||
stream.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on('end', () => {
|
||||
files.push({ path: normalized, data: Buffer.concat(chunks) });
|
||||
next();
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
extract.on('finish', () => resolve(files));
|
||||
extract.on('error', reject);
|
||||
extract.end(buffer);
|
||||
});
|
||||
}
|
||||
|
||||
async function extractArchive(buffer: Buffer, url: string): Promise<ExtractedFile[]> {
|
||||
const normalizedUrl = url.toLowerCase();
|
||||
if (normalizedUrl.endsWith('.zip')) {
|
||||
return extractZipFiles(buffer);
|
||||
}
|
||||
if (normalizedUrl.endsWith('.tar.gz') || normalizedUrl.endsWith('.tgz')) {
|
||||
return extractTarFiles(gunzipSync(buffer));
|
||||
}
|
||||
if (normalizedUrl.endsWith('.tar')) {
|
||||
return extractTarFiles(buffer);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported archive type: ${url}`);
|
||||
}
|
||||
|
||||
async function writeFilesToServer(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
destination: string,
|
||||
files: ExtractedFile[],
|
||||
): Promise<void> {
|
||||
for (const file of files) {
|
||||
await daemonWriteFile(node, serverUuid, joinServerPath(destination, file.path), file.data);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeCfgValue(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function buildMysqlConnectionString(database: ManagedServerDatabaseRecord): string {
|
||||
return `mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}?charset=utf8mb4`;
|
||||
}
|
||||
|
||||
function renderFivemServerConfig(
|
||||
serverName: string,
|
||||
description: string | null | undefined,
|
||||
database: ManagedServerDatabaseRecord,
|
||||
): string {
|
||||
const safeServerName = escapeCfgValue(serverName.trim() || 'QBCore Server');
|
||||
const safeProjectDescription = escapeCfgValue(
|
||||
description?.trim() || 'QBCore server provisioned by Source GamePanel.',
|
||||
);
|
||||
const rconPassword = randomBytes(16).toString('hex');
|
||||
const mysqlConnectionString = escapeCfgValue(buildMysqlConnectionString(database));
|
||||
|
||||
return `# Generated by Source GamePanel
|
||||
# QBCore resources and base dependencies are installed automatically.
|
||||
|
||||
endpoint_add_tcp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
|
||||
endpoint_add_udp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
|
||||
|
||||
sv_maxclients "32"
|
||||
sv_hostname "${safeServerName}"
|
||||
sets sv_projectName "[QBCore] ${safeServerName}"
|
||||
sets sv_projectDesc "${safeProjectDescription}"
|
||||
sets locale "en-US"
|
||||
sets tags "qbcore, qb-core, roleplay, source-gamepanel"
|
||||
set steam_webApiKey "none"
|
||||
set resources_useSystemChat "true"
|
||||
set mysql_connection_string "${mysqlConnectionString}"
|
||||
|
||||
setr qb_locale "en"
|
||||
setr UseTarget "false"
|
||||
setr voice_useNativeAudio "true"
|
||||
setr voice_useSendingRangeOnly "true"
|
||||
setr voice_defaultCycle "GRAVE"
|
||||
setr voice_defaultVolume "0.3"
|
||||
setr voice_enableRadioAnim "1"
|
||||
setr voice_syncData "1"
|
||||
|
||||
sv_scriptHookAllowed "0"
|
||||
sv_endpointprivacy "true"
|
||||
rcon_password "${rconPassword}"
|
||||
|
||||
ensure mapmanager
|
||||
ensure chat
|
||||
ensure spawnmanager
|
||||
ensure sessionmanager
|
||||
ensure basic-gamemode
|
||||
ensure hardcap
|
||||
ensure baseevents
|
||||
|
||||
ensure qb-core
|
||||
ensure [qb]
|
||||
ensure [standalone]
|
||||
ensure [voice]
|
||||
ensure [defaultmaps]
|
||||
|
||||
add_ace group.admin command allow
|
||||
add_ace group.admin command.quit deny
|
||||
add_ace resource.qb-core command allow
|
||||
add_ace qbcore.god command allow
|
||||
add_principal qbcore.god group.admin
|
||||
add_principal qbcore.god qbcore.admin
|
||||
add_principal qbcore.admin qbcore.mod
|
||||
`;
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('Server responded with NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
|
||||
export function isFivemQbCoreGame(gameSlug: string): boolean {
|
||||
return gameSlug.trim().toLowerCase() === 'fivem';
|
||||
}
|
||||
|
||||
export async function ensureFivemQbCoreDatabase(
|
||||
app: FastifyInstance,
|
||||
context: Pick<FivemProvisionContext, 'node' | 'serverId' | 'serverUuid'>,
|
||||
): Promise<ManagedServerDatabaseRecord> {
|
||||
const existing = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.where(eq(serverDatabases.serverId, context.serverId))
|
||||
.orderBy(asc(serverDatabases.createdAt));
|
||||
|
||||
const preferred =
|
||||
existing.find((database) => database.name.trim().toLowerCase() === QBCORE_DATABASE_NAME) ??
|
||||
existing[0];
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const managedDatabase = await daemonCreateDatabase(context.node, {
|
||||
serverUuid: context.serverUuid,
|
||||
name: QBCORE_DATABASE_NAME,
|
||||
});
|
||||
|
||||
try {
|
||||
const [created] = await app.db
|
||||
.insert(serverDatabases)
|
||||
.values({
|
||||
serverId: context.serverId,
|
||||
name: QBCORE_DATABASE_NAME,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
password: managedDatabase.password,
|
||||
host: managedDatabase.host,
|
||||
port: managedDatabase.port,
|
||||
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
|
||||
})
|
||||
.returning({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
throw new Error('Failed to persist managed database metadata');
|
||||
}
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
try {
|
||||
await daemonDeleteDatabase(context.node, {
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
app.log.error(
|
||||
{
|
||||
cleanupError,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
},
|
||||
'Failed to roll back managed MySQL database after metadata save failure',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFivemQbCoreDatabase(
|
||||
app: FastifyInstance,
|
||||
context: Pick<FivemProvisionContext, 'node' | 'serverId'>,
|
||||
): Promise<void> {
|
||||
const [database] = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.where(
|
||||
and(
|
||||
eq(serverDatabases.serverId, context.serverId),
|
||||
eq(serverDatabases.name, QBCORE_DATABASE_NAME),
|
||||
),
|
||||
);
|
||||
|
||||
if (!database) return;
|
||||
|
||||
await daemonDeleteDatabase(context.node, {
|
||||
databaseName: database.databaseName,
|
||||
username: database.username,
|
||||
});
|
||||
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, database.id));
|
||||
}
|
||||
|
||||
async function installGitHubResource(
|
||||
app: FastifyInstance,
|
||||
context: FivemProvisionContext,
|
||||
resource: GitHubArchiveResource,
|
||||
): Promise<void> {
|
||||
const archiveUrl = `https://codeload.github.com/${resource.owner}/${resource.repo}/tar.gz/refs/heads/${encodeURIComponent(resource.ref)}`;
|
||||
const archive = await downloadBinary(archiveUrl, GITHUB_ARCHIVE_MAX_BYTES);
|
||||
let files = await extractTarFiles(gunzipSync(archive));
|
||||
files = stripSharedTopLevelDirectory(files);
|
||||
if (resource.subpath) {
|
||||
files = filterFilesBySubpath(files, resource.subpath);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new Error(
|
||||
`GitHub archive had no files: ${resource.owner}/${resource.repo}@${resource.ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
destination: resource.destination,
|
||||
filesWritten: files.length,
|
||||
repo: `${resource.owner}/${resource.repo}`,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
},
|
||||
'Installed FiveM GitHub resource',
|
||||
);
|
||||
}
|
||||
|
||||
async function installRemoteArchive(
|
||||
app: FastifyInstance,
|
||||
context: FivemProvisionContext,
|
||||
resource: RemoteArchiveResource,
|
||||
): Promise<void> {
|
||||
const archive = await downloadBinary(resource.url, URL_ARCHIVE_MAX_BYTES);
|
||||
let files = await extractArchive(archive, resource.url);
|
||||
if (resource.collapseTopLevelDirectory) {
|
||||
files = stripSharedTopLevelDirectory(files);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Remote archive had no files: ${resource.url}`);
|
||||
}
|
||||
|
||||
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
destination: resource.destination,
|
||||
filesWritten: files.length,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
url: resource.url,
|
||||
},
|
||||
'Installed FiveM remote archive',
|
||||
);
|
||||
}
|
||||
|
||||
export async function provisionFivemQbCoreServer(
|
||||
app: FastifyInstance,
|
||||
context: FivemProvisionContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await daemonReadFile(context.node, context.serverUuid, FIVE_M_QBCORE_MARKER_PATH);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const database = await ensureFivemQbCoreDatabase(app, context);
|
||||
const qbCoreSql = await downloadText(QBCORE_SQL_URL);
|
||||
|
||||
await daemonImportDatabaseSql(context.node, {
|
||||
databaseName: database.databaseName,
|
||||
sql: qbCoreSql,
|
||||
});
|
||||
|
||||
for (const resource of FIVEM_GITHUB_RESOURCES) {
|
||||
await installGitHubResource(app, context, resource);
|
||||
}
|
||||
|
||||
for (const resource of FIVEM_REMOTE_ARCHIVES) {
|
||||
await installRemoteArchive(app, context, resource);
|
||||
}
|
||||
|
||||
try {
|
||||
await daemonDeleteFiles(context.node, context.serverUuid, [
|
||||
'/resources/[cfx-default]/[gameplay]/chat',
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await daemonWriteFile(
|
||||
context.node,
|
||||
context.serverUuid,
|
||||
'/server.cfg',
|
||||
renderFivemServerConfig(context.serverName, context.serverDescription, database),
|
||||
);
|
||||
|
||||
await daemonWriteFile(
|
||||
context.node,
|
||||
context.serverUuid,
|
||||
FIVE_M_QBCORE_MARKER_PATH,
|
||||
JSON.stringify(
|
||||
{
|
||||
installedAt: new Date().toISOString(),
|
||||
manifestVersion: 1,
|
||||
resourceCount: FIVEM_GITHUB_RESOURCES.length + FIVEM_REMOTE_ARCHIVES.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await app.db
|
||||
.update(servers)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(servers.id, context.serverId));
|
||||
}
|
||||
+15
-3
@@ -15,13 +15,25 @@ const ACCESS_TOKEN_EXPIRY = '15m';
|
||||
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||
|
||||
export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string {
|
||||
return app.jwt.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY });
|
||||
const signer = (app as any).jwt?.sign;
|
||||
if (typeof signer !== 'function') {
|
||||
throw new Error('JWT signer is not configured');
|
||||
}
|
||||
return signer(payload, { expiresIn: ACCESS_TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string {
|
||||
return (app as any).jwtRefresh.sign(payload, { expiresIn: REFRESH_TOKEN_EXPIRY });
|
||||
const signer = (app as any).jwt?.refresh?.sign ?? (app as any).jwt?.jwtRefresh?.sign;
|
||||
if (typeof signer !== 'function') {
|
||||
throw new Error('Refresh JWT signer is not configured');
|
||||
}
|
||||
return signer(payload, { expiresIn: REFRESH_TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload {
|
||||
return (app as any).jwtRefresh.verify(token) as RefreshTokenPayload;
|
||||
const verifier = (app as any).jwt?.refresh?.verify ?? (app as any).jwt?.jwtRefresh?.verify;
|
||||
if (typeof verifier !== 'function') {
|
||||
throw new Error('Refresh JWT verifier is not configured');
|
||||
}
|
||||
return verifier(token) as RefreshTokenPayload;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import * as tar from 'tar-stream';
|
||||
import type { Headers } from 'tar-stream';
|
||||
import * as unzipper from 'unzipper';
|
||||
import type {
|
||||
GameAutomationRule,
|
||||
ServerAutomationEvent,
|
||||
ServerAutomationAction,
|
||||
ServerAutomationGitHubReleaseExtractAction,
|
||||
ServerAutomationHttpDirectoryExtractAction,
|
||||
ServerAutomationInsertBeforeLineAction,
|
||||
ServerAutomationWriteFileAction,
|
||||
} from '@source/shared';
|
||||
import {
|
||||
daemonReadFile,
|
||||
daemonSendCommand,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
import {
|
||||
CS2_PERSISTED_SERVER_CFG_PATH,
|
||||
CS2_SERVER_CFG_PATH,
|
||||
DEFAULT_CS2_SERVER_CFG,
|
||||
} from './cs2-server-config.js';
|
||||
|
||||
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const AUTOMATION_MARKER_ROOT = '/.gamepanel/automation';
|
||||
const CS2_GAMEINFO_PATH = '/game/csgo/gameinfo.gi';
|
||||
const CS2_GAMEINFO_METAMOD_LINE = '\t\t\tGame csgo/addons/metamod';
|
||||
const CS2_GAMEINFO_INSERT_BEFORE_PATTERN = '^\\s*Game\\s+csgo\\s*$';
|
||||
const CS2_GAMEINFO_EXISTS_PATTERN = '^\\s*Game\\s+csgo/addons/metamod\\s*$';
|
||||
const CS2_GAMEINFO_INSERT_ACTION_ID = 'ensure-cs2-metamod-gameinfo-entry';
|
||||
const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction = {
|
||||
id: CS2_GAMEINFO_INSERT_ACTION_ID,
|
||||
type: 'insert_before_line',
|
||||
path: CS2_GAMEINFO_PATH,
|
||||
line: CS2_GAMEINFO_METAMOD_LINE,
|
||||
beforePattern: CS2_GAMEINFO_INSERT_BEFORE_PATTERN,
|
||||
existsPattern: CS2_GAMEINFO_EXISTS_PATTERN,
|
||||
skipIfExists: true,
|
||||
};
|
||||
|
||||
const DEFAULT_CS2_SERVER_CONFIG_ACTION: ServerAutomationWriteFileAction = {
|
||||
id: 'write-cs2-default-server-config',
|
||||
type: 'write_file',
|
||||
path: `/${CS2_SERVER_CFG_PATH}`,
|
||||
data: DEFAULT_CS2_SERVER_CFG,
|
||||
};
|
||||
|
||||
const DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION: ServerAutomationWriteFileAction = {
|
||||
id: 'write-cs2-persisted-server-config',
|
||||
type: 'write_file',
|
||||
path: `/${CS2_PERSISTED_SERVER_CFG_PATH}`,
|
||||
data: DEFAULT_CS2_SERVER_CFG,
|
||||
};
|
||||
|
||||
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
|
||||
cs2: [
|
||||
{
|
||||
id: 'cs2-write-default-server-config',
|
||||
event: 'server.install.completed',
|
||||
enabled: true,
|
||||
runOncePerServer: true,
|
||||
continueOnError: false,
|
||||
actions: [
|
||||
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
|
||||
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cs2-install-latest-metamod',
|
||||
event: 'server.install.completed',
|
||||
enabled: true,
|
||||
runOncePerServer: true,
|
||||
continueOnError: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'install-cs2-metamod',
|
||||
type: 'http_directory_extract',
|
||||
indexUrl: 'https://mms.alliedmods.net/mmsdrop/2.0/',
|
||||
assetNamePattern: '^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$',
|
||||
destination: '/game/csgo',
|
||||
stripComponents: 0,
|
||||
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
|
||||
},
|
||||
{ ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cs2-install-latest-counterstrikesharp-runtime',
|
||||
event: 'server.install.completed',
|
||||
enabled: true,
|
||||
runOncePerServer: true,
|
||||
continueOnError: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'install-cs2-runtime',
|
||||
type: 'github_release_extract',
|
||||
owner: 'roflmuffin',
|
||||
repo: 'CounterStrikeSharp',
|
||||
assetNamePatterns: [
|
||||
'^counterstrikesharp-with-runtime-.*linux.*\\.zip$',
|
||||
'^counterstrikesharp-with-runtime.*\\.zip$',
|
||||
],
|
||||
destination: '/game/csgo',
|
||||
stripComponents: 0,
|
||||
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface ServerAutomationContext {
|
||||
serverId: string;
|
||||
serverUuid: string;
|
||||
gameSlug: string;
|
||||
event: ServerAutomationEvent;
|
||||
node: DaemonNodeConnection;
|
||||
automationRulesRaw: unknown;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface ServerAutomationRunResult {
|
||||
workflowsMatched: number;
|
||||
workflowsExecuted: number;
|
||||
workflowsSkipped: number;
|
||||
workflowsFailed: number;
|
||||
actionFailures: number;
|
||||
failures: ServerAutomationFailure[];
|
||||
}
|
||||
|
||||
interface ExtractedFile {
|
||||
path: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
export interface ServerAutomationFailure {
|
||||
level: 'action' | 'workflow';
|
||||
workflowId: string;
|
||||
actionId?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface GitHubReleaseResponse {
|
||||
tag_name: string;
|
||||
assets: GitHubReleaseAsset[];
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function readWorkflowId(value: unknown): string | null {
|
||||
if (!isObject(value)) return null;
|
||||
const id = value.id;
|
||||
if (typeof id !== 'string' || id.trim() === '') return null;
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizeWorkflow(
|
||||
gameSlug: string,
|
||||
workflow: GameAutomationRule,
|
||||
): GameAutomationRule {
|
||||
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
|
||||
|
||||
if (workflow.id === 'cs2-write-default-server-config') {
|
||||
return {
|
||||
...workflow,
|
||||
actions: [
|
||||
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
|
||||
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') {
|
||||
const normalizedActions = workflow.actions.map((action) => {
|
||||
if (action.type !== 'github_release_extract') return action;
|
||||
if (action.id !== 'install-cs2-runtime') return action;
|
||||
|
||||
const destination = (action.destination ?? '').trim();
|
||||
if (destination !== '' && destination !== '/') return action;
|
||||
|
||||
return {
|
||||
...action,
|
||||
destination: '/game/csgo',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
actions: normalizedActions,
|
||||
};
|
||||
}
|
||||
|
||||
if (workflow.id === 'cs2-install-latest-metamod') {
|
||||
const hasGameInfoAction = workflow.actions.some(
|
||||
(action) =>
|
||||
action.type === 'insert_before_line' &&
|
||||
(action.id === CS2_GAMEINFO_INSERT_ACTION_ID || action.path === CS2_GAMEINFO_PATH),
|
||||
);
|
||||
|
||||
if (hasGameInfoAction) return workflow;
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
actions: [...workflow.actions, { ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION }],
|
||||
};
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] {
|
||||
const defaults = DEFAULT_GAME_AUTOMATION_RULES[gameSlug.toLowerCase()] ?? [];
|
||||
if (!Array.isArray(raw)) {
|
||||
return defaults.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
}
|
||||
|
||||
const configured = raw as GameAutomationRule[];
|
||||
if (defaults.length === 0) {
|
||||
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
}
|
||||
|
||||
const existingIds = new Set(
|
||||
raw
|
||||
.map(readWorkflowId)
|
||||
.filter((workflowId): workflowId is string => workflowId !== null),
|
||||
);
|
||||
|
||||
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
|
||||
if (missingDefaults.length === 0) {
|
||||
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
}
|
||||
|
||||
return [...configured, ...missingDefaults].map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||
}
|
||||
|
||||
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
|
||||
const cleanId = workflowId.trim().replace(/[^a-zA-Z0-9._-]+/g, '-');
|
||||
return `${AUTOMATION_MARKER_ROOT}/${event}/${cleanId}.json`;
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePathSegments(path: string): string[] {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||
}
|
||||
|
||||
function joinServerPath(base: string, relative: string): string {
|
||||
const baseSegments = normalizePathSegments(base);
|
||||
const relativeSegments = normalizePathSegments(relative);
|
||||
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function normalizeArchivePath(path: string, stripComponents = 0): string | null {
|
||||
const segments = normalizePathSegments(path);
|
||||
const stripped = segments.slice(Math.max(0, stripComponents));
|
||||
if (stripped.length === 0) return null;
|
||||
return stripped.join('/');
|
||||
}
|
||||
|
||||
async function hasMarker(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
event: ServerAutomationEvent,
|
||||
workflowId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await daemonReadFile(node, serverUuid, markerPath(event, workflowId));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMarker(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
event: ServerAutomationEvent,
|
||||
workflowId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await daemonWriteFile(
|
||||
node,
|
||||
serverUuid,
|
||||
markerPath(event, workflowId),
|
||||
JSON.stringify(payload, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function githubHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
};
|
||||
|
||||
const token = process.env.GITHUB_TOKEN?.trim();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function compileAssetPatterns(patterns: string[]): RegExp[] {
|
||||
const compiled: RegExp[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const tryCompile = (pattern: string) => {
|
||||
const key = pattern.trim();
|
||||
if (!key || seen.has(key)) return;
|
||||
try {
|
||||
compiled.push(new RegExp(key, 'i'));
|
||||
seen.add(key);
|
||||
} catch {
|
||||
// Ignore invalid regex patterns in configuration.
|
||||
}
|
||||
};
|
||||
|
||||
for (const pattern of patterns) {
|
||||
tryCompile(pattern);
|
||||
|
||||
// Some JSON-stored patterns may be over-escaped (e.g. "\\\\." instead of "\\.").
|
||||
// Collapse double backslashes once and compile a fallback variant.
|
||||
if (pattern.includes('\\\\')) {
|
||||
tryCompile(pattern.replace(/\\\\/g, '\\'));
|
||||
}
|
||||
}
|
||||
|
||||
return compiled;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(
|
||||
action: ServerAutomationGitHubReleaseExtractAction,
|
||||
): Promise<GitHubReleaseResponse> {
|
||||
const releaseUrl = `https://api.github.com/repos/${action.owner}/${action.repo}/releases/latest`;
|
||||
const response = await fetch(releaseUrl, {
|
||||
headers: githubHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub latest release request failed (${action.owner}/${action.repo}): HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const release = (await response.json()) as GitHubReleaseResponse;
|
||||
if (!Array.isArray(release.assets)) {
|
||||
throw new Error(`GitHub release payload has no assets (${action.owner}/${action.repo})`);
|
||||
}
|
||||
|
||||
return release;
|
||||
}
|
||||
|
||||
interface DirectoryAssetCandidate {
|
||||
name: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
function extractNumberParts(value: string): number[] {
|
||||
const matches = value.match(/\d+/g);
|
||||
if (!matches) return [];
|
||||
return matches
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.filter((num) => Number.isFinite(num));
|
||||
}
|
||||
|
||||
function compareNumberPartsDesc(a: number[], b: number[]): number {
|
||||
const maxLength = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < maxLength; i += 1) {
|
||||
const left = a[i] ?? -1;
|
||||
const right = b[i] ?? -1;
|
||||
if (left !== right) {
|
||||
return right - left;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function pickLatestDirectoryAsset(candidates: DirectoryAssetCandidate[]): DirectoryAssetCandidate {
|
||||
const sorted = [...candidates].sort((left, right) => {
|
||||
const numberDiff = compareNumberPartsDesc(
|
||||
extractNumberParts(left.name),
|
||||
extractNumberParts(right.name),
|
||||
);
|
||||
if (numberDiff !== 0) return numberDiff;
|
||||
return right.name.localeCompare(left.name);
|
||||
});
|
||||
|
||||
return sorted[0] ?? candidates[0]!;
|
||||
}
|
||||
|
||||
function extractDirectoryCandidates(
|
||||
html: string,
|
||||
indexUrl: string,
|
||||
assetPattern: RegExp,
|
||||
): DirectoryAssetCandidate[] {
|
||||
const hrefRegex = /href\s*=\s*(['"])(.*?)\1/gi;
|
||||
const candidates: DirectoryAssetCandidate[] = [];
|
||||
|
||||
let match: RegExpExecArray | null = null;
|
||||
while ((match = hrefRegex.exec(html)) !== null) {
|
||||
const href = (match[2] ?? '').trim();
|
||||
if (!href || href.endsWith('/')) continue;
|
||||
|
||||
try {
|
||||
const resolvedUrl = new URL(href, indexUrl);
|
||||
const filename = decodeURIComponent(resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '');
|
||||
if (!filename || !assetPattern.test(filename)) continue;
|
||||
|
||||
candidates.push({
|
||||
name: filename,
|
||||
downloadUrl: resolvedUrl.toString(),
|
||||
});
|
||||
} catch {
|
||||
// Ignore malformed links.
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function resolveLatestDirectoryAsset(
|
||||
action: ServerAutomationHttpDirectoryExtractAction,
|
||||
): Promise<DirectoryAssetCandidate> {
|
||||
let assetPattern: RegExp;
|
||||
try {
|
||||
assetPattern = new RegExp(action.assetNamePattern, 'i');
|
||||
} catch {
|
||||
throw new Error(`Invalid assetNamePattern regex for action ${action.id}`);
|
||||
}
|
||||
|
||||
const response = await fetch(action.indexUrl, {
|
||||
headers: { 'User-Agent': 'SourceGamePanel/1.0' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Directory listing request failed (${action.indexUrl}): HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const candidates = extractDirectoryCandidates(html, action.indexUrl, assetPattern);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
`No matching directory asset for ${action.indexUrl} with pattern: ${action.assetNamePattern}`,
|
||||
);
|
||||
}
|
||||
|
||||
return pickLatestDirectoryAsset(candidates);
|
||||
}
|
||||
|
||||
async function downloadBinary(url: string, maxBytes: number): Promise<Buffer> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DEFAULT_DOWNLOAD_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed with HTTP ${response.status}: ${url}`);
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length') ?? '0');
|
||||
if (contentLength > maxBytes) {
|
||||
throw new Error(`Artifact exceeds max size (${contentLength} > ${maxBytes} bytes)`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) {
|
||||
throw new Error('Downloaded artifact is empty');
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new Error(`Artifact exceeds max size (${buffer.length} > ${maxBytes} bytes)`);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractZipFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
|
||||
const archive = await unzipper.Open.buffer(buffer);
|
||||
const files: ExtractedFile[] = [];
|
||||
|
||||
for (const entry of archive.files) {
|
||||
if (entry.type !== 'File') continue;
|
||||
|
||||
const normalized = normalizeArchivePath(entry.path, stripComponents);
|
||||
if (!normalized) continue;
|
||||
|
||||
files.push({
|
||||
path: normalized,
|
||||
data: await entry.buffer(),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractTarFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const extract = tar.extract();
|
||||
const files: ExtractedFile[] = [];
|
||||
|
||||
extract.on('entry', (header: Headers, stream, next) => {
|
||||
const type = header.type ?? 'file';
|
||||
const normalized = normalizeArchivePath(header.name, stripComponents);
|
||||
const isFileType = type === 'file' || type === 'contiguous-file';
|
||||
|
||||
if (!isFileType || !normalized) {
|
||||
stream.resume();
|
||||
stream.on('end', next);
|
||||
stream.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on('end', () => {
|
||||
files.push({ path: normalized, data: Buffer.concat(chunks) });
|
||||
next();
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
extract.on('finish', () => resolve(files));
|
||||
extract.on('error', reject);
|
||||
extract.end(buffer);
|
||||
});
|
||||
}
|
||||
|
||||
async function extractArtifactFiles(
|
||||
artifact: Buffer,
|
||||
assetName: string,
|
||||
stripComponents = 0,
|
||||
): Promise<ExtractedFile[]> {
|
||||
const name = assetName.toLowerCase();
|
||||
|
||||
if (name.endsWith('.zip')) {
|
||||
return extractZipFiles(artifact, stripComponents);
|
||||
}
|
||||
|
||||
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
|
||||
return extractTarFiles(gunzipSync(artifact), stripComponents);
|
||||
}
|
||||
|
||||
if (name.endsWith('.tar')) {
|
||||
return extractTarFiles(artifact, stripComponents);
|
||||
}
|
||||
|
||||
const normalized = normalizeArchivePath(assetName, stripComponents) ?? assetName;
|
||||
return [{ path: normalized, data: artifact }];
|
||||
}
|
||||
|
||||
async function executeGitHubReleaseExtract(
|
||||
app: FastifyInstance,
|
||||
context: ServerAutomationContext,
|
||||
action: ServerAutomationGitHubReleaseExtractAction,
|
||||
): Promise<void> {
|
||||
const release = await fetchLatestRelease(action);
|
||||
const patterns = compileAssetPatterns(action.assetNamePatterns);
|
||||
|
||||
if (patterns.length === 0) {
|
||||
throw new Error(`No valid asset regex pattern for action ${action.id}`);
|
||||
}
|
||||
|
||||
const asset = release.assets.find((candidate) =>
|
||||
patterns.some((pattern) => pattern.test(candidate.name)),
|
||||
);
|
||||
|
||||
if (!asset) {
|
||||
throw new Error(
|
||||
`No matching release asset for ${action.owner}/${action.repo} with patterns: ${action.assetNamePatterns.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
|
||||
const files = await extractArtifactFiles(
|
||||
artifact,
|
||||
asset.name,
|
||||
Number(action.stripComponents) || 0,
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Extracted artifact has no files: ${asset.name}`);
|
||||
}
|
||||
|
||||
const destination = action.destination ?? '/';
|
||||
|
||||
for (const file of files) {
|
||||
const targetPath = joinServerPath(destination, file.path);
|
||||
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
|
||||
}
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
release: release.tag_name,
|
||||
asset: asset.name,
|
||||
filesWritten: files.length,
|
||||
},
|
||||
'Automation action completed: github_release_extract',
|
||||
);
|
||||
}
|
||||
|
||||
async function executeHttpDirectoryExtract(
|
||||
app: FastifyInstance,
|
||||
context: ServerAutomationContext,
|
||||
action: ServerAutomationHttpDirectoryExtractAction,
|
||||
): Promise<void> {
|
||||
const selectedAsset = await resolveLatestDirectoryAsset(action);
|
||||
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
|
||||
const files = await extractArtifactFiles(
|
||||
artifact,
|
||||
selectedAsset.name,
|
||||
Number(action.stripComponents) || 0,
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Extracted artifact has no files: ${selectedAsset.name}`);
|
||||
}
|
||||
|
||||
const destination = action.destination ?? '/';
|
||||
for (const file of files) {
|
||||
const targetPath = joinServerPath(destination, file.path);
|
||||
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
|
||||
}
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
source: action.indexUrl,
|
||||
asset: selectedAsset.name,
|
||||
filesWritten: files.length,
|
||||
},
|
||||
'Automation action completed: http_directory_extract',
|
||||
);
|
||||
}
|
||||
|
||||
async function executeInsertBeforeLine(
|
||||
app: FastifyInstance,
|
||||
context: ServerAutomationContext,
|
||||
action: ServerAutomationInsertBeforeLineAction,
|
||||
): Promise<void> {
|
||||
const file = await daemonReadFile(context.node, context.serverUuid, action.path);
|
||||
const content = file.data.toString('utf8');
|
||||
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
||||
const hasTrailingEol = content.endsWith('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
if (hasTrailingEol && lines[lines.length - 1] === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
const skipIfExists = action.skipIfExists !== false;
|
||||
if (skipIfExists) {
|
||||
const existsRegex = action.existsPattern
|
||||
? new RegExp(action.existsPattern, 'i')
|
||||
: null;
|
||||
|
||||
const alreadyExists = lines.some((line) =>
|
||||
existsRegex ? existsRegex.test(line) : line === action.line,
|
||||
);
|
||||
|
||||
if (alreadyExists) {
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
path: action.path,
|
||||
},
|
||||
'Automation action skipped: line already present',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let beforeRegex: RegExp;
|
||||
try {
|
||||
beforeRegex = new RegExp(action.beforePattern);
|
||||
} catch {
|
||||
throw new Error(`Invalid beforePattern regex for action ${action.id}`);
|
||||
}
|
||||
|
||||
const insertIndex = lines.findIndex((line) => beforeRegex.test(line));
|
||||
if (insertIndex < 0) {
|
||||
throw new Error(
|
||||
`Could not find insertion point in ${action.path} with pattern: ${action.beforePattern}`,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = [...lines.slice(0, insertIndex), action.line, ...lines.slice(insertIndex)];
|
||||
const output = `${updated.join(eol)}${hasTrailingEol ? eol : ''}`;
|
||||
await daemonWriteFile(context.node, context.serverUuid, action.path, output);
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
path: action.path,
|
||||
},
|
||||
'Automation action completed: insert_before_line',
|
||||
);
|
||||
}
|
||||
|
||||
async function executeAction(
|
||||
app: FastifyInstance,
|
||||
context: ServerAutomationContext,
|
||||
action: ServerAutomationAction,
|
||||
): Promise<void> {
|
||||
switch (action.type) {
|
||||
case 'github_release_extract': {
|
||||
await executeGitHubReleaseExtract(app, context, action);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'http_directory_extract': {
|
||||
await executeHttpDirectoryExtract(app, context, action);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'insert_before_line': {
|
||||
await executeInsertBeforeLine(app, context, action);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'write_file': {
|
||||
const payload =
|
||||
action.encoding === 'base64'
|
||||
? Buffer.from(action.data, 'base64')
|
||||
: action.data;
|
||||
|
||||
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
path: action.path,
|
||||
},
|
||||
'Automation action completed: write_file',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'send_command': {
|
||||
await daemonSendCommand(context.node, context.serverUuid, action.command);
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
event: context.event,
|
||||
actionId: action.id,
|
||||
command: action.command,
|
||||
},
|
||||
'Automation action completed: send_command',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
const unknownAction = action as { type?: unknown };
|
||||
throw new Error(`Unsupported automation action type: ${String(unknownAction.type)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runServerAutomationEvent(
|
||||
app: FastifyInstance,
|
||||
context: ServerAutomationContext,
|
||||
): Promise<ServerAutomationRunResult> {
|
||||
const workflows = asAutomationRules(context.automationRulesRaw, context.gameSlug)
|
||||
.filter((rule) => isObject(rule))
|
||||
.filter((rule) => rule.event === context.event)
|
||||
.filter((rule) => rule.enabled !== false)
|
||||
.filter((rule) => Array.isArray(rule.actions) && rule.actions.length > 0);
|
||||
|
||||
const result: ServerAutomationRunResult = {
|
||||
workflowsMatched: workflows.length,
|
||||
workflowsExecuted: 0,
|
||||
workflowsSkipped: 0,
|
||||
workflowsFailed: 0,
|
||||
actionFailures: 0,
|
||||
failures: [],
|
||||
};
|
||||
|
||||
if (workflows.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const runOnce = workflow.runOncePerServer !== false;
|
||||
|
||||
try {
|
||||
if (
|
||||
runOnce &&
|
||||
!context.force &&
|
||||
await hasMarker(context.node, context.serverUuid, context.event, workflow.id)
|
||||
) {
|
||||
result.workflowsSkipped += 1;
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
'Skipping automation workflow (already completed)',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const action of workflow.actions) {
|
||||
try {
|
||||
await executeAction(app, context, action);
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
result.actionFailures += 1;
|
||||
result.failures.push({
|
||||
level: 'action',
|
||||
workflowId: workflow.id,
|
||||
actionId: action.id,
|
||||
message,
|
||||
});
|
||||
app.log.error(
|
||||
{
|
||||
err: error,
|
||||
errorMessage: message,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
workflowId: workflow.id,
|
||||
actionId: action.id,
|
||||
},
|
||||
'Automation action failed',
|
||||
);
|
||||
|
||||
if (workflow.continueOnError) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (runOnce) {
|
||||
await writeMarker(context.node, context.serverUuid, context.event, workflow.id, {
|
||||
workflowId: workflow.id,
|
||||
event: context.event,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
'Automation workflow completed',
|
||||
);
|
||||
result.workflowsExecuted += 1;
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
result.workflowsFailed += 1;
|
||||
result.failures.push({
|
||||
level: 'workflow',
|
||||
workflowId: workflow.id,
|
||||
message,
|
||||
});
|
||||
app.log.error(
|
||||
{
|
||||
err: error,
|
||||
errorMessage: message,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
gameSlug: context.gameSlug,
|
||||
event: context.event,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
'Automation workflow failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { AccessTokenPayload } from '../lib/jwt.js';
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
jwtRefresh: FastifyInstance['jwt'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +27,13 @@ export default fp(async (app: FastifyInstance) => {
|
||||
// Access token JWT
|
||||
await app.register(jwt, {
|
||||
secret: jwtSecret,
|
||||
namespace: 'jwt',
|
||||
});
|
||||
|
||||
// Refresh token JWT (separate namespace)
|
||||
await app.register(jwt, {
|
||||
secret: jwtRefreshSecret,
|
||||
namespace: 'jwtRefresh',
|
||||
namespace: 'refresh',
|
||||
decoratorName: 'jwtRefresh',
|
||||
});
|
||||
|
||||
// Auth decorator
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { createDb, type Database } from '@source/database';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -17,5 +18,26 @@ export default fp(async (app: FastifyInstance) => {
|
||||
const db = createDb(databaseUrl);
|
||||
app.decorate('db', db);
|
||||
|
||||
await db.execute(sql.raw(`
|
||||
CREATE TABLE IF NOT EXISTS server_databases (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name varchar(255) NOT NULL,
|
||||
database_name varchar(255) NOT NULL UNIQUE,
|
||||
username varchar(64) NOT NULL UNIQUE,
|
||||
password text NOT NULL,
|
||||
host varchar(255) NOT NULL,
|
||||
port integer NOT NULL,
|
||||
phpmyadmin_url text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`));
|
||||
await db.execute(
|
||||
sql.raw(
|
||||
'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)',
|
||||
),
|
||||
);
|
||||
|
||||
app.log.info('Database connected');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import { nodes, organizationMembers, servers } from '@source/database';
|
||||
import { ROLES } from '@source/shared';
|
||||
import type { Role } from '@source/shared';
|
||||
import type { AccessTokenPayload } from '../lib/jwt.js';
|
||||
import {
|
||||
daemonOpenConsoleStream,
|
||||
daemonSendCommand,
|
||||
type DaemonConsoleStreamHandle,
|
||||
type DaemonNodeConnection,
|
||||
} from '../lib/daemon.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
io: SocketIOServer;
|
||||
}
|
||||
}
|
||||
|
||||
type ConsolePermission = 'console.read' | 'console.write';
|
||||
type ConsoleCommandAck = {
|
||||
requestId: string | null;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
interface SharedConsoleStream {
|
||||
handle: DaemonConsoleStreamHandle;
|
||||
subscribers: number;
|
||||
}
|
||||
|
||||
function roomForServer(serverId: string): string {
|
||||
return `server:console:${serverId}`;
|
||||
}
|
||||
|
||||
export default fp(async (app: FastifyInstance) => {
|
||||
const io = new SocketIOServer(app.server, {
|
||||
path: '/socket.io',
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
},
|
||||
});
|
||||
|
||||
app.decorate('io', io);
|
||||
|
||||
const serverStreams = new Map<string, SharedConsoleStream>();
|
||||
const socketSubscriptions = new Map<string, string>();
|
||||
|
||||
const clearServerSubscriptions = (serverId: string) => {
|
||||
for (const [socketId, subscribedServerId] of socketSubscriptions.entries()) {
|
||||
if (subscribedServerId === serverId) {
|
||||
socketSubscriptions.delete(socketId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
io.use((socket, next) => {
|
||||
const token = typeof socket.handshake.auth?.token === 'string'
|
||||
? socket.handshake.auth.token
|
||||
: null;
|
||||
|
||||
if (!token) {
|
||||
next(new Error('Unauthorized'));
|
||||
return;
|
||||
}
|
||||
|
||||
const verifier = (app as any).jwt?.verify;
|
||||
if (typeof verifier !== 'function') {
|
||||
next(new Error('Authentication is not configured'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = verifier(token) as AccessTokenPayload;
|
||||
(socket.data as { user?: AccessTokenPayload }).user = payload;
|
||||
next();
|
||||
} catch {
|
||||
next(new Error('Unauthorized'));
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const cleanupSocketStream = () => {
|
||||
const subscribedServerId = socketSubscriptions.get(socket.id);
|
||||
if (!subscribedServerId) return;
|
||||
|
||||
socketSubscriptions.delete(socket.id);
|
||||
socket.leave(roomForServer(subscribedServerId));
|
||||
|
||||
const shared = serverStreams.get(subscribedServerId);
|
||||
if (!shared) return;
|
||||
|
||||
shared.subscribers = Math.max(0, shared.subscribers - 1);
|
||||
if (shared.subscribers === 0) {
|
||||
shared.handle.close();
|
||||
serverStreams.delete(subscribedServerId);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('server:console:join', async (payload: unknown) => {
|
||||
const serverId = typeof (payload as { serverId?: unknown })?.serverId === 'string'
|
||||
? ((payload as { serverId: string }).serverId)
|
||||
: '';
|
||||
if (!serverId) {
|
||||
socket.emit('server:console:output', { line: '[error] Invalid server id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = (socket.data as { user?: AccessTokenPayload }).user;
|
||||
if (!user) {
|
||||
socket.emit('server:console:output', { line: '[error] Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const server = await getServerContext(app, serverId);
|
||||
if (!server) {
|
||||
socket.emit('server:console:output', { line: '[error] Server not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await hasConsolePermission(app, user, server.organizationId, 'console.read');
|
||||
if (!allowed) {
|
||||
socket.emit('server:console:output', { line: '[error] Missing permission: console.read' });
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSubscription = socketSubscriptions.get(socket.id);
|
||||
if (previousSubscription === serverId) {
|
||||
return;
|
||||
}
|
||||
cleanupSocketStream();
|
||||
socket.join(roomForServer(serverId));
|
||||
|
||||
let shared = serverStreams.get(serverId);
|
||||
if (!shared) {
|
||||
try {
|
||||
const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid);
|
||||
const room = roomForServer(serverId);
|
||||
|
||||
streamHandle.stream.on('data', (output) => {
|
||||
io.to(room).emit('server:console:output', { line: output.line });
|
||||
});
|
||||
|
||||
streamHandle.stream.on('end', () => {
|
||||
const current = serverStreams.get(serverId);
|
||||
if (current?.handle !== streamHandle) return;
|
||||
serverStreams.delete(serverId);
|
||||
clearServerSubscriptions(serverId);
|
||||
io.to(room).emit('server:console:output', { line: '[console] Stream ended' });
|
||||
io.in(room).socketsLeave(room);
|
||||
});
|
||||
|
||||
streamHandle.stream.on('error', (error) => {
|
||||
const current = serverStreams.get(serverId);
|
||||
if (current?.handle !== streamHandle) return;
|
||||
serverStreams.delete(serverId);
|
||||
clearServerSubscriptions(serverId);
|
||||
app.log.warn(
|
||||
{ error, serverId, serverUuid: server.serverUuid },
|
||||
'Console stream failed',
|
||||
);
|
||||
io.to(room).emit('server:console:output', { line: '[error] Console stream failed' });
|
||||
io.in(room).socketsLeave(room);
|
||||
});
|
||||
|
||||
shared = {
|
||||
handle: streamHandle,
|
||||
subscribers: 0,
|
||||
};
|
||||
serverStreams.set(serverId, shared);
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
||||
'Failed to open console stream',
|
||||
);
|
||||
socket.leave(roomForServer(serverId));
|
||||
socket.emit('server:console:output', { line: '[error] Failed to open console stream' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
shared.subscribers += 1;
|
||||
socketSubscriptions.set(socket.id, serverId);
|
||||
});
|
||||
|
||||
socket.on('server:console:leave', () => {
|
||||
cleanupSocketStream();
|
||||
});
|
||||
|
||||
socket.on('server:console:command', async (payload: unknown) => {
|
||||
const body = payload as {
|
||||
serverId?: unknown;
|
||||
orgId?: unknown;
|
||||
command?: unknown;
|
||||
requestId?: unknown;
|
||||
};
|
||||
|
||||
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
|
||||
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
|
||||
const command = typeof body.command === 'string' ? body.command.trim() : '';
|
||||
const requestId = typeof body.requestId === 'string' && body.requestId.trim()
|
||||
? body.requestId.trim()
|
||||
: null;
|
||||
|
||||
if (!serverId || !orgId || !command) {
|
||||
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
|
||||
const ack: ConsoleCommandAck = {
|
||||
requestId,
|
||||
ok: false,
|
||||
error: 'Invalid command payload',
|
||||
};
|
||||
socket.emit('server:console:command:ack', ack);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = (socket.data as { user?: AccessTokenPayload }).user;
|
||||
if (!user) {
|
||||
socket.emit('server:console:output', { line: '[error] Unauthorized' });
|
||||
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Unauthorized' };
|
||||
socket.emit('server:console:command:ack', ack);
|
||||
return;
|
||||
}
|
||||
|
||||
const server = await getServerContext(app, serverId, orgId);
|
||||
if (!server) {
|
||||
socket.emit('server:console:output', { line: '[error] Server not found' });
|
||||
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Server not found' };
|
||||
socket.emit('server:console:command:ack', ack);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await hasConsolePermission(app, user, orgId, 'console.write');
|
||||
if (!allowed) {
|
||||
socket.emit('server:console:output', { line: '[error] Missing permission: console.write' });
|
||||
const ack: ConsoleCommandAck = {
|
||||
requestId,
|
||||
ok: false,
|
||||
error: 'Missing permission: console.write',
|
||||
};
|
||||
socket.emit('server:console:command:ack', ack);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await daemonSendCommand(server.node, server.serverUuid, command);
|
||||
const ack: ConsoleCommandAck = { requestId, ok: true };
|
||||
socket.emit('server:console:command:ack', ack);
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
||||
'Failed to send console command',
|
||||
);
|
||||
socket.emit('server:console:output', { line: '[error] Failed to send command' });
|
||||
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Failed to send command' };
|
||||
socket.emit('server:console:command:ack', ack);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
cleanupSocketStream();
|
||||
});
|
||||
});
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
for (const stream of serverStreams.values()) {
|
||||
stream.handle.close();
|
||||
}
|
||||
serverStreams.clear();
|
||||
socketSubscriptions.clear();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
io.close(() => resolve());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function hasConsolePermission(
|
||||
app: FastifyInstance,
|
||||
user: AccessTokenPayload,
|
||||
orgId: string,
|
||||
permission: ConsolePermission,
|
||||
): Promise<boolean> {
|
||||
if (user.isSuperAdmin) return true;
|
||||
|
||||
const member = await app.db.query.organizationMembers.findFirst({
|
||||
where: and(
|
||||
eq(organizationMembers.organizationId, orgId),
|
||||
eq(organizationMembers.userId, user.sub),
|
||||
),
|
||||
columns: {
|
||||
role: true,
|
||||
customPermissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) return false;
|
||||
|
||||
const custom = (member.customPermissions ?? {}) as Record<string, boolean>;
|
||||
if (permission in custom) {
|
||||
return Boolean(custom[permission]);
|
||||
}
|
||||
|
||||
const rolePerms = ROLES[member.role as Role]?.permissions ?? [];
|
||||
return (rolePerms as readonly string[]).includes(permission);
|
||||
}
|
||||
|
||||
async function getServerContext(
|
||||
app: FastifyInstance,
|
||||
serverId: string,
|
||||
orgId?: string,
|
||||
): Promise<{
|
||||
organizationId: string;
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
} | null> {
|
||||
const whereClause = orgId
|
||||
? and(eq(servers.id, serverId), eq(servers.organizationId, orgId))
|
||||
: eq(servers.id, serverId);
|
||||
|
||||
const [row] = await app.db
|
||||
.select({
|
||||
organizationId: servers.organizationId,
|
||||
serverUuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(whereClause);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
serverUuid: row.serverUuid,
|
||||
node: {
|
||||
fqdn: row.nodeFqdn,
|
||||
grpcPort: row.nodeGrpcPort,
|
||||
daemonToken: row.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,197 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, desc, count } from 'drizzle-orm';
|
||||
import { users, games, nodes, auditLogs } from '@source/database';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { eq, desc, count, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { users, games, nodes, auditLogs, plugins, pluginReleases } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requireSuperAdmin } from '../../lib/permissions.js';
|
||||
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
||||
import { CreateGameSchema, UpdateGameSchema, GameIdParamSchema } from './schemas.js';
|
||||
import { uploadPluginArtifact } from '../../lib/cdn.js';
|
||||
import * as yazl from 'yazl';
|
||||
import {
|
||||
CreateGameSchema,
|
||||
UpdateGameSchema,
|
||||
GameIdParamSchema,
|
||||
PluginIdParamSchema,
|
||||
PluginReleaseIdParamSchema,
|
||||
CreateGlobalPluginSchema,
|
||||
UpdateGlobalPluginSchema,
|
||||
ImportPluginsSchema,
|
||||
CreatePluginReleaseSchema,
|
||||
UpdatePluginReleaseSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
type ReleaseChannel = 'stable' | 'beta' | 'alpha';
|
||||
|
||||
interface UploadArtifactFile {
|
||||
relativePath: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
interface UploadJsonFile {
|
||||
filename: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
function toSlug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.slice(0, 200);
|
||||
}
|
||||
|
||||
function sanitizeRelativeSegments(path: string): string[] {
|
||||
const segments = path.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment === '.' || segment === '') continue;
|
||||
if (segment === '..') {
|
||||
throw AppError.badRequest('Invalid artifact path segment');
|
||||
}
|
||||
normalized.push(segment);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(path: string, fallbackName: string): string {
|
||||
const segments = sanitizeRelativeSegments(path);
|
||||
if (segments.length === 0) {
|
||||
return sanitizeRelativeSegments(fallbackName).join('/');
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') return [];
|
||||
if (typeof rawValue !== 'string') {
|
||||
throw AppError.badRequest(`${fieldName} must be a JSON string`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawValue);
|
||||
} catch {
|
||||
throw AppError.badRequest(`${fieldName} is not valid JSON`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw AppError.badRequest(`${fieldName} must be a JSON array`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseJsonArrayUploadFile(
|
||||
file: UploadJsonFile | null,
|
||||
fieldName: string,
|
||||
): unknown[] {
|
||||
if (!file) return [];
|
||||
|
||||
let rawValue = file.data.toString('utf8');
|
||||
if (rawValue.charCodeAt(0) === 0xfeff) {
|
||||
rawValue = rawValue.slice(1);
|
||||
}
|
||||
|
||||
return parseJsonArrayField(rawValue, fieldName);
|
||||
}
|
||||
|
||||
function parseJsonArrayInput(
|
||||
rawValue: unknown,
|
||||
file: UploadJsonFile | null,
|
||||
fieldName: string,
|
||||
): unknown[] {
|
||||
if (file) return parseJsonArrayUploadFile(file, fieldName);
|
||||
return parseJsonArrayField(rawValue, fieldName);
|
||||
}
|
||||
|
||||
function parseOptionalBoolean(rawValue: unknown): boolean | undefined {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') return undefined;
|
||||
if (typeof rawValue === 'boolean') return rawValue;
|
||||
if (typeof rawValue !== 'string') return undefined;
|
||||
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseReleaseChannel(rawValue: unknown): ReleaseChannel {
|
||||
if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue;
|
||||
if (typeof rawValue === 'string') {
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized;
|
||||
}
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
async function zipArtifacts(files: UploadArtifactFile[]): Promise<Buffer> {
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
const archive = new yazl.ZipFile();
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
archive.outputStream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
archive.outputStream.on('error', reject);
|
||||
archive.outputStream.on('end', () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
archive.addBuffer(file.data, file.relativePath.replace(/^\/+/g, ''));
|
||||
}
|
||||
|
||||
archive.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveImportGame(
|
||||
app: FastifyInstance,
|
||||
{
|
||||
gameId,
|
||||
gameSlug,
|
||||
}: {
|
||||
gameId?: string;
|
||||
gameSlug?: string;
|
||||
},
|
||||
) {
|
||||
if (gameId) {
|
||||
const game = await app.db.query.games.findFirst({
|
||||
where: eq(games.id, gameId),
|
||||
});
|
||||
if (!game) {
|
||||
throw AppError.notFound(`Game not found: ${gameId}`);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
const normalizedSlug = gameSlug?.trim().toLowerCase();
|
||||
if (normalizedSlug) {
|
||||
const game = await app.db.query.games.findFirst({
|
||||
where: eq(games.slug, normalizedSlug),
|
||||
});
|
||||
if (!game) {
|
||||
throw AppError.notFound(`Game not found: ${normalizedSlug}`);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
throw AppError.badRequest('gameId or gameSlug is required for each import item');
|
||||
}
|
||||
|
||||
export default async function adminRoutes(app: FastifyInstance) {
|
||||
await app.register(multipart, {
|
||||
limits: {
|
||||
files: 200,
|
||||
parts: 600,
|
||||
fileSize: 512 * 1024 * 1024,
|
||||
},
|
||||
});
|
||||
|
||||
// All admin routes require auth + super admin
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
app.addHook('onRequest', async (request) => {
|
||||
@@ -61,6 +246,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
stopCommand?: string;
|
||||
configFiles?: unknown[];
|
||||
environmentVars?: unknown[];
|
||||
automationRules?: unknown[];
|
||||
};
|
||||
|
||||
const existing = await app.db.query.games.findFirst({
|
||||
@@ -74,6 +260,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
...body,
|
||||
configFiles: body.configFiles ?? [],
|
||||
environmentVars: body.environmentVars ?? [],
|
||||
automationRules: body.automationRules ?? [],
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -98,6 +285,617 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
|
||||
// === Nodes (global view) ===
|
||||
|
||||
// === Global Plugins ===
|
||||
|
||||
app.get(
|
||||
'/plugins',
|
||||
{
|
||||
schema: {
|
||||
querystring: Type.Object({
|
||||
gameId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { gameId } = request.query as { gameId?: string };
|
||||
|
||||
const rows = await app.db
|
||||
.select({
|
||||
id: plugins.id,
|
||||
gameId: plugins.gameId,
|
||||
name: plugins.name,
|
||||
slug: plugins.slug,
|
||||
description: plugins.description,
|
||||
source: plugins.source,
|
||||
isGlobal: plugins.isGlobal,
|
||||
updatedAt: plugins.updatedAt,
|
||||
gameName: games.name,
|
||||
gameSlug: games.slug,
|
||||
})
|
||||
.from(plugins)
|
||||
.innerJoin(games, eq(plugins.gameId, games.id))
|
||||
.where(gameId ? eq(plugins.gameId, gameId) : undefined)
|
||||
.orderBy(plugins.name);
|
||||
|
||||
return { data: rows };
|
||||
},
|
||||
);
|
||||
|
||||
app.post('/plugins', { schema: CreateGlobalPluginSchema }, async (request, reply) => {
|
||||
const body = request.body as {
|
||||
gameId: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
source?: 'manual' | 'spiget';
|
||||
};
|
||||
|
||||
const game = await app.db.query.games.findFirst({
|
||||
where: eq(games.id, body.gameId),
|
||||
});
|
||||
if (!game) throw AppError.notFound('Game not found');
|
||||
|
||||
const slug = toSlug(body.slug ?? body.name);
|
||||
if (!slug) throw AppError.badRequest('Plugin slug is invalid');
|
||||
|
||||
const existing = await app.db.query.plugins.findFirst({
|
||||
where: and(eq(plugins.gameId, body.gameId), eq(plugins.slug, slug)),
|
||||
});
|
||||
if (existing) throw AppError.conflict('Plugin slug already exists for this game');
|
||||
|
||||
const [created] = await app.db
|
||||
.insert(plugins)
|
||||
.values({
|
||||
gameId: body.gameId,
|
||||
name: body.name,
|
||||
slug,
|
||||
description: body.description ?? null,
|
||||
source: body.source ?? 'manual',
|
||||
isGlobal: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.post('/plugins/import', { schema: ImportPluginsSchema }, async (request) => {
|
||||
const body = request.body as {
|
||||
defaultGameId?: string;
|
||||
defaultGameSlug?: string;
|
||||
stopOnError?: boolean;
|
||||
items: Array<{
|
||||
gameId?: string;
|
||||
gameSlug?: string;
|
||||
plugin: {
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
source?: 'manual' | 'spiget';
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
release?: {
|
||||
version: string;
|
||||
channel?: 'stable' | 'beta' | 'alpha';
|
||||
artifactType?: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination?: string;
|
||||
fileName?: string;
|
||||
changelog?: string;
|
||||
installSchema?: unknown[];
|
||||
configTemplates?: unknown[];
|
||||
isPublished?: boolean;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
index: number;
|
||||
success: boolean;
|
||||
gameId?: string;
|
||||
gameSlug?: string;
|
||||
pluginId?: string;
|
||||
pluginSlug?: string;
|
||||
pluginAction?: 'created' | 'updated';
|
||||
releaseId?: string;
|
||||
releaseVersion?: string;
|
||||
releaseAction?: 'created' | 'updated' | 'skipped';
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const [index, item] of body.items.entries()) {
|
||||
try {
|
||||
const game = await resolveImportGame(app, {
|
||||
gameId: item.gameId ?? body.defaultGameId,
|
||||
gameSlug: item.gameSlug ?? body.defaultGameSlug,
|
||||
});
|
||||
|
||||
const pluginPayload = item.plugin;
|
||||
const pluginSlug = toSlug(pluginPayload.slug ?? pluginPayload.name);
|
||||
if (!pluginSlug) {
|
||||
throw AppError.badRequest('Plugin slug is invalid');
|
||||
}
|
||||
|
||||
const existingPlugin = await app.db.query.plugins.findFirst({
|
||||
where: and(eq(plugins.gameId, game.id), eq(plugins.slug, pluginSlug)),
|
||||
});
|
||||
|
||||
let pluginRecord: typeof plugins.$inferSelect;
|
||||
let pluginAction: 'created' | 'updated';
|
||||
|
||||
if (existingPlugin) {
|
||||
const [updatedPlugin] = await app.db
|
||||
.update(plugins)
|
||||
.set({
|
||||
name: pluginPayload.name,
|
||||
slug: pluginSlug,
|
||||
description:
|
||||
pluginPayload.description !== undefined
|
||||
? pluginPayload.description
|
||||
: existingPlugin.description,
|
||||
source: pluginPayload.source ?? existingPlugin.source,
|
||||
isGlobal: pluginPayload.isGlobal ?? existingPlugin.isGlobal,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, existingPlugin.id))
|
||||
.returning();
|
||||
|
||||
if (!updatedPlugin) {
|
||||
throw AppError.notFound('Plugin not found');
|
||||
}
|
||||
|
||||
pluginRecord = updatedPlugin;
|
||||
pluginAction = 'updated';
|
||||
} else {
|
||||
const [createdPlugin] = await app.db
|
||||
.insert(plugins)
|
||||
.values({
|
||||
gameId: game.id,
|
||||
name: pluginPayload.name,
|
||||
slug: pluginSlug,
|
||||
description: pluginPayload.description ?? null,
|
||||
source: pluginPayload.source ?? 'manual',
|
||||
isGlobal: pluginPayload.isGlobal ?? true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!createdPlugin) {
|
||||
throw new AppError(500, 'Failed to create plugin');
|
||||
}
|
||||
|
||||
pluginRecord = createdPlugin;
|
||||
pluginAction = 'created';
|
||||
}
|
||||
|
||||
let releaseAction: 'created' | 'updated' | 'skipped' = 'skipped';
|
||||
let releaseRecord: typeof pluginReleases.$inferSelect | null = null;
|
||||
|
||||
if (item.release) {
|
||||
const releasePayload = item.release;
|
||||
const existingRelease = await app.db.query.pluginReleases.findFirst({
|
||||
where: and(
|
||||
eq(pluginReleases.pluginId, pluginRecord.id),
|
||||
eq(pluginReleases.version, releasePayload.version),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingRelease) {
|
||||
const [updatedRelease] = await app.db
|
||||
.update(pluginReleases)
|
||||
.set({
|
||||
channel: releasePayload.channel ?? existingRelease.channel,
|
||||
artifactType: releasePayload.artifactType ?? existingRelease.artifactType,
|
||||
artifactUrl: releasePayload.artifactUrl,
|
||||
destination:
|
||||
releasePayload.destination !== undefined
|
||||
? releasePayload.destination
|
||||
: existingRelease.destination,
|
||||
fileName:
|
||||
releasePayload.fileName !== undefined
|
||||
? releasePayload.fileName
|
||||
: existingRelease.fileName,
|
||||
changelog:
|
||||
releasePayload.changelog !== undefined
|
||||
? releasePayload.changelog
|
||||
: existingRelease.changelog,
|
||||
installSchema: releasePayload.installSchema ?? existingRelease.installSchema,
|
||||
configTemplates: releasePayload.configTemplates ?? existingRelease.configTemplates,
|
||||
isPublished: releasePayload.isPublished ?? existingRelease.isPublished,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginReleases.id, existingRelease.id))
|
||||
.returning();
|
||||
|
||||
if (!updatedRelease) {
|
||||
throw AppError.notFound('Plugin release not found');
|
||||
}
|
||||
|
||||
releaseRecord = updatedRelease;
|
||||
releaseAction = 'updated';
|
||||
} else {
|
||||
const [createdRelease] = await app.db
|
||||
.insert(pluginReleases)
|
||||
.values({
|
||||
pluginId: pluginRecord.id,
|
||||
version: releasePayload.version,
|
||||
channel: releasePayload.channel ?? 'stable',
|
||||
artifactType: releasePayload.artifactType ?? 'file',
|
||||
artifactUrl: releasePayload.artifactUrl,
|
||||
destination: releasePayload.destination ?? null,
|
||||
fileName: releasePayload.fileName ?? null,
|
||||
changelog: releasePayload.changelog ?? null,
|
||||
installSchema: releasePayload.installSchema ?? [],
|
||||
configTemplates: releasePayload.configTemplates ?? [],
|
||||
isPublished: releasePayload.isPublished ?? true,
|
||||
createdByUserId: request.user.sub,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!createdRelease) {
|
||||
throw new AppError(500, 'Failed to create plugin release');
|
||||
}
|
||||
|
||||
releaseRecord = createdRelease;
|
||||
releaseAction = 'created';
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
index,
|
||||
success: true,
|
||||
gameId: game.id,
|
||||
gameSlug: game.slug,
|
||||
pluginId: pluginRecord.id,
|
||||
pluginSlug: pluginRecord.slug,
|
||||
pluginAction,
|
||||
releaseId: releaseRecord?.id,
|
||||
releaseVersion: releaseRecord?.version,
|
||||
releaseAction,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (body.stopOnError) {
|
||||
throw AppError.badRequest(`Import failed at item ${index}: ${message}`);
|
||||
}
|
||||
|
||||
results.push({
|
||||
index,
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((result) => result.success).length;
|
||||
const failed = results.length - succeeded;
|
||||
|
||||
return {
|
||||
results,
|
||||
summary: {
|
||||
total: results.length,
|
||||
succeeded,
|
||||
failed,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
const body = request.body as {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
source?: 'manual' | 'spiget';
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
|
||||
const existing = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!existing) throw AppError.notFound('Plugin not found');
|
||||
|
||||
const nextSlug = body.slug !== undefined
|
||||
? toSlug(body.slug)
|
||||
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
|
||||
if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid');
|
||||
|
||||
const duplicate = await app.db.query.plugins.findFirst({
|
||||
where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)),
|
||||
});
|
||||
if (duplicate && duplicate.id !== existing.id) {
|
||||
throw AppError.conflict('Plugin slug already exists for this game');
|
||||
}
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(plugins)
|
||||
.set({
|
||||
name: body.name ?? existing.name,
|
||||
slug: nextSlug,
|
||||
description: body.description ?? existing.description,
|
||||
source: body.source ?? existing.source,
|
||||
isGlobal: body.isGlobal ?? existing.isGlobal,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, existing.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw AppError.notFound('Plugin not found');
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
|
||||
const plugin = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||
|
||||
const releases = await app.db
|
||||
.select()
|
||||
.from(pluginReleases)
|
||||
.where(eq(pluginReleases.pluginId, pluginId))
|
||||
.orderBy(desc(pluginReleases.createdAt));
|
||||
|
||||
return { plugin, releases };
|
||||
});
|
||||
|
||||
app.post('/plugins/:pluginId/releases/upload', { schema: PluginIdParamSchema }, async (request, reply) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
|
||||
const plugin = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||
|
||||
if (!request.isMultipart()) {
|
||||
throw AppError.badRequest('Content-Type must be multipart/form-data');
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = {};
|
||||
const files: UploadArtifactFile[] = [];
|
||||
let installSchemaFile: UploadJsonFile | null = null;
|
||||
let configTemplatesFile: UploadJsonFile | null = null;
|
||||
const relativePathQueue: string[] = [];
|
||||
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === 'file') {
|
||||
if (part.fieldname === 'installSchemaFile') {
|
||||
const data = await part.toBuffer();
|
||||
if (data.length > 0) {
|
||||
installSchemaFile = {
|
||||
filename: part.filename || 'install-schema.json',
|
||||
data,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.fieldname === 'configTemplatesFile') {
|
||||
const data = await part.toBuffer();
|
||||
if (data.length > 0) {
|
||||
configTemplatesFile = {
|
||||
filename: part.filename || 'config-templates.json',
|
||||
data,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackName = `artifact-${files.length + 1}.bin`;
|
||||
const queuedPath = relativePathQueue.shift();
|
||||
const relativePath = normalizeRelativePath(
|
||||
queuedPath ?? part.filename ?? '',
|
||||
fallbackName,
|
||||
);
|
||||
const data = await part.toBuffer();
|
||||
if (data.length === 0) continue;
|
||||
files.push({ relativePath, data });
|
||||
} else {
|
||||
if (part.fieldname === 'relativePath') {
|
||||
const raw = typeof part.value === 'string' ? part.value : '';
|
||||
relativePathQueue.push(raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
fields[part.fieldname] = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
throw AppError.badRequest('At least one file is required');
|
||||
}
|
||||
|
||||
const version = typeof fields.version === 'string' ? fields.version.trim() : '';
|
||||
if (!version) {
|
||||
throw AppError.badRequest('version is required');
|
||||
}
|
||||
|
||||
const channel = parseReleaseChannel(fields.channel);
|
||||
const destination = typeof fields.destination === 'string' && fields.destination.trim().length > 0
|
||||
? fields.destination.trim()
|
||||
: null;
|
||||
const changelog = typeof fields.changelog === 'string' && fields.changelog.trim().length > 0
|
||||
? fields.changelog
|
||||
: null;
|
||||
const isPublished = parseOptionalBoolean(fields.isPublished) ?? true;
|
||||
const installSchema = parseJsonArrayInput(fields.installSchema, installSchemaFile, 'installSchema');
|
||||
const configTemplates = parseJsonArrayInput(
|
||||
fields.configTemplates,
|
||||
configTemplatesFile,
|
||||
'configTemplates',
|
||||
);
|
||||
|
||||
const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : '';
|
||||
const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/'));
|
||||
const shouldZip = files.length > 1 || hasNestedPaths;
|
||||
|
||||
let artifactType: 'file' | 'zip';
|
||||
let artifactContent: Buffer;
|
||||
let uploadFileName: string;
|
||||
let releaseFileName: string | null;
|
||||
|
||||
if (shouldZip) {
|
||||
artifactType = 'zip';
|
||||
artifactContent = await zipArtifacts(files);
|
||||
|
||||
const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`;
|
||||
uploadFileName = suggestedName.toLowerCase().endsWith('.zip')
|
||||
? suggestedName
|
||||
: `${suggestedName}.zip`;
|
||||
releaseFileName = null;
|
||||
} else {
|
||||
artifactType = 'file';
|
||||
const [singleFile] = files;
|
||||
if (!singleFile) {
|
||||
throw AppError.badRequest('No artifact file received');
|
||||
}
|
||||
|
||||
artifactContent = singleFile.data;
|
||||
const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin';
|
||||
uploadFileName = rawFileName || originalName;
|
||||
releaseFileName = uploadFileName;
|
||||
}
|
||||
|
||||
const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, {
|
||||
pluginId: plugin.id,
|
||||
pluginSlug: plugin.slug,
|
||||
releaseVersion: version,
|
||||
uploadedBy: request.user.sub,
|
||||
uploadMode: shouldZip ? 'archive' : 'single',
|
||||
sourceFileCount: files.length,
|
||||
});
|
||||
|
||||
const [created] = await app.db
|
||||
.insert(pluginReleases)
|
||||
.values({
|
||||
pluginId: plugin.id,
|
||||
version,
|
||||
channel,
|
||||
artifactType,
|
||||
artifactUrl: uploaded.artifactPointer,
|
||||
destination,
|
||||
fileName: releaseFileName,
|
||||
changelog,
|
||||
installSchema,
|
||||
configTemplates,
|
||||
isPublished,
|
||||
createdByUserId: request.user.sub,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return reply.code(201).send({
|
||||
release: created,
|
||||
artifact: {
|
||||
bucket: uploaded.bucket,
|
||||
fileId: uploaded.file.id,
|
||||
storedName: uploaded.file.storedName,
|
||||
originalName: uploaded.file.originalName,
|
||||
pointer: uploaded.artifactPointer,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => {
|
||||
const { pluginId } = request.params as { pluginId: string };
|
||||
const body = request.body as {
|
||||
version: string;
|
||||
channel?: 'stable' | 'beta' | 'alpha';
|
||||
artifactType?: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination?: string;
|
||||
fileName?: string;
|
||||
changelog?: string;
|
||||
installSchema?: unknown[];
|
||||
configTemplates?: unknown[];
|
||||
isPublished?: boolean;
|
||||
cloneFromReleaseId?: string;
|
||||
};
|
||||
|
||||
const plugin = await app.db.query.plugins.findFirst({
|
||||
where: eq(plugins.id, pluginId),
|
||||
});
|
||||
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||
|
||||
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
|
||||
if (body.cloneFromReleaseId) {
|
||||
baseRelease = await app.db.query.pluginReleases.findFirst({
|
||||
where: and(
|
||||
eq(pluginReleases.id, body.cloneFromReleaseId),
|
||||
eq(pluginReleases.pluginId, pluginId),
|
||||
),
|
||||
}) ?? null;
|
||||
if (!baseRelease) {
|
||||
throw AppError.notFound('Clone source release not found');
|
||||
}
|
||||
}
|
||||
|
||||
const [created] = await app.db
|
||||
.insert(pluginReleases)
|
||||
.values({
|
||||
pluginId,
|
||||
version: body.version,
|
||||
channel: body.channel ?? baseRelease?.channel ?? 'stable',
|
||||
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
|
||||
artifactUrl: body.artifactUrl,
|
||||
destination: body.destination ?? baseRelease?.destination ?? null,
|
||||
fileName: body.fileName ?? baseRelease?.fileName ?? null,
|
||||
changelog: body.changelog ?? baseRelease?.changelog ?? null,
|
||||
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
|
||||
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
|
||||
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
|
||||
createdByUserId: request.user.sub,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.patch(
|
||||
'/plugins/:pluginId/releases/:releaseId',
|
||||
{ schema: { ...PluginReleaseIdParamSchema, ...UpdatePluginReleaseSchema } },
|
||||
async (request) => {
|
||||
const { pluginId, releaseId } = request.params as { pluginId: string; releaseId: string };
|
||||
const body = request.body as {
|
||||
version?: string;
|
||||
channel?: 'stable' | 'beta' | 'alpha';
|
||||
artifactType?: 'file' | 'zip';
|
||||
artifactUrl?: string;
|
||||
destination?: string;
|
||||
fileName?: string;
|
||||
changelog?: string;
|
||||
installSchema?: unknown[];
|
||||
configTemplates?: unknown[];
|
||||
isPublished?: boolean;
|
||||
};
|
||||
|
||||
const release = await app.db.query.pluginReleases.findFirst({
|
||||
where: and(eq(pluginReleases.id, releaseId), eq(pluginReleases.pluginId, pluginId)),
|
||||
});
|
||||
if (!release) throw AppError.notFound('Plugin release not found');
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(pluginReleases)
|
||||
.set({
|
||||
version: body.version ?? release.version,
|
||||
channel: body.channel ?? release.channel,
|
||||
artifactType: body.artifactType ?? release.artifactType,
|
||||
artifactUrl: body.artifactUrl ?? release.artifactUrl,
|
||||
destination: body.destination ?? release.destination,
|
||||
fileName: body.fileName ?? release.fileName,
|
||||
changelog: body.changelog ?? release.changelog,
|
||||
installSchema: body.installSchema ?? release.installSchema,
|
||||
configTemplates: body.configTemplates ?? release.configTemplates,
|
||||
isPublished: body.isPublished ?? release.isPublished,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginReleases.id, release.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw AppError.notFound('Plugin release not found');
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/nodes
|
||||
app.get('/nodes', async () => {
|
||||
const nodeList = await app.db
|
||||
|
||||
@@ -10,6 +10,7 @@ export const CreateGameSchema = {
|
||||
stopCommand: Type.Optional(Type.String()),
|
||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -22,6 +23,7 @@ export const UpdateGameSchema = {
|
||||
stopCommand: Type.Optional(Type.String()),
|
||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -30,3 +32,132 @@ export const GameIdParamSchema = {
|
||||
gameId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const PluginIdParamSchema = {
|
||||
params: Type.Object({
|
||||
pluginId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const PluginReleaseIdParamSchema = {
|
||||
params: Type.Object({
|
||||
pluginId: Type.String({ format: 'uuid' }),
|
||||
releaseId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const CreateGlobalPluginSchema = {
|
||||
body: Type.Object({
|
||||
gameId: Type.String({ format: 'uuid' }),
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
description: Type.Optional(Type.String()),
|
||||
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
|
||||
}),
|
||||
};
|
||||
|
||||
export const UpdateGlobalPluginSchema = {
|
||||
body: Type.Object({
|
||||
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
description: Type.Optional(Type.String()),
|
||||
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
|
||||
isGlobal: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
};
|
||||
|
||||
const ImportPluginPayloadSchema = Type.Object({
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
description: Type.Optional(Type.String()),
|
||||
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
|
||||
isGlobal: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
export const ReleaseInstallFieldSchema = Type.Object({
|
||||
key: Type.String({ minLength: 1, maxLength: 120 }),
|
||||
label: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
type: Type.Union([
|
||||
Type.Literal('text'),
|
||||
Type.Literal('number'),
|
||||
Type.Literal('boolean'),
|
||||
Type.Literal('select'),
|
||||
]),
|
||||
description: Type.Optional(Type.String({ maxLength: 1000 })),
|
||||
required: Type.Optional(Type.Boolean()),
|
||||
defaultValue: Type.Optional(Type.Any()),
|
||||
options: Type.Optional(Type.Array(Type.Object({
|
||||
label: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
value: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
}))),
|
||||
min: Type.Optional(Type.Number()),
|
||||
max: Type.Optional(Type.Number()),
|
||||
pattern: Type.Optional(Type.String({ maxLength: 500 })),
|
||||
secret: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
export const ReleaseTemplateSchema = Type.Object({
|
||||
path: Type.String({ minLength: 1 }),
|
||||
content: Type.String(),
|
||||
});
|
||||
|
||||
const ImportPluginReleasePayloadSchema = Type.Object({
|
||||
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||
artifactUrl: Type.String({ format: 'uri' }),
|
||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
changelog: Type.Optional(Type.String()),
|
||||
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
|
||||
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
|
||||
isPublished: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
export const ImportPluginsSchema = {
|
||||
body: Type.Object({
|
||||
defaultGameId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
defaultGameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||
stopOnError: Type.Optional(Type.Boolean()),
|
||||
items: Type.Array(
|
||||
Type.Object({
|
||||
gameId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
gameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||
plugin: ImportPluginPayloadSchema,
|
||||
release: Type.Optional(ImportPluginReleasePayloadSchema),
|
||||
}),
|
||||
{ minItems: 1, maxItems: 500 },
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
export const CreatePluginReleaseSchema = {
|
||||
body: Type.Object({
|
||||
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||
artifactUrl: Type.String({ format: 'uri' }),
|
||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
changelog: Type.Optional(Type.String()),
|
||||
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
|
||||
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
|
||||
isPublished: Type.Optional(Type.Boolean()),
|
||||
cloneFromReleaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
}),
|
||||
};
|
||||
|
||||
export const UpdatePluginReleaseSchema = {
|
||||
body: Type.Object({
|
||||
version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||
artifactUrl: Type.Optional(Type.String({ format: 'uri' })),
|
||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
changelog: Type.Optional(Type.String()),
|
||||
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
|
||||
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
|
||||
isPublished: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -171,6 +171,39 @@ export default async function authRoutes(app: FastifyInstance) {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// POST /api/auth/change-password
|
||||
app.post('/change-password', { onRequest: [app.authenticate] }, async (request) => {
|
||||
const { currentPassword, newPassword } = request.body as {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
if (!currentPassword || !newPassword || newPassword.length < 8) {
|
||||
throw AppError.badRequest('New password must be at least 8 characters');
|
||||
}
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.id, request.user.sub),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw AppError.notFound('User not found');
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(user.passwordHash, currentPassword);
|
||||
if (!isValid) {
|
||||
throw AppError.unauthorized('Current password is incorrect', 'INVALID_PASSWORD');
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(newPassword);
|
||||
await app.db
|
||||
.update(users)
|
||||
.set({ passwordHash: newHash, updatedAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
|
||||
const payload = request.user;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { games } from '@source/database';
|
||||
|
||||
export default async function gameRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
// GET /api/games
|
||||
app.get('/', async () => {
|
||||
const gameList = await app.db
|
||||
.select()
|
||||
.from(games)
|
||||
.orderBy(games.name);
|
||||
|
||||
return { data: gameList };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { and, eq, lte } from 'drizzle-orm';
|
||||
import { nodes, scheduledTasks, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { computeNextRun } from '../../lib/schedule-utils.js';
|
||||
|
||||
function extractBearerToken(authHeader?: string): string | null {
|
||||
if (!authHeader) return null;
|
||||
const [scheme, token] = authHeader.split(' ');
|
||||
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
function extractCdnWebhookSecret(request: FastifyRequest): string | null {
|
||||
const byHeader = request.headers['x-cdn-webhook-secret'] ?? request.headers['x-webhook-secret'];
|
||||
if (typeof byHeader === 'string' && byHeader.trim().length > 0) {
|
||||
return byHeader.trim();
|
||||
}
|
||||
|
||||
const authHeader = typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: undefined;
|
||||
|
||||
return extractBearerToken(authHeader);
|
||||
}
|
||||
|
||||
async function requireDaemonToken(
|
||||
app: FastifyInstance,
|
||||
request: FastifyRequest,
|
||||
): Promise<{ id: string }> {
|
||||
const token = extractBearerToken(
|
||||
typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
throw AppError.unauthorized('Missing daemon bearer token', 'DAEMON_AUTH_MISSING');
|
||||
}
|
||||
|
||||
const node = await app.db.query.nodes.findFirst({
|
||||
where: eq(nodes.daemonToken, token),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (!node) {
|
||||
throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID');
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export default async function internalRoutes(app: FastifyInstance) {
|
||||
app.post(
|
||||
'/cdn/webhook/plugins',
|
||||
{
|
||||
schema: {
|
||||
body: Type.Optional(Type.Unknown()),
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const configuredSecret = process.env.CDN_WEBHOOK_SECRET?.trim();
|
||||
if (configuredSecret) {
|
||||
const providedSecret = extractCdnWebhookSecret(request);
|
||||
if (!providedSecret || providedSecret !== configuredSecret) {
|
||||
throw AppError.unauthorized('Invalid CDN webhook secret', 'CDN_WEBHOOK_AUTH_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
const eventType = typeof body?.eventType === 'string'
|
||||
? body.eventType
|
||||
: (typeof body?.type === 'string' ? body.type : 'unknown');
|
||||
|
||||
request.log.info(
|
||||
{ eventType, payload: body },
|
||||
'Received CDN plugin webhook event',
|
||||
);
|
||||
|
||||
return reply.code(202).send({ accepted: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/schedules/due', async (request) => {
|
||||
const node = await requireDaemonToken(app, request);
|
||||
const now = new Date();
|
||||
|
||||
const dueTasks = await app.db
|
||||
.select({
|
||||
id: scheduledTasks.id,
|
||||
serverUuid: servers.uuid,
|
||||
action: scheduledTasks.action,
|
||||
payload: scheduledTasks.payload,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
isActive: scheduledTasks.isActive,
|
||||
nextRunAt: scheduledTasks.nextRunAt,
|
||||
})
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(and(
|
||||
eq(servers.nodeId, node.id),
|
||||
eq(scheduledTasks.isActive, true),
|
||||
lte(scheduledTasks.nextRunAt, now),
|
||||
));
|
||||
|
||||
return {
|
||||
tasks: dueTasks.map((task) => ({
|
||||
id: task.id,
|
||||
server_uuid: task.serverUuid,
|
||||
action: task.action,
|
||||
payload: task.payload,
|
||||
schedule_type: task.scheduleType,
|
||||
is_active: task.isActive,
|
||||
next_run_at: task.nextRunAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/schedules/:taskId/ack',
|
||||
{
|
||||
schema: {
|
||||
params: Type.Object({
|
||||
taskId: Type.String(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const node = await requireDaemonToken(app, request);
|
||||
const { taskId } = request.params as { taskId: string };
|
||||
|
||||
const [task] = await app.db
|
||||
.select({
|
||||
id: scheduledTasks.id,
|
||||
isActive: scheduledTasks.isActive,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
scheduleData: scheduledTasks.scheduleData,
|
||||
})
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(and(
|
||||
eq(scheduledTasks.id, taskId),
|
||||
eq(servers.nodeId, node.id),
|
||||
));
|
||||
|
||||
if (!task) {
|
||||
throw AppError.notFound('Scheduled task not found');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const nextRunAt = task.isActive
|
||||
? computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
await app.db
|
||||
.update(scheduledTasks)
|
||||
.set({
|
||||
lastRunAt: now,
|
||||
nextRunAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(scheduledTasks.id, taskId));
|
||||
|
||||
return { success: true, taskId };
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/servers/:serverUuid/backup',
|
||||
{
|
||||
schema: {
|
||||
params: Type.Object({
|
||||
serverUuid: Type.String(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
await requireDaemonToken(app, request);
|
||||
const { serverUuid } = request.params as { serverUuid: string };
|
||||
return { success: true, serverUuid };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nodes } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
|
||||
const HeartbeatSchema = {
|
||||
body: Type.Object({
|
||||
active_servers: Type.Number({ minimum: 0 }),
|
||||
total_servers: Type.Number({ minimum: 0 }),
|
||||
version: Type.String(),
|
||||
}),
|
||||
};
|
||||
|
||||
function extractBearerToken(authHeader?: string): string | null {
|
||||
if (!authHeader) return null;
|
||||
const [scheme, token] = authHeader.split(' ');
|
||||
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
export default async function daemonNodeRoutes(app: FastifyInstance) {
|
||||
// POST /api/nodes/heartbeat
|
||||
app.post('/heartbeat', { schema: HeartbeatSchema }, async (request) => {
|
||||
const token = extractBearerToken(
|
||||
typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
throw AppError.unauthorized('Missing daemon bearer token', 'DAEMON_AUTH_MISSING');
|
||||
}
|
||||
|
||||
const node = await app.db.query.nodes.findFirst({
|
||||
where: eq(nodes.daemonToken, token),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (!node) {
|
||||
throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await app.db
|
||||
.update(nodes)
|
||||
.set({
|
||||
isOnline: true,
|
||||
lastHeartbeat: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(nodes.id, node.id));
|
||||
|
||||
const body = request.body as {
|
||||
active_servers: number;
|
||||
total_servers: number;
|
||||
version: string;
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nodeId: node.id,
|
||||
activeServers: body.active_servers,
|
||||
totalServers: body.total_servers,
|
||||
version: body.version,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { nodes, allocations } from '@source/database';
|
||||
import { nodes, allocations, servers, games } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
daemonGetNodeStats,
|
||||
daemonGetNodeStatus,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
import {
|
||||
NodeParamSchema,
|
||||
CreateNodeSchema,
|
||||
@@ -26,7 +31,16 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||
.where(eq(nodes.organizationId, orgId))
|
||||
.orderBy(nodes.createdAt);
|
||||
|
||||
return { data: nodeList };
|
||||
const total = nodeList.length;
|
||||
return {
|
||||
data: nodeList,
|
||||
meta: {
|
||||
total,
|
||||
page: 1,
|
||||
perPage: total,
|
||||
totalPages: total === 0 ? 0 : 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/organizations/:orgId/nodes
|
||||
@@ -124,6 +138,95 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/servers
|
||||
app.get('/:nodeId/servers', { schema: NodeParamSchema }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
|
||||
const serverList = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
name: servers.name,
|
||||
status: servers.status,
|
||||
memoryLimit: servers.memoryLimit,
|
||||
cpuLimit: servers.cpuLimit,
|
||||
gameName: games.name,
|
||||
})
|
||||
.from(servers)
|
||||
.leftJoin(games, eq(servers.gameId, games.id))
|
||||
.where(and(eq(servers.nodeId, nodeId), eq(servers.organizationId, orgId)));
|
||||
|
||||
return { data: serverList };
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/stats
|
||||
// Returns real-time stats from daemon when available, with DB fallback.
|
||||
app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
|
||||
const node = await app.db.query.nodes.findFirst({
|
||||
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
|
||||
});
|
||||
if (!node) throw AppError.notFound('Node not found');
|
||||
|
||||
const serverList = await app.db
|
||||
.select({ id: servers.id, status: servers.status })
|
||||
.from(servers)
|
||||
.where(eq(servers.nodeId, nodeId));
|
||||
|
||||
const totalServers = serverList.length;
|
||||
let activeServers = serverList.filter((s) => s.status === 'running').length;
|
||||
let cpuPercent = 0;
|
||||
let memoryUsed = 0;
|
||||
let memoryTotal = node.memoryTotal;
|
||||
let diskUsed = 0;
|
||||
let diskTotal = node.diskTotal;
|
||||
let uptime = 0;
|
||||
|
||||
const daemonNode: DaemonNodeConnection = {
|
||||
fqdn: node.fqdn,
|
||||
grpcPort: node.grpcPort,
|
||||
daemonToken: node.daemonToken,
|
||||
};
|
||||
|
||||
try {
|
||||
const [liveStats, liveStatus] = await Promise.all([
|
||||
daemonGetNodeStats(daemonNode),
|
||||
daemonGetNodeStatus(daemonNode),
|
||||
]);
|
||||
|
||||
cpuPercent = Number.isFinite(liveStats.cpuPercent)
|
||||
? Math.max(0, Math.min(100, liveStats.cpuPercent))
|
||||
: 0;
|
||||
memoryUsed = Math.max(0, liveStats.memoryUsed);
|
||||
memoryTotal = liveStats.memoryTotal > 0 ? liveStats.memoryTotal : node.memoryTotal;
|
||||
diskUsed = Math.max(0, liveStats.diskUsed);
|
||||
diskTotal = liveStats.diskTotal > 0 ? liveStats.diskTotal : node.diskTotal;
|
||||
uptime = Math.max(0, liveStatus.uptimeSeconds);
|
||||
|
||||
if (Number.isFinite(liveStatus.activeServers)) {
|
||||
activeServers = Math.max(0, Math.min(totalServers, liveStatus.activeServers));
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{ error, nodeId, orgId },
|
||||
'Failed to fetch live node stats from daemon, returning fallback values',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cpuPercent,
|
||||
memoryUsed,
|
||||
memoryTotal,
|
||||
diskUsed,
|
||||
diskTotal,
|
||||
activeServers,
|
||||
totalServers,
|
||||
uptime,
|
||||
};
|
||||
});
|
||||
|
||||
// === Allocations ===
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/allocations
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { servers, backups } from '@source/database';
|
||||
import { servers, backups, nodes } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
daemonCreateBackup,
|
||||
daemonDeleteBackup,
|
||||
daemonRestoreBackup,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
@@ -54,10 +60,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
|
||||
const body = request.body as { name: string; isLocked?: boolean };
|
||||
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||
|
||||
// Create backup record (pending — daemon will update when complete)
|
||||
const [backup] = await app.db
|
||||
@@ -69,12 +72,38 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
// TODO: Send gRPC CreateBackup to daemon
|
||||
// Daemon will:
|
||||
// 1. tar+gz the server directory
|
||||
// 2. Upload to @source/cdn
|
||||
// 3. Callback to API with cdnPath, sizeBytes, checksum
|
||||
// 4. API updates backup record with completedAt
|
||||
if (!backup) {
|
||||
throw new AppError(500, 'Failed to create backup record', 'BACKUP_CREATE_FAILED');
|
||||
}
|
||||
|
||||
let completedBackup = backup;
|
||||
try {
|
||||
const daemonResult = await daemonCreateBackup(
|
||||
serverContext.node,
|
||||
serverContext.serverUuid,
|
||||
backup.id,
|
||||
);
|
||||
|
||||
if (!daemonResult.success) {
|
||||
throw new Error('Daemon returned unsuccessful backup response');
|
||||
}
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(backups)
|
||||
.set({
|
||||
sizeBytes: daemonResult.sizeBytes,
|
||||
checksum: daemonResult.checksum || null,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(backups.id, backup.id))
|
||||
.returning();
|
||||
|
||||
completedBackup = updated ?? completedBackup;
|
||||
} catch (error) {
|
||||
request.log.error({ error, serverId, backupId: backup.id }, 'Failed to create backup on daemon');
|
||||
await app.db.delete(backups).where(eq(backups.id, backup.id));
|
||||
throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED');
|
||||
}
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
@@ -83,7 +112,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
metadata: { name: body.name },
|
||||
});
|
||||
|
||||
return reply.code(201).send(backup);
|
||||
return reply.code(201).send(completedBackup);
|
||||
});
|
||||
|
||||
// POST /backups/:backupId/restore — restore a backup
|
||||
@@ -95,10 +124,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
};
|
||||
await requirePermission(request, orgId, 'backup.restore');
|
||||
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||
|
||||
const backup = await app.db.query.backups.findFirst({
|
||||
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
|
||||
@@ -106,12 +132,20 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
if (!backup) throw AppError.notFound('Backup not found');
|
||||
if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed');
|
||||
|
||||
// TODO: Send gRPC RestoreBackup to daemon
|
||||
// Daemon will:
|
||||
// 1. Stop the server
|
||||
// 2. Download backup from @source/cdn
|
||||
// 3. Extract tar.gz over server directory
|
||||
// 4. Start the server
|
||||
try {
|
||||
await daemonRestoreBackup(
|
||||
serverContext.node,
|
||||
serverContext.serverUuid,
|
||||
backup.id,
|
||||
backup.cdnPath,
|
||||
);
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, serverId, backupId },
|
||||
'Failed to restore backup on daemon',
|
||||
);
|
||||
throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED');
|
||||
}
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
@@ -161,7 +195,17 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
if (!backup) throw AppError.notFound('Backup not found');
|
||||
if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup');
|
||||
|
||||
// TODO: Send gRPC DeleteBackup to daemon to remove from CDN
|
||||
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||
|
||||
try {
|
||||
await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id);
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, serverId, backupId },
|
||||
'Failed to delete backup on daemon',
|
||||
);
|
||||
throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED');
|
||||
}
|
||||
|
||||
await app.db.delete(backups).where(eq(backups.id, backupId));
|
||||
|
||||
@@ -175,3 +219,33 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
async function getServerBackupContext(
|
||||
app: FastifyInstance,
|
||||
orgId: string,
|
||||
serverId: string,
|
||||
): Promise<{ serverUuid: string; node: DaemonNodeConnection }> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
serverUuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverUuid: server.serverUuid,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { servers, games } from '@source/database';
|
||||
import { servers, games, nodes } from '@source/database';
|
||||
import type { GameConfigFile, ConfigParser } from '@source/shared';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
|
||||
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
|
||||
import {
|
||||
isManagedCs2ServerConfigPath,
|
||||
readManagedCs2ServerConfig,
|
||||
writeManagedCs2ServerConfig,
|
||||
} from '../../lib/cs2-server-config.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
@@ -60,16 +66,30 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
};
|
||||
await requirePermission(request, orgId, 'config.read');
|
||||
|
||||
const { game, server, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
|
||||
// TODO: Read file from daemon via gRPC
|
||||
// For now, return empty parsed result (will be connected in Phase 4 integration)
|
||||
let raw = '';
|
||||
try {
|
||||
if (isManagedCs2ServerConfigPath(game.slug, configFile.path)) {
|
||||
raw = await readManagedCs2ServerConfig(node, server.uuid);
|
||||
} else {
|
||||
const file = await daemonReadFile(node, server.uuid, configFile.path);
|
||||
raw = file.data.toString('utf8');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingConfigFileError(error)) {
|
||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon');
|
||||
throw new AppError(502, 'Failed to read config file from daemon', 'DAEMON_CONFIG_READ_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
const entries = raw ? parseConfig(raw, configFile.parser as ConfigParser) : [];
|
||||
return {
|
||||
path: configFile.path,
|
||||
parser: configFile.parser,
|
||||
editableKeys: configFile.editableKeys ?? null,
|
||||
entries: [],
|
||||
raw: '',
|
||||
entries,
|
||||
raw,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -98,12 +118,36 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
const { entries } = request.body as { entries: { key: string; value: string }[] };
|
||||
await requirePermission(request, orgId, 'config.write');
|
||||
|
||||
const { configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
|
||||
// If editableKeys is set, only allow those keys
|
||||
const isManagedCs2Config = isManagedCs2ServerConfigPath(game.slug, configFile.path);
|
||||
|
||||
let originalContent: string | undefined;
|
||||
let originalEntries: { key: string; value: string }[] = [];
|
||||
try {
|
||||
if (isManagedCs2Config) {
|
||||
originalContent = await readManagedCs2ServerConfig(node, server.uuid);
|
||||
} else {
|
||||
const current = await daemonReadFile(node, server.uuid, configFile.path);
|
||||
originalContent = current.data.toString('utf8');
|
||||
}
|
||||
originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser);
|
||||
} catch (error) {
|
||||
if (!isMissingConfigFileError(error)) {
|
||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write');
|
||||
throw new AppError(502, 'Failed to read existing config file', 'DAEMON_CONFIG_READ_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
// If editableKeys is set, allow:
|
||||
// 1) explicitly editable keys
|
||||
// 2) keys that already exist in the current file
|
||||
if (configFile.editableKeys && configFile.editableKeys.length > 0) {
|
||||
const allowedKeys = new Set(configFile.editableKeys);
|
||||
const invalidKeys = entries.filter((e) => !allowedKeys.has(e.key));
|
||||
const existingKeys = new Set(originalEntries.map((entry) => entry.key));
|
||||
const invalidKeys = entries.filter(
|
||||
(entry) => !allowedKeys.has(entry.key) && !existingKeys.has(entry.key),
|
||||
);
|
||||
if (invalidKeys.length > 0) {
|
||||
throw AppError.badRequest(
|
||||
`Keys not allowed: ${invalidKeys.map((k) => k.key).join(', ')}`,
|
||||
@@ -111,11 +155,17 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize the entries
|
||||
const content = serializeConfig(entries, configFile.parser as ConfigParser);
|
||||
const content = serializeConfig(
|
||||
entries,
|
||||
configFile.parser as ConfigParser,
|
||||
originalContent,
|
||||
);
|
||||
|
||||
// TODO: Write file to daemon via gRPC
|
||||
// For now, just return success
|
||||
if (isManagedCs2Config) {
|
||||
await writeManagedCs2ServerConfig(node, server.uuid, content);
|
||||
} else {
|
||||
await daemonWriteFile(node, server.uuid, configFile.path, content);
|
||||
}
|
||||
return { success: true, path: configFile.path, content };
|
||||
},
|
||||
);
|
||||
@@ -127,13 +177,22 @@ async function getServerConfig(
|
||||
serverId: string,
|
||||
configIndex: number,
|
||||
) {
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
uuid: servers.uuid,
|
||||
gameId: servers.gameId,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
|
||||
const game = await app.db.query.games.findFirst({
|
||||
where: eq(games.id, server.gameId),
|
||||
where: eq(games.id, server.gameId as string),
|
||||
});
|
||||
if (!game) throw AppError.notFound('Game not found');
|
||||
|
||||
@@ -141,5 +200,20 @@ async function getServerConfig(
|
||||
const configFile = configFiles[configIndex];
|
||||
if (!configFile) throw AppError.notFound('Config file not found');
|
||||
|
||||
return { game, server, configFile };
|
||||
const node: DaemonNodeConnection = {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
};
|
||||
|
||||
return { game, server, node, configFile };
|
||||
}
|
||||
|
||||
function isMissingConfigFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('Server responded with NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { nodes, serverDatabases, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
daemonCreateDatabase,
|
||||
daemonDeleteDatabase,
|
||||
daemonUpdateDatabasePassword,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
|
||||
const ServerDatabaseParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
databaseId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
const ServerScopeSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
const CreateServerDatabaseSchema = {
|
||||
body: Type.Object({
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })),
|
||||
}),
|
||||
};
|
||||
|
||||
const UpdateServerDatabaseSchema = {
|
||||
body: Type.Object({
|
||||
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })),
|
||||
}),
|
||||
};
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string) {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
name: servers.name,
|
||||
uuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function buildNodeConnection(server: {
|
||||
nodeDaemonToken: string;
|
||||
nodeFqdn: string;
|
||||
nodeGrpcPort: number;
|
||||
}): DaemonNodeConnection {
|
||||
return {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
};
|
||||
}
|
||||
|
||||
function daemonErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default async function databaseRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', { schema: ServerScopeSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.read');
|
||||
await getServerContext(app, orgId, serverId);
|
||||
|
||||
const databases = await app.db
|
||||
.select()
|
||||
.from(serverDatabases)
|
||||
.where(eq(serverDatabases.serverId, serverId))
|
||||
.orderBy(serverDatabases.createdAt);
|
||||
|
||||
return { data: databases };
|
||||
});
|
||||
|
||||
app.post('/', { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, async (request, reply) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const body = request.body as { name: string; password?: string };
|
||||
const name = body.name.trim();
|
||||
if (!name) {
|
||||
throw AppError.badRequest('Database name is required');
|
||||
}
|
||||
|
||||
const server = await getServerContext(app, orgId, serverId);
|
||||
|
||||
let managedDatabase;
|
||||
try {
|
||||
managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), {
|
||||
name,
|
||||
password: body.password,
|
||||
serverUuid: server.uuid,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, serverUuid: server.uuid },
|
||||
'Failed to provision node-local MySQL database',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to provision node-local MySQL database'),
|
||||
'MANAGED_MYSQL_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [created] = await app.db
|
||||
.insert(serverDatabases)
|
||||
.values({
|
||||
serverId,
|
||||
name,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
password: managedDatabase.password,
|
||||
host: managedDatabase.host,
|
||||
port: managedDatabase.port,
|
||||
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.database.create',
|
||||
metadata: {
|
||||
name: created!.name,
|
||||
databaseName: created!.databaseName,
|
||||
username: created!.username,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.code(201).send(created);
|
||||
} catch (error) {
|
||||
try {
|
||||
await daemonDeleteDatabase(buildNodeConnection(server), {
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
request.log.error(
|
||||
{ cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||
'Failed to roll back node-local MySQL database after panel insert failure',
|
||||
);
|
||||
}
|
||||
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||
'Failed to persist managed MySQL database metadata',
|
||||
);
|
||||
throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED');
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/:databaseId', { schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } }, async (request) => {
|
||||
const { orgId, serverId, databaseId } = request.params as {
|
||||
databaseId: string;
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const body = request.body as { name?: string; password?: string };
|
||||
|
||||
const [current] = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
createdAt: serverDatabases.createdAt,
|
||||
updatedAt: serverDatabases.updatedAt,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(serverDatabases.id, databaseId),
|
||||
eq(serverDatabases.serverId, serverId),
|
||||
eq(servers.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!current) {
|
||||
throw AppError.notFound('Database not found');
|
||||
}
|
||||
|
||||
const nextName = body.name === undefined ? undefined : body.name.trim();
|
||||
if (body.name !== undefined && !nextName) {
|
||||
throw AppError.badRequest('Database name is required');
|
||||
}
|
||||
|
||||
const nextPassword = body.password?.trim();
|
||||
if (!nextName && !nextPassword) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (nextPassword) {
|
||||
try {
|
||||
await daemonUpdateDatabasePassword(buildNodeConnection(current), {
|
||||
password: nextPassword,
|
||||
username: current.username,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseId, username: current.username },
|
||||
'Failed to rotate node-local MySQL password',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to rotate database password'),
|
||||
'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (nextName) patch.name = nextName;
|
||||
if (nextPassword) patch.password = nextPassword;
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(serverDatabases)
|
||||
.set(patch)
|
||||
.where(eq(serverDatabases.id, databaseId))
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.database.update',
|
||||
metadata: {
|
||||
databaseId,
|
||||
updatedName: nextName ?? undefined,
|
||||
passwordRotated: Boolean(nextPassword),
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/:databaseId', { schema: ServerDatabaseParamSchema }, async (request, reply) => {
|
||||
const { orgId, serverId, databaseId } = request.params as {
|
||||
databaseId: string;
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const [current] = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(serverDatabases.id, databaseId),
|
||||
eq(serverDatabases.serverId, serverId),
|
||||
eq(servers.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!current) {
|
||||
throw AppError.notFound('Database not found');
|
||||
}
|
||||
|
||||
try {
|
||||
await daemonDeleteDatabase(buildNodeConnection(current), {
|
||||
databaseName: current.databaseName,
|
||||
username: current.username,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseId, databaseName: current.databaseName },
|
||||
'Failed to delete node-local MySQL database',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to delete node-local MySQL database'),
|
||||
'MANAGED_MYSQL_DELETE_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, databaseId));
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.database.delete',
|
||||
metadata: {
|
||||
databaseId,
|
||||
name: current.name,
|
||||
databaseName: current.databaseName,
|
||||
username: current.username,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { games, nodes, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import {
|
||||
daemonDeleteFiles,
|
||||
daemonListFiles,
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
import {
|
||||
CS2_PERSISTED_SERVER_CFG_PATH,
|
||||
CS2_PERSISTED_SERVER_CFG_FILE,
|
||||
isManagedCs2ServerConfigPath,
|
||||
readManagedCs2ServerConfig,
|
||||
writeManagedCs2ServerConfig,
|
||||
} from '../../lib/cs2-server-config.js';
|
||||
|
||||
const FileParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean {
|
||||
if (gameSlug !== 'cs2') return false;
|
||||
if (fileName.trim() === CS2_PERSISTED_SERVER_CFG_FILE) return true;
|
||||
if (isDirectory) return false;
|
||||
|
||||
const normalizedName = fileName.trim().toLowerCase();
|
||||
if (normalizedName.endsWith('.vpk')) return true;
|
||||
if (/^backup_round.*\.txt$/.test(normalizedName)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function decodeBase64Payload(data: string): Buffer {
|
||||
const normalized = data.trim();
|
||||
if (!normalized) return Buffer.alloc(0);
|
||||
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) {
|
||||
throw AppError.badRequest('Invalid base64 payload');
|
||||
}
|
||||
|
||||
return Buffer.from(normalized, 'base64');
|
||||
}
|
||||
|
||||
export default async function fileRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
...FileParamSchema,
|
||||
querystring: Type.Object({
|
||||
path: Type.Optional(Type.String()),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { path } = request.query as { path?: string };
|
||||
|
||||
await requirePermission(request, orgId, 'files.read');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
const files = await daemonListFiles(
|
||||
serverContext.node,
|
||||
serverContext.serverUuid,
|
||||
path?.trim() || '/',
|
||||
);
|
||||
|
||||
const filteredFiles = files.filter(
|
||||
(file) => !shouldHideFileForGame(serverContext.gameSlug, file.name, file.isDirectory),
|
||||
);
|
||||
|
||||
return { files: filteredFiles };
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/read',
|
||||
{
|
||||
schema: {
|
||||
...FileParamSchema,
|
||||
querystring: Type.Object({
|
||||
path: Type.String({ minLength: 1 }),
|
||||
encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { path, encoding } = request.query as {
|
||||
path: string;
|
||||
encoding?: 'utf8' | 'base64';
|
||||
};
|
||||
|
||||
await requirePermission(request, orgId, 'files.read');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
const requestedEncoding = encoding === 'base64' ? 'base64' : 'utf8';
|
||||
let payload: Buffer;
|
||||
let mimeType = 'text/plain';
|
||||
|
||||
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) {
|
||||
payload = Buffer.from(
|
||||
await readManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid),
|
||||
'utf8',
|
||||
);
|
||||
} else {
|
||||
const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path);
|
||||
payload = content.data;
|
||||
mimeType = content.mimeType;
|
||||
}
|
||||
|
||||
return {
|
||||
data:
|
||||
requestedEncoding === 'base64'
|
||||
? payload.toString('base64')
|
||||
: payload.toString('utf8'),
|
||||
encoding: requestedEncoding,
|
||||
mimeType,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/write',
|
||||
{
|
||||
bodyLimit: 128 * 1024 * 1024,
|
||||
schema: {
|
||||
...FileParamSchema,
|
||||
body: Type.Object({
|
||||
path: Type.String({ minLength: 1 }),
|
||||
data: Type.String(),
|
||||
encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { path, data, encoding } = request.body as {
|
||||
path: string;
|
||||
data: string;
|
||||
encoding?: 'utf8' | 'base64';
|
||||
};
|
||||
|
||||
await requirePermission(request, orgId, 'files.write');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
|
||||
|
||||
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) {
|
||||
await writeManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid, payload);
|
||||
} else {
|
||||
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
|
||||
}
|
||||
return { success: true, path };
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/delete',
|
||||
{
|
||||
schema: {
|
||||
...FileParamSchema,
|
||||
body: Type.Object({
|
||||
paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { paths } = request.body as { paths: string[] };
|
||||
|
||||
await requirePermission(request, orgId, 'files.delete');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
const resolvedPaths = paths.flatMap((path) =>
|
||||
isManagedCs2ServerConfigPath(serverContext.gameSlug, path)
|
||||
? [
|
||||
path,
|
||||
path.trim().startsWith('/')
|
||||
? `/${CS2_PERSISTED_SERVER_CFG_PATH}`
|
||||
: CS2_PERSISTED_SERVER_CFG_PATH,
|
||||
]
|
||||
: [path],
|
||||
);
|
||||
|
||||
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths);
|
||||
return { success: true, paths };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
serverUuid: string;
|
||||
gameSlug: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
uuid: servers.uuid,
|
||||
gameSlug: games.slug,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.innerJoin(games, eq(servers.gameId, games.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverUuid: server.uuid,
|
||||
gameSlug: server.gameSlug,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { nodes, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { daemonGetActivePlayers, type DaemonNodeConnection } from '../../lib/daemon.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export default async function playerRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', { schema: ParamSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.read');
|
||||
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
const players = await daemonGetActivePlayers(serverContext.node, serverContext.serverUuid);
|
||||
|
||||
return {
|
||||
players: players.players.map((player) => ({
|
||||
name: player.name,
|
||||
steamid: player.id || undefined,
|
||||
})),
|
||||
maxPlayers: players.maxPlayers,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
uuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverUuid: server.uuid,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,17 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { servers, scheduledTasks } from '@source/database';
|
||||
import { nodes, servers, scheduledTasks } from '@source/database';
|
||||
import type { PowerAction } from '@source/shared';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import { computeNextRun } from '../../lib/schedule-utils.js';
|
||||
import {
|
||||
daemonSendCommand,
|
||||
daemonSetPowerState,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
@@ -194,8 +200,18 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
||||
});
|
||||
if (!task) throw AppError.notFound('Scheduled task not found');
|
||||
|
||||
// TODO: Execute task action (send to daemon via gRPC)
|
||||
// For now, just update lastRunAt and nextRunAt
|
||||
if (task.action === 'command') {
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
await daemonSendCommand(serverContext.node, serverContext.serverUuid, task.payload);
|
||||
} else if (task.action === 'power') {
|
||||
const action = task.payload as PowerAction;
|
||||
if (!['start', 'stop', 'restart', 'kill'].includes(action)) {
|
||||
throw AppError.badRequest('Invalid power action in schedule payload');
|
||||
}
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
await daemonSetPowerState(serverContext.node, serverContext.serverUuid, action);
|
||||
}
|
||||
|
||||
const nextRun = computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>);
|
||||
|
||||
await app.db
|
||||
@@ -206,3 +222,32 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
||||
return { success: true, triggered: task.name };
|
||||
});
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
uuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverUuid: server.uuid,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const CreateServerSchema = {
|
||||
diskLimit: Type.Number({ minimum: 256 * 1024 * 1024 }), // min 256MB
|
||||
cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000, default: 100 })),
|
||||
allocationId: Type.String({ format: 'uuid' }),
|
||||
additionalAllocationIds: Type.Optional(Type.Array(Type.String({ format: 'uuid' }))),
|
||||
environment: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
startupOverride: Type.Optional(Type.String()),
|
||||
}),
|
||||
|
||||
Generated
+1
@@ -484,6 +484,7 @@ dependencies = [
|
||||
"bollard",
|
||||
"flate2",
|
||||
"futures",
|
||||
"libc",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"reqwest",
|
||||
|
||||
@@ -32,6 +32,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
libc = "0.2"
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM rust:1.83-bookworm AS build
|
||||
|
||||
# Install protoc
|
||||
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY apps/daemon/ .
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
# --- Production ---
|
||||
FROM debian:bookworm-slim AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libssl3 \
|
||||
mariadb-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/target/release/gamepanel-daemon /app/gamepanel-daemon
|
||||
|
||||
# Data directories
|
||||
RUN mkdir -p /var/lib/gamepanel/servers /var/lib/gamepanel/backups /etc/gamepanel
|
||||
|
||||
EXPOSE 50051
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD /app/gamepanel-daemon --health-check || exit 1
|
||||
|
||||
CMD ["/app/gamepanel-daemon"]
|
||||
@@ -0,0 +1,152 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tokio::sync::{mpsc, oneshot, RwLock};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::server::ServerManager;
|
||||
|
||||
const DEFAULT_QUEUE_CAPACITY: usize = 256;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CommandJob {
|
||||
command: String,
|
||||
response_tx: oneshot::Sender<Result<()>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WorkerHandle {
|
||||
id: u64,
|
||||
sender: mpsc::Sender<CommandJob>,
|
||||
}
|
||||
|
||||
pub struct CommandDispatcher {
|
||||
server_manager: Arc<ServerManager>,
|
||||
workers: Arc<RwLock<HashMap<String, WorkerHandle>>>,
|
||||
next_worker_id: Arc<AtomicU64>,
|
||||
queue_capacity: usize,
|
||||
}
|
||||
|
||||
impl CommandDispatcher {
|
||||
pub fn new(server_manager: Arc<ServerManager>) -> Self {
|
||||
Self {
|
||||
server_manager,
|
||||
workers: Arc::new(RwLock::new(HashMap::new())),
|
||||
next_worker_id: Arc::new(AtomicU64::new(1)),
|
||||
queue_capacity: DEFAULT_QUEUE_CAPACITY,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
|
||||
let cmd = command.trim();
|
||||
if cmd.is_empty() {
|
||||
return Err(anyhow!("Command cannot be empty"));
|
||||
}
|
||||
|
||||
// Retry once if the current worker channel is unexpectedly closed.
|
||||
for _ in 0..2 {
|
||||
let worker = self.get_or_create_worker(server_uuid).await;
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
let job = CommandJob {
|
||||
command: cmd.to_string(),
|
||||
response_tx,
|
||||
};
|
||||
|
||||
match worker.sender.send(job).await {
|
||||
Ok(_) => {
|
||||
return response_rx
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(anyhow!("Command worker dropped response channel")));
|
||||
}
|
||||
Err(send_err) => {
|
||||
warn!(
|
||||
server_uuid = %server_uuid,
|
||||
worker_id = worker.id,
|
||||
error = %send_err,
|
||||
"Command worker queue send failed, rotating worker",
|
||||
);
|
||||
self.remove_worker_if_matches(server_uuid, worker.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Failed to dispatch command after retry"))
|
||||
}
|
||||
|
||||
async fn get_or_create_worker(&self, server_uuid: &str) -> WorkerHandle {
|
||||
if let Some(existing) = self.workers.read().await.get(server_uuid).cloned() {
|
||||
return existing;
|
||||
}
|
||||
|
||||
let worker_id = self.next_worker_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (sender, receiver) = mpsc::channel::<CommandJob>(self.queue_capacity);
|
||||
let handle = WorkerHandle {
|
||||
id: worker_id,
|
||||
sender: sender.clone(),
|
||||
};
|
||||
|
||||
{
|
||||
let mut workers = self.workers.write().await;
|
||||
if let Some(existing) = workers.get(server_uuid).cloned() {
|
||||
return existing;
|
||||
}
|
||||
workers.insert(server_uuid.to_string(), handle.clone());
|
||||
}
|
||||
|
||||
self.spawn_worker(server_uuid.to_string(), worker_id, receiver);
|
||||
handle
|
||||
}
|
||||
|
||||
fn spawn_worker(
|
||||
&self,
|
||||
server_uuid: String,
|
||||
worker_id: u64,
|
||||
mut receiver: mpsc::Receiver<CommandJob>,
|
||||
) {
|
||||
let server_manager = self.server_manager.clone();
|
||||
let workers = self.workers.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
debug!(server_uuid = %server_uuid, worker_id, "Command worker started");
|
||||
|
||||
while let Some(job) = receiver.recv().await {
|
||||
let result = execute_command(server_manager.clone(), &server_uuid, &job.command).await;
|
||||
let _ = job.response_tx.send(result);
|
||||
}
|
||||
|
||||
let mut map = workers.write().await;
|
||||
if let Some(current) = map.get(&server_uuid) {
|
||||
if current.id == worker_id {
|
||||
map.remove(&server_uuid);
|
||||
}
|
||||
}
|
||||
debug!(server_uuid = %server_uuid, worker_id, "Command worker stopped");
|
||||
});
|
||||
}
|
||||
|
||||
async fn remove_worker_if_matches(&self, server_uuid: &str, worker_id: u64) {
|
||||
let mut workers = self.workers.write().await;
|
||||
if let Some(current) = workers.get(server_uuid) {
|
||||
if current.id == worker_id {
|
||||
workers.remove(server_uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_command(
|
||||
server_manager: Arc<ServerManager>,
|
||||
server_uuid: &str,
|
||||
command: &str,
|
||||
) -> Result<()> {
|
||||
server_manager
|
||||
.docker()
|
||||
.send_command(server_uuid, command)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub struct DaemonConfig {
|
||||
pub data_path: PathBuf,
|
||||
#[serde(default = "default_backup_path")]
|
||||
pub backup_path: PathBuf,
|
||||
#[serde(default)]
|
||||
pub managed_mysql: Option<ManagedMysqlConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -36,6 +38,19 @@ impl Default for DockerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ManagedMysqlConfig {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub connection_host: Option<String>,
|
||||
#[serde(default)]
|
||||
pub connection_port: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub phpmyadmin_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bin: Option<String>,
|
||||
}
|
||||
|
||||
fn default_grpc_port() -> u16 {
|
||||
50051
|
||||
}
|
||||
|
||||
@@ -1,26 +1,207 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use bollard::container::{
|
||||
Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
|
||||
StopContainerOptions, StatsOptions, Stats,
|
||||
AttachContainerOptions, Config, CreateContainerOptions, ListContainersOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
|
||||
StopContainerOptions, StatsOptions, Stats, UploadToContainerOptions,
|
||||
};
|
||||
use bollard::image::CreateImageOptions;
|
||||
use bollard::models::{HostConfig, PortBinding};
|
||||
use bollard::models::{HostConfig, MountPointTypeEnum, PortBinding};
|
||||
use futures::StreamExt;
|
||||
use tracing::info;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::docker::DockerManager;
|
||||
use crate::server::ServerSpec;
|
||||
use crate::server::state::ServerState;
|
||||
|
||||
/// Container name prefix for all managed game servers.
|
||||
const CONTAINER_PREFIX: &str = "gp_";
|
||||
const SATISFACTORY_RUN_SH: &str = include_str!("../game/satisfactory_run.sh");
|
||||
|
||||
pub fn container_name(server_uuid: &str) -> String {
|
||||
format!("{}{}", CONTAINER_PREFIX, server_uuid)
|
||||
}
|
||||
|
||||
fn uuid_from_container_name(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim_start_matches('/');
|
||||
trimmed
|
||||
.strip_prefix(CONTAINER_PREFIX)
|
||||
.filter(|uuid| !uuid.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn container_data_path_for_image(image: &str) -> &'static str {
|
||||
let normalized = image.to_ascii_lowercase();
|
||||
if normalized.contains("cm2network/cs2") || normalized.contains("joedwards32/cs2") {
|
||||
return "/home/steam/cs2-dedicated";
|
||||
}
|
||||
if normalized.contains("cm2network/csgo") {
|
||||
return "/home/steam/csgo-dedicated";
|
||||
}
|
||||
if normalized.contains("spritsail/fivem") {
|
||||
return "/config";
|
||||
}
|
||||
if normalized.contains("wolveix/satisfactory-server") {
|
||||
return "/config";
|
||||
}
|
||||
"/data"
|
||||
}
|
||||
|
||||
fn is_wolveix_satisfactory_image(image: &str) -> bool {
|
||||
image
|
||||
.to_ascii_lowercase()
|
||||
.contains("wolveix/satisfactory-server")
|
||||
}
|
||||
|
||||
fn server_state_from_container_status(status: &str) -> ServerState {
|
||||
match status {
|
||||
"running" | "restarting" | "paused" => ServerState::Running,
|
||||
"created" | "exited" => ServerState::Stopped,
|
||||
"dead" => ServerState::Error,
|
||||
_ => ServerState::Error,
|
||||
}
|
||||
}
|
||||
|
||||
impl DockerManager {
|
||||
async fn attach_command_stream(
|
||||
&self,
|
||||
container_name: &str,
|
||||
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
||||
let bollard::container::AttachContainerResults { mut output, input } = self
|
||||
.client()
|
||||
.attach_container(
|
||||
container_name,
|
||||
Some(AttachContainerOptions::<String> {
|
||||
stdin: Some(true),
|
||||
stream: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let name = container_name.to_string();
|
||||
let drain_task = tokio::spawn(async move {
|
||||
while let Some(chunk) = output.next().await {
|
||||
if let Err(error) = chunk {
|
||||
debug!(container = %name, error = %error, "Container stdin attach stream closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
debug!(container = %name, "Container stdin attach stream ended");
|
||||
});
|
||||
|
||||
Ok(Arc::new(crate::docker::manager::CommandStreamHandle::new(input, drain_task)))
|
||||
}
|
||||
|
||||
async fn get_or_attach_command_stream(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
||||
let name = container_name(server_uuid);
|
||||
|
||||
if let Some(existing) = self.command_streams().read().await.get(&name).cloned() {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let created = self.attach_command_stream(&name).await?;
|
||||
|
||||
let mut streams = self.command_streams().write().await;
|
||||
if let Some(existing) = streams.get(&name).cloned() {
|
||||
created.abort();
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
streams.insert(name, created.clone());
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn clear_command_stream(&self, server_uuid: &str) {
|
||||
let name = container_name(server_uuid);
|
||||
if let Some(stream) = self.command_streams().write().await.remove(&name) {
|
||||
stream.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_exec(&self, container_name: &str, cmd: Vec<String>) -> Result<String> {
|
||||
let exec = self
|
||||
.client()
|
||||
.create_exec(
|
||||
container_name,
|
||||
bollard::exec::CreateExecOptions::<String> {
|
||||
cmd: Some(cmd),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut captured = String::new();
|
||||
match self.client()
|
||||
.start_exec(&exec.id, None::<bollard::exec::StartExecOptions>)
|
||||
.await?
|
||||
{
|
||||
bollard::exec::StartExecResults::Attached { mut output, .. } => {
|
||||
while let Some(chunk) = output.next().await {
|
||||
let chunk = chunk?;
|
||||
captured.push_str(&chunk.to_string());
|
||||
}
|
||||
}
|
||||
bollard::exec::StartExecResults::Detached => {}
|
||||
}
|
||||
|
||||
// Wait briefly for completion and collect exit code.
|
||||
for _ in 0..30 {
|
||||
let status = self.client().inspect_exec(&exec.id).await?;
|
||||
if !status.running.unwrap_or(false) {
|
||||
let code = status.exit_code.unwrap_or(0);
|
||||
if code == 0 {
|
||||
return Ok(captured);
|
||||
}
|
||||
return Err(anyhow::anyhow!("exec command failed with exit code {}", code));
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("exec command timeout"))
|
||||
}
|
||||
|
||||
async fn patch_satisfactory_run_script(&self, container_name: &str) -> Result<()> {
|
||||
let mut archive = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut archive);
|
||||
let bytes = SATISFACTORY_RUN_SH.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, "run.sh", Cursor::new(bytes))?;
|
||||
builder.finish()?;
|
||||
}
|
||||
|
||||
self.client()
|
||||
.upload_to_container(
|
||||
container_name,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/home/steam",
|
||||
no_overwrite_dir_non_dir: "false",
|
||||
}),
|
||||
archive.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rcon_command(&self, server_uuid: &str, command: &str) -> Result<String> {
|
||||
let name = container_name(server_uuid);
|
||||
self.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Pull a Docker image if not already present.
|
||||
pub async fn pull_image(&self, image: &str) -> Result<()> {
|
||||
info!(image = %image, "Pulling Docker image");
|
||||
@@ -49,6 +230,7 @@ impl DockerManager {
|
||||
/// Create and configure a container for a game server.
|
||||
pub async fn create_container(&self, spec: &ServerSpec) -> Result<String> {
|
||||
let name = container_name(&spec.uuid);
|
||||
let data_mount_path = container_data_path_for_image(&spec.docker_image);
|
||||
|
||||
// Build port bindings
|
||||
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
||||
@@ -84,8 +266,10 @@ impl DockerManager {
|
||||
port_bindings: Some(port_bindings),
|
||||
network_mode: Some(self.network_name().to_string()),
|
||||
binds: Some(vec![format!(
|
||||
"{}:/data",
|
||||
"{}:{}",
|
||||
spec.data_path.display()
|
||||
,
|
||||
data_mount_path
|
||||
)]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -96,7 +280,13 @@ impl DockerManager {
|
||||
env: Some(env),
|
||||
exposed_ports: Some(exposed_ports),
|
||||
host_config: Some(host_config),
|
||||
working_dir: Some("/data".to_string()),
|
||||
// Preserve image default working directory when no custom startup command is set.
|
||||
// Some game images rely on their built-in WORKDIR and entrypoint scripts.
|
||||
working_dir: if spec.startup_command.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data_mount_path.to_string())
|
||||
},
|
||||
cmd: if spec.startup_command.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -118,6 +308,10 @@ impl DockerManager {
|
||||
let options = CreateContainerOptions { name: name.as_str(), platform: None };
|
||||
let response = self.client().create_container(Some(options), config).await?;
|
||||
|
||||
if is_wolveix_satisfactory_image(&spec.docker_image) {
|
||||
self.patch_satisfactory_run_script(&name).await?;
|
||||
}
|
||||
|
||||
info!(container_id = %response.id, uuid = %spec.uuid, "Container created");
|
||||
Ok(response.id)
|
||||
}
|
||||
@@ -135,6 +329,7 @@ impl DockerManager {
|
||||
/// Stop a container gracefully.
|
||||
pub async fn stop_container(&self, server_uuid: &str, timeout_secs: i64) -> Result<()> {
|
||||
let name = container_name(server_uuid);
|
||||
self.clear_command_stream(server_uuid).await;
|
||||
self.client()
|
||||
.stop_container(
|
||||
&name,
|
||||
@@ -150,6 +345,7 @@ impl DockerManager {
|
||||
/// Kill a container immediately.
|
||||
pub async fn kill_container(&self, server_uuid: &str) -> Result<()> {
|
||||
let name = container_name(server_uuid);
|
||||
self.clear_command_stream(server_uuid).await;
|
||||
self.client()
|
||||
.kill_container::<String>(&name, None)
|
||||
.await?;
|
||||
@@ -160,6 +356,7 @@ impl DockerManager {
|
||||
/// Remove a container and its volumes.
|
||||
pub async fn remove_container(&self, server_uuid: &str) -> Result<()> {
|
||||
let name = container_name(server_uuid);
|
||||
self.clear_command_stream(server_uuid).await;
|
||||
self.client()
|
||||
.remove_container(
|
||||
&name,
|
||||
@@ -217,6 +414,192 @@ impl DockerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read container runtime metadata (image + env vars) from Docker inspect.
|
||||
pub async fn container_runtime_metadata(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
) -> Result<(String, HashMap<String, String>)> {
|
||||
let name = container_name(server_uuid);
|
||||
let info = self.client().inspect_container(&name, None).await?;
|
||||
|
||||
let image = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.image.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut env_map = HashMap::new();
|
||||
if let Some(env_vars) = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.env.clone())
|
||||
{
|
||||
for entry in env_vars {
|
||||
if let Some((key, value)) = entry.split_once('=') {
|
||||
env_map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((image, env_map))
|
||||
}
|
||||
|
||||
/// Discover existing managed containers and rebuild in-memory server specs.
|
||||
pub async fn recover_managed_server_specs(&self, data_root: &Path) -> Result<Vec<ServerSpec>> {
|
||||
let containers = self
|
||||
.client()
|
||||
.list_containers(Some(ListContainersOptions::<String> {
|
||||
all: true,
|
||||
..Default::default()
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let mut recovered = Vec::new();
|
||||
|
||||
for container in containers {
|
||||
let uuid = container
|
||||
.names
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|name| uuid_from_container_name(name));
|
||||
|
||||
let Some(uuid) = uuid else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let info = self.client().inspect_container(&container_name(&uuid), None).await?;
|
||||
|
||||
let image = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.image.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let data_mount_path = info
|
||||
.mounts
|
||||
.as_ref()
|
||||
.and_then(|mounts| {
|
||||
mounts.iter().find_map(|mount| {
|
||||
if mount.typ != Some(MountPointTypeEnum::BIND) {
|
||||
return None;
|
||||
}
|
||||
mount.source.as_ref().map(PathBuf::from)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| data_root.join(&uuid));
|
||||
|
||||
let data_destination = info
|
||||
.mounts
|
||||
.as_ref()
|
||||
.and_then(|mounts| {
|
||||
mounts.iter().find_map(|mount| {
|
||||
if mount.typ != Some(MountPointTypeEnum::BIND) {
|
||||
return None;
|
||||
}
|
||||
mount.destination.clone()
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| container_data_path_for_image(&image).to_string());
|
||||
|
||||
let startup_command = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| {
|
||||
let working_dir = cfg.working_dir.as_deref().unwrap_or_default();
|
||||
if working_dir != data_destination {
|
||||
return None;
|
||||
}
|
||||
cfg.cmd.as_ref().map(|cmd| cmd.join(" "))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut environment = HashMap::new();
|
||||
if let Some(env_vars) = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.env.clone())
|
||||
{
|
||||
for entry in env_vars {
|
||||
if let Some((key, value)) = entry.split_once('=') {
|
||||
environment.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ports = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.port_bindings.as_ref())
|
||||
.map(|bindings| {
|
||||
bindings
|
||||
.iter()
|
||||
.flat_map(|(container_port, host_bindings)| {
|
||||
let (container_port, protocol) = match container_port.split_once('/') {
|
||||
Some((port, protocol)) => (port, protocol),
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let Ok(container_port_num) = container_port.parse::<u16>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
host_bindings
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|binding| {
|
||||
let host_port = binding.host_port.as_deref()?.parse::<u16>().ok()?;
|
||||
Some(crate::server::PortMap {
|
||||
host_port,
|
||||
container_port: container_port_num,
|
||||
protocol: protocol.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let memory_limit = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.memory)
|
||||
.unwrap_or_default();
|
||||
|
||||
let cpu_limit = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.nano_cpus)
|
||||
.map(|nano_cpus| (nano_cpus / 10_000_000) as i32)
|
||||
.unwrap_or_default();
|
||||
|
||||
let state = info
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.status.as_ref())
|
||||
.map(|status| server_state_from_container_status(&format!("{status:?}").to_lowercase()))
|
||||
.unwrap_or(ServerState::Error);
|
||||
|
||||
recovered.push(ServerSpec {
|
||||
uuid,
|
||||
docker_image: image,
|
||||
memory_limit,
|
||||
disk_limit: 0,
|
||||
cpu_limit,
|
||||
startup_command,
|
||||
environment,
|
||||
ports,
|
||||
data_path: data_mount_path,
|
||||
state,
|
||||
container_id: info.id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
/// Stream container logs (stdout + stderr). Returns an owned stream.
|
||||
pub fn stream_logs(
|
||||
self: &Arc<Self>,
|
||||
@@ -237,27 +620,22 @@ impl DockerManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a command to a container via exec (attach to stdin).
|
||||
/// Send a command to a container via a persistent Docker attach stdin stream.
|
||||
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
|
||||
let name = container_name(server_uuid);
|
||||
let trimmed = command.trim_end_matches(|ch| ch == '\r' || ch == '\n');
|
||||
let payload = format!("{trimmed}\n");
|
||||
|
||||
let exec = self
|
||||
.client()
|
||||
.create_exec(
|
||||
&name,
|
||||
bollard::exec::CreateExecOptions {
|
||||
cmd: Some(vec!["sh", "-c", &format!("echo '{}' > /proc/1/fd/0", command)]),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
for _ in 0..2 {
|
||||
let stream = self.get_or_attach_command_stream(server_uuid).await?;
|
||||
match stream.write_all(payload.as_bytes()).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => {
|
||||
debug!(server_uuid = %server_uuid, error = %error, "Failed to write to container stdin, resetting attach stream");
|
||||
self.clear_command_stream(server_uuid).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.client()
|
||||
.start_exec(&exec.id, None::<bollard::exec::StartExecOptions>)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
Err(anyhow::anyhow!("failed to write command to container stdin"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,50 @@
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use bollard::Docker;
|
||||
use bollard::network::CreateNetworkOptions;
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::info;
|
||||
|
||||
use crate::config::DockerConfig;
|
||||
|
||||
type AttachedInput = Pin<Box<dyn AsyncWrite + Send>>;
|
||||
|
||||
pub(crate) struct CommandStreamHandle {
|
||||
input: Mutex<AttachedInput>,
|
||||
drain_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CommandStreamHandle {
|
||||
pub(crate) fn new(input: AttachedInput, drain_task: JoinHandle<()>) -> Self {
|
||||
Self {
|
||||
input: Mutex::new(input),
|
||||
drain_task,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> {
|
||||
let mut input = self.input.lock().await;
|
||||
input.write_all(bytes).await?;
|
||||
input.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn abort(&self) {
|
||||
self.drain_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the Docker client and network setup.
|
||||
#[derive(Clone)]
|
||||
pub struct DockerManager {
|
||||
client: Docker,
|
||||
network_name: String,
|
||||
command_streams: Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>,
|
||||
}
|
||||
|
||||
impl DockerManager {
|
||||
@@ -30,6 +65,7 @@ impl DockerManager {
|
||||
let manager = Self {
|
||||
client,
|
||||
network_name: config.network.clone(),
|
||||
command_streams: Arc::new(RwLock::new(HashMap::new())),
|
||||
};
|
||||
|
||||
manager.ensure_network(&config.network_subnet).await?;
|
||||
@@ -45,6 +81,10 @@ impl DockerManager {
|
||||
&self.network_name
|
||||
}
|
||||
|
||||
pub(crate) fn command_streams(&self) -> &Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>> {
|
||||
&self.command_streams
|
||||
}
|
||||
|
||||
async fn ensure_network(&self, subnet: &str) -> Result<()> {
|
||||
let networks = self.client.list_networks::<String>(None).await?;
|
||||
let exists = networks
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
|
||||
@@ -94,14 +94,24 @@ impl FileSystem {
|
||||
/// Write file contents.
|
||||
pub async fn write_file(&self, path: &str, data: &[u8]) -> Result<(), DaemonError> {
|
||||
let resolved = self.resolve(path)?;
|
||||
let owner = resolved
|
||||
.parent()
|
||||
.and_then(resolve_target_ownership);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = resolved.parent() {
|
||||
fs::create_dir_all(parent).await.map_err(DaemonError::Io)?;
|
||||
if let Some(ref owner) = owner {
|
||||
apply_ownership_to_path_chain(parent, &owner)?;
|
||||
}
|
||||
}
|
||||
|
||||
debug!(path = %resolved.display(), "Writing file");
|
||||
fs::write(&resolved, data).await.map_err(DaemonError::Io)
|
||||
fs::write(&resolved, data).await.map_err(DaemonError::Io)?;
|
||||
if let Some(ref owner) = owner {
|
||||
apply_ownership(&resolved, owner.uid, owner.gid)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete files or directories.
|
||||
@@ -119,6 +129,100 @@ impl FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct OwnershipTarget {
|
||||
anchor: PathBuf,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn resolve_target_ownership(start: &Path) -> Option<OwnershipTarget> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let mut cursor = Some(start);
|
||||
let mut fallback: Option<OwnershipTarget> = None;
|
||||
|
||||
while let Some(path) = cursor {
|
||||
if let Ok(metadata) = std::fs::metadata(path) {
|
||||
let candidate = OwnershipTarget {
|
||||
anchor: path.to_path_buf(),
|
||||
uid: metadata.uid(),
|
||||
gid: metadata.gid(),
|
||||
};
|
||||
|
||||
if fallback.is_none() {
|
||||
fallback = Some(candidate.clone());
|
||||
}
|
||||
|
||||
if candidate.uid != 0 || candidate.gid != 0 {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
cursor = path.parent();
|
||||
}
|
||||
|
||||
fallback
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn resolve_target_ownership(_start: &Path) -> Option<OwnershipTarget> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn apply_ownership_to_path_chain(target: &Path, owner: &OwnershipTarget) -> Result<(), DaemonError> {
|
||||
if !target.starts_with(&owner.anchor) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut current = owner.anchor.clone();
|
||||
apply_ownership(¤t, owner.uid, owner.gid)?;
|
||||
|
||||
let remainder = match target.strip_prefix(&owner.anchor) {
|
||||
Ok(path) => path,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
for component in remainder.components() {
|
||||
current.push(component.as_os_str());
|
||||
apply_ownership(¤t, owner.uid, owner.gid)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn apply_ownership_to_path_chain(_target: &Path, _owner: &OwnershipTarget) -> Result<(), DaemonError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn apply_ownership(path: &Path, uid: u32, gid: u32) -> Result<(), DaemonError> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let bytes = path.as_os_str().as_bytes();
|
||||
let c_path = CString::new(bytes).map_err(|err| {
|
||||
DaemonError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid path for chown: {err}"),
|
||||
))
|
||||
})?;
|
||||
|
||||
let result = unsafe { libc::chown(c_path.as_ptr(), uid, gid) };
|
||||
if result != 0 {
|
||||
return Err(DaemonError::Io(std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn apply_ownership(_path: &Path, _uid: u32, _gid: u32) -> Result<(), DaemonError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
|
||||
+89
-20
@@ -34,36 +34,28 @@ fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
|
||||
for line in response.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Parse max players from "players : X humans, Y bots (Z/M max)"
|
||||
if trimmed.starts_with("players") && trimmed.contains("max") {
|
||||
if let Some(max_str) = trimmed.split('/').last() {
|
||||
if let Some(num) = max_str.split_whitespace().next() {
|
||||
max_players = num.parse().unwrap_or(0);
|
||||
}
|
||||
// Parse max players from status line variants:
|
||||
// "players : X humans, Y bots (Z/M max)"
|
||||
// "players : X humans, Y bots (Z max)"
|
||||
if trimmed.starts_with("players") {
|
||||
if let Some(parsed_max) = parse_max_players_from_line(trimmed) {
|
||||
max_players = parsed_max;
|
||||
}
|
||||
}
|
||||
|
||||
// Player table header: starts with #
|
||||
if trimmed.starts_with("# userid") {
|
||||
if trimmed.contains("---------players--------") || trimmed.starts_with("# userid") {
|
||||
in_player_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// End of player section
|
||||
if in_player_section && (trimmed.is_empty() || trimmed.starts_with('#')) {
|
||||
if trimmed.is_empty() {
|
||||
if in_player_section && (trimmed == "#end" || trimmed.starts_with("---------")) {
|
||||
in_player_section = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse player lines: "# userid name steamid ..."
|
||||
if in_player_section && trimmed.starts_with('#') {
|
||||
let parts: Vec<&str> = trimmed.splitn(6, char::is_whitespace).collect();
|
||||
if parts.len() >= 4 {
|
||||
let name = parts.get(2).unwrap_or(&"").trim_matches('"').to_string();
|
||||
let steamid = parts.get(3).unwrap_or(&"").to_string();
|
||||
|
||||
// Parse player lines for both old and current CS2 status formats.
|
||||
if in_player_section {
|
||||
if let Some((name, steamid)) = parse_player_line(trimmed) {
|
||||
players.push(Cs2Player {
|
||||
name,
|
||||
steamid,
|
||||
@@ -77,6 +69,62 @@ fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
|
||||
(players, max_players)
|
||||
}
|
||||
|
||||
fn parse_max_players_from_line(line: &str) -> Option<u32> {
|
||||
let start = line.find('(')?;
|
||||
let end = line[start + 1..].find(')')? + start + 1;
|
||||
let inside = &line[start + 1..end];
|
||||
|
||||
inside
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<u32>().ok())
|
||||
.max()
|
||||
}
|
||||
|
||||
fn parse_player_line(line: &str) -> Option<(String, String)> {
|
||||
// Skip table/header rows.
|
||||
if line.is_empty()
|
||||
|| line.starts_with("id ")
|
||||
|| line.contains("userid")
|
||||
|| line.contains("steamid")
|
||||
|| line.contains("adr name")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Legacy format: # 2 "Player" STEAM_...
|
||||
if let Some(quote_start) = line.find('"') {
|
||||
let quote_end = line[quote_start + 1..].find('"')? + quote_start + 1;
|
||||
let name = line[quote_start + 1..quote_end].trim().to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rest = line[quote_end + 1..].trim();
|
||||
let steamid = rest.split_whitespace().next()?.to_string();
|
||||
if steamid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
return Some((name, steamid));
|
||||
}
|
||||
|
||||
// Current CS2 format: ... 'PlayerName'
|
||||
let quote_end = line.rfind('\'')?;
|
||||
let before_end = &line[..quote_end];
|
||||
let quote_start = before_end.rfind('\'')?;
|
||||
if quote_start >= quote_end {
|
||||
return None;
|
||||
}
|
||||
let name = line[quote_start + 1..quote_end].trim().to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// New status output does not include steamid in player rows.
|
||||
Some((name, String::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -91,7 +139,28 @@ players : 2 humans, 0 bots (16/0 max) (not hibernating)
|
||||
# 3 "Player2" STEAM_1:0:67890 00:10 30 0 active 128000
|
||||
"#;
|
||||
let (players, max) = parse_status_response(response);
|
||||
assert_eq!(max, 0); // simplified parser
|
||||
assert_eq!(max, 16);
|
||||
assert_eq!(players.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_status_current_cs2_format() {
|
||||
let response = r#"Server: Running [0.0.0.0:27015]
|
||||
players : 1 humans, 2 bots (0 max) (not hibernating) (unreserved)
|
||||
---------players--------
|
||||
id time ping loss state rate adr name
|
||||
65535 [NoChan] 0 0 challenging 0unknown ''
|
||||
1 BOT 0 0 active 0 'Rezan'
|
||||
2 00:21 11 0 active 786432 212.154.6.153:57008 'hibna'
|
||||
3 BOT 0 0 active 0 'Squad'
|
||||
#end
|
||||
"#;
|
||||
|
||||
let (players, max) = parse_status_response(response);
|
||||
assert_eq!(max, 0);
|
||||
assert_eq!(players.len(), 3);
|
||||
assert_eq!(players[0].name, "Rezan");
|
||||
assert_eq!(players[1].name, "hibna");
|
||||
assert_eq!(players[2].name, "Squad");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
NUMCHECK='^[0-9]+$'
|
||||
MSGWARNING="\033[0;33mWARNING:\033[0m"
|
||||
|
||||
if ! [[ "$SERVERGAMEPORT" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid server port given: %s\n" "$SERVERGAMEPORT"
|
||||
SERVERGAMEPORT="7777"
|
||||
fi
|
||||
printf "Setting server port to %s\n" "$SERVERGAMEPORT"
|
||||
|
||||
if ! [[ "$SERVERMESSAGINGPORT" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid messaging port given: %s\n" "$SERVERMESSAGINGPORT"
|
||||
SERVERMESSAGINGPORT="8888"
|
||||
fi
|
||||
printf "Setting messaging port to %s\n" "$SERVERMESSAGINGPORT"
|
||||
|
||||
if ! [[ "$AUTOSAVENUM" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid autosave number given: %s\n" "$AUTOSAVENUM"
|
||||
AUTOSAVENUM="5"
|
||||
fi
|
||||
printf "Setting autosave number to %s\n" "$AUTOSAVENUM"
|
||||
|
||||
if ! [[ "$MAXOBJECTS" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid max objects number given: %s\n" "$MAXOBJECTS"
|
||||
MAXOBJECTS="2162688"
|
||||
fi
|
||||
printf "Setting max objects to %s\n" "$MAXOBJECTS"
|
||||
|
||||
if ! [[ "$MAXTICKRATE" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid max tick rate number given: %s\n" "$MAXTICKRATE"
|
||||
MAXTICKRATE="30"
|
||||
fi
|
||||
printf "Setting max tick rate to %s\n" "$MAXTICKRATE"
|
||||
|
||||
[[ "${SERVERSTREAMING,,}" == "true" ]] && SERVERSTREAMING="1" || SERVERSTREAMING="0"
|
||||
printf "Setting server streaming to %s\n" "$SERVERSTREAMING"
|
||||
|
||||
if ! [[ "$TIMEOUT" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid timeout number given: %s\n" "$TIMEOUT"
|
||||
TIMEOUT="30"
|
||||
fi
|
||||
printf "Setting timeout to %s\n" "$TIMEOUT"
|
||||
|
||||
if ! [[ "$MAXPLAYERS" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid max players given: %s\n" "$MAXPLAYERS"
|
||||
MAXPLAYERS="4"
|
||||
fi
|
||||
printf "Setting max players to %s\n" "$MAXPLAYERS"
|
||||
|
||||
if [[ "${DISABLESEASONALEVENTS,,}" == "true" ]]; then
|
||||
printf "Disabling seasonal events\n"
|
||||
DISABLESEASONALEVENTS="-DisableSeasonalEvents"
|
||||
else
|
||||
DISABLESEASONALEVENTS=""
|
||||
fi
|
||||
|
||||
if [[ "$MULTIHOME" != "" ]]; then
|
||||
if [[ "$MULTIHOME" == "::" ]]; then
|
||||
printf "Multihome will accept IPv4 and IPv6 connections\n"
|
||||
fi
|
||||
printf "Setting multihome to %s\n" "$MULTIHOME"
|
||||
MULTIHOME="-multihome=$MULTIHOME"
|
||||
fi
|
||||
|
||||
ini_args=(
|
||||
"-ini:Engine:[/Script/FactoryGame.FGSaveSession]:mNumRotatingAutosaves=$AUTOSAVENUM"
|
||||
"-ini:Engine:[/Script/Engine.GarbageCollectionSettings]:gc.MaxObjectsInEditor=$MAXOBJECTS"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:LanServerMaxTickRate=$MAXTICKRATE"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:NetServerMaxTickRate=$MAXTICKRATE"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:ConnectionTimeout=$TIMEOUT"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:InitialConnectTimeout=$TIMEOUT"
|
||||
"-ini:Engine:[ConsoleVariables]:wp.Runtime.EnableServerStreaming=$SERVERSTREAMING"
|
||||
"-ini:Game:[/Script/Engine.GameSession]:ConnectionTimeout=$TIMEOUT"
|
||||
"-ini:Game:[/Script/Engine.GameSession]:InitialConnectTimeout=$TIMEOUT"
|
||||
"-ini:Game:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
|
||||
"-ini:GameUserSettings:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
|
||||
"$DISABLESEASONALEVENTS"
|
||||
"$MULTIHOME"
|
||||
)
|
||||
|
||||
if [[ "${SKIPUPDATE,,}" != "false" ]] && [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
|
||||
printf "%s Skip update is set, but no game files exist. Updating anyway\n" "$MSGWARNING"
|
||||
SKIPUPDATE="false"
|
||||
fi
|
||||
|
||||
if [[ "${SKIPUPDATE,,}" != "true" ]]; then
|
||||
STEAMBETAPASSWORD=""
|
||||
|
||||
if [[ -n "${STEAMBETAID}" ]]; then
|
||||
printf "STEAMBETAID is set. Using beta ID: %s\n" "$STEAMBETAID"
|
||||
STEAMBETAFLAG="$STEAMBETAID"
|
||||
if [[ -n "${STEAMBETAKEY}" ]]; then
|
||||
STEAMBETAPASSWORD="-betapassword $STEAMBETAKEY"
|
||||
printf "Beta password provided\n"
|
||||
fi
|
||||
elif [[ "${STEAMBETA,,}" == "true" ]]; then
|
||||
printf "Experimental flag is set. Experimental will be downloaded instead of Early Access.\n"
|
||||
STEAMBETAFLAG="experimental"
|
||||
else
|
||||
STEAMBETAFLAG=""
|
||||
fi
|
||||
|
||||
STORAGEAVAILABLE=$(stat -f -c "%a*%S" .)
|
||||
STORAGEAVAILABLE=$((STORAGEAVAILABLE/1024/1024/1024))
|
||||
printf "Checking available storage: %sGB detected\n" "$STORAGEAVAILABLE"
|
||||
|
||||
if [[ "$STORAGEAVAILABLE" -lt 8 ]]; then
|
||||
printf "You have less than 8GB (%sGB detected) of available storage to download the game.\nIf this is a fresh install, it will probably fail.\n" "$STORAGEAVAILABLE"
|
||||
fi
|
||||
|
||||
printf "\nDownloading the latest version of the game...\n"
|
||||
if [ -f "/config/gamefiles/steamapps/appmanifest_1690800.acf" ]; then
|
||||
printf "\nRemoving the app manifest to force Steam to check for an update...\n"
|
||||
rm "/config/gamefiles/steamapps/appmanifest_1690800.acf" || true
|
||||
fi
|
||||
|
||||
if [[ -n "$STEAMBETAFLAG" ]]; then
|
||||
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" -beta "$STEAMBETAFLAG" $STEAMBETAPASSWORD validate +quit
|
||||
else
|
||||
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" validate +quit
|
||||
fi
|
||||
|
||||
cp -r /home/steam/.steam/steam/logs/* "/config/logs/steam" || printf "Failed to store Steam logs\n"
|
||||
else
|
||||
printf "Skipping update as flag is set\n"
|
||||
fi
|
||||
|
||||
printf "Launching game server\n\n"
|
||||
|
||||
cp -r "/config/saved/server/." "/config/backups/" 2>/dev/null || true
|
||||
cp -r "${GAMESAVESDIR}/server/." "/config/backups" 2>/dev/null || true
|
||||
rm -rf "$GAMESAVESDIR"
|
||||
ln -sf "/config/saved" "$GAMESAVESDIR"
|
||||
|
||||
if [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
|
||||
printf "FactoryServer launch script is missing.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd /config/gamefiles || exit 1
|
||||
|
||||
chmod +x FactoryServer.sh || true
|
||||
./FactoryServer.sh -Port="$SERVERGAMEPORT" -ReliablePort="$SERVERMESSAGINGPORT" -ExternalReliablePort="$SERVERMESSAGINGPORT" "${ini_args[@]}" "$@" &
|
||||
|
||||
sleep 2
|
||||
satisfactory_pid=$(ps --ppid ${!} o pid=)
|
||||
|
||||
shutdown() {
|
||||
printf "\nReceived SIGINT. Shutting down.\n"
|
||||
kill -INT $satisfactory_pid 2>/dev/null
|
||||
}
|
||||
trap shutdown SIGINT SIGTERM
|
||||
|
||||
wait
|
||||
+559
-38
@@ -1,14 +1,23 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::ffi::CString;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use futures::StreamExt;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::{Request, Response, Status};
|
||||
use tracing::{info, error};
|
||||
use tracing::{info, error, warn};
|
||||
|
||||
use crate::command::CommandDispatcher;
|
||||
use crate::server::{ServerManager, PortMap};
|
||||
use crate::filesystem::FileSystem;
|
||||
use crate::backup::BackupManager;
|
||||
use crate::managed_mysql::ManagedMysqlManager;
|
||||
|
||||
// Import generated protobuf types
|
||||
pub mod pb {
|
||||
@@ -20,14 +29,34 @@ use pb::*;
|
||||
|
||||
pub struct DaemonServiceImpl {
|
||||
server_manager: Arc<ServerManager>,
|
||||
command_dispatcher: Arc<CommandDispatcher>,
|
||||
backup_manager: BackupManager,
|
||||
managed_mysql: Arc<ManagedMysqlManager>,
|
||||
daemon_token: String,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl DaemonServiceImpl {
|
||||
pub fn new(server_manager: Arc<ServerManager>, daemon_token: String) -> Self {
|
||||
pub fn new(
|
||||
server_manager: Arc<ServerManager>,
|
||||
command_dispatcher: Arc<CommandDispatcher>,
|
||||
daemon_token: String,
|
||||
backup_root: PathBuf,
|
||||
api_url: String,
|
||||
managed_mysql: Arc<ManagedMysqlManager>,
|
||||
) -> Self {
|
||||
let backup_manager = BackupManager::new(
|
||||
server_manager.clone(),
|
||||
backup_root,
|
||||
api_url,
|
||||
daemon_token.clone(),
|
||||
);
|
||||
|
||||
Self {
|
||||
server_manager,
|
||||
command_dispatcher,
|
||||
backup_manager,
|
||||
managed_mysql,
|
||||
daemon_token,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
@@ -50,6 +79,56 @@ impl DaemonServiceImpl {
|
||||
let data_path = self.server_manager.data_root().join(uuid);
|
||||
FileSystem::new(data_path)
|
||||
}
|
||||
|
||||
async fn get_server_runtime(
|
||||
&self,
|
||||
uuid: &str,
|
||||
) -> Option<(String, HashMap<String, String>)> {
|
||||
if let Ok(spec) = self.server_manager.get_server(uuid).await {
|
||||
return Some((spec.docker_image, spec.environment));
|
||||
}
|
||||
|
||||
self.server_manager
|
||||
.docker()
|
||||
.container_runtime_metadata(uuid)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn env_value(env: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|k| env.get(*k))
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn env_u16(env: &HashMap<String, String>, keys: &[&str]) -> Option<u16> {
|
||||
Self::env_value(env, keys).and_then(|v| v.parse::<u16>().ok())
|
||||
}
|
||||
|
||||
fn env_i32(env: &HashMap<String, String>, keys: &[&str]) -> Option<i32> {
|
||||
Self::env_value(env, keys).and_then(|v| v.parse::<i32>().ok())
|
||||
}
|
||||
|
||||
fn cs2_rcon_password(env: &HashMap<String, String>) -> String {
|
||||
Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"])
|
||||
.unwrap_or_else(|| "changeme".to_string())
|
||||
}
|
||||
|
||||
fn map_ports(ports: &[PortMapping]) -> Vec<PortMap> {
|
||||
ports
|
||||
.iter()
|
||||
.map(|p| PortMap {
|
||||
host_port: p.host_port as u16,
|
||||
container_port: p.container_port as u16,
|
||||
protocol: if p.protocol.is_empty() {
|
||||
"tcp".to_string()
|
||||
} else {
|
||||
p.protocol.clone()
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
type GrpcStream<T> = Pin<Box<dyn futures::Stream<Item = Result<T, Status>> + Send>>;
|
||||
@@ -87,17 +166,12 @@ impl DaemonService for DaemonServiceImpl {
|
||||
self.check_auth(&request)?;
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||
let data_root = self.server_manager.data_root().clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut previous_cpu = read_cpu_sample();
|
||||
loop {
|
||||
// Read system stats
|
||||
let stats = NodeStats {
|
||||
cpu_percent: 0.0, // TODO: real system stats
|
||||
memory_used: 0,
|
||||
memory_total: 0,
|
||||
disk_used: 0,
|
||||
disk_total: 0,
|
||||
};
|
||||
let stats = read_node_stats(&data_root, &mut previous_cpu);
|
||||
if tx.send(Ok(stats)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -117,20 +191,6 @@ impl DaemonService for DaemonServiceImpl {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
let ports: Vec<PortMap> = req
|
||||
.ports
|
||||
.iter()
|
||||
.map(|p| PortMap {
|
||||
host_port: p.host_port as u16,
|
||||
container_port: p.container_port as u16,
|
||||
protocol: if p.protocol.is_empty() {
|
||||
"tcp".to_string()
|
||||
} else {
|
||||
p.protocol.clone()
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.server_manager
|
||||
.create_server(
|
||||
req.uuid.clone(),
|
||||
@@ -140,7 +200,7 @@ impl DaemonService for DaemonServiceImpl {
|
||||
req.cpu_limit,
|
||||
req.startup_command,
|
||||
req.environment,
|
||||
ports,
|
||||
Self::map_ports(&req.ports),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Status::from(e))?;
|
||||
@@ -151,6 +211,33 @@ impl DaemonService for DaemonServiceImpl {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_server(
|
||||
&self,
|
||||
request: Request<UpdateServerRequest>,
|
||||
) -> Result<Response<ServerResponse>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
let state = self.server_manager
|
||||
.update_server(
|
||||
req.uuid.clone(),
|
||||
req.docker_image,
|
||||
req.memory_limit,
|
||||
req.disk_limit,
|
||||
req.cpu_limit,
|
||||
req.startup_command,
|
||||
req.environment,
|
||||
Self::map_ports(&req.ports),
|
||||
)
|
||||
.await
|
||||
.map_err(Status::from)?;
|
||||
|
||||
Ok(Response::new(ServerResponse {
|
||||
uuid: req.uuid,
|
||||
status: state.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete_server(
|
||||
&self,
|
||||
request: Request<ServerIdentifier>,
|
||||
@@ -181,6 +268,107 @@ impl DaemonService for DaemonServiceImpl {
|
||||
Ok(Response::new(Empty {}))
|
||||
}
|
||||
|
||||
async fn create_database(
|
||||
&self,
|
||||
request: Request<CreateDatabaseRequest>,
|
||||
) -> Result<Response<ManagedDatabaseCredentials>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.server_uuid.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Server UUID is required"));
|
||||
}
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Database name is required"));
|
||||
}
|
||||
|
||||
let password = req.password.trim();
|
||||
let database = self
|
||||
.managed_mysql
|
||||
.create_database(
|
||||
req.server_uuid.trim(),
|
||||
req.name.trim(),
|
||||
if password.is_empty() { None } else { Some(password) },
|
||||
)
|
||||
.await
|
||||
.map_err(Status::from)?;
|
||||
|
||||
Ok(Response::new(ManagedDatabaseCredentials {
|
||||
database_name: database.database_name,
|
||||
username: database.username,
|
||||
password: database.password,
|
||||
host: database.host,
|
||||
port: i32::from(database.port),
|
||||
phpmyadmin_url: database.phpmyadmin_url.unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn import_database_sql(
|
||||
&self,
|
||||
request: Request<ImportDatabaseSqlRequest>,
|
||||
) -> Result<Response<Empty>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.database_name.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Database name is required"));
|
||||
}
|
||||
if req.sql.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("SQL payload is required"));
|
||||
}
|
||||
|
||||
self.managed_mysql
|
||||
.import_sql(req.database_name.trim(), &req.sql)
|
||||
.await
|
||||
.map_err(Status::from)?;
|
||||
|
||||
Ok(Response::new(Empty {}))
|
||||
}
|
||||
|
||||
async fn update_database_password(
|
||||
&self,
|
||||
request: Request<UpdateDatabasePasswordRequest>,
|
||||
) -> Result<Response<Empty>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.username.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Database username is required"));
|
||||
}
|
||||
if req.password.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Database password is required"));
|
||||
}
|
||||
|
||||
self.managed_mysql
|
||||
.update_password(req.username.trim(), req.password.trim())
|
||||
.await
|
||||
.map_err(Status::from)?;
|
||||
|
||||
Ok(Response::new(Empty {}))
|
||||
}
|
||||
|
||||
async fn delete_database(
|
||||
&self,
|
||||
request: Request<DeleteDatabaseRequest>,
|
||||
) -> Result<Response<Empty>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.database_name.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Database name is required"));
|
||||
}
|
||||
if req.username.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Database username is required"));
|
||||
}
|
||||
|
||||
self.managed_mysql
|
||||
.delete_database(req.database_name.trim(), req.username.trim())
|
||||
.await
|
||||
.map_err(Status::from)?;
|
||||
|
||||
Ok(Response::new(Empty {}))
|
||||
}
|
||||
|
||||
// === Power ===
|
||||
|
||||
async fn set_power_state(
|
||||
@@ -241,9 +429,6 @@ impl DaemonService for DaemonServiceImpl {
|
||||
self.check_auth(&request)?;
|
||||
let uuid = request.into_inner().uuid;
|
||||
|
||||
// Verify server exists
|
||||
let _ = self.server_manager.get_server(&uuid).await.map_err(Status::from)?;
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(256);
|
||||
let docker = self.server_manager.docker().clone();
|
||||
|
||||
@@ -283,8 +468,7 @@ impl DaemonService for DaemonServiceImpl {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
self.server_manager
|
||||
.docker()
|
||||
self.command_dispatcher
|
||||
.send_command(&req.uuid, &req.command)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?;
|
||||
@@ -391,8 +575,20 @@ impl DaemonService for DaemonServiceImpl {
|
||||
request: Request<BackupRequest>,
|
||||
) -> Result<Response<BackupResponse>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
// TODO: implement backup creation
|
||||
Err(Status::unimplemented("Not yet implemented"))
|
||||
let req = request.into_inner();
|
||||
|
||||
let (_path, size_bytes, checksum) = self
|
||||
.backup_manager
|
||||
.create_backup(&req.server_uuid, &req.backup_id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("Failed to create backup: {e}")))?;
|
||||
|
||||
Ok(Response::new(BackupResponse {
|
||||
backup_id: req.backup_id,
|
||||
size_bytes: size_bytes.min(i64::MAX as u64) as i64,
|
||||
checksum,
|
||||
success: true,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn restore_backup(
|
||||
@@ -400,8 +596,21 @@ impl DaemonService for DaemonServiceImpl {
|
||||
request: Request<RestoreBackupRequest>,
|
||||
) -> Result<Response<Empty>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
// TODO: implement backup restoration
|
||||
Err(Status::unimplemented("Not yet implemented"))
|
||||
let req = request.into_inner();
|
||||
|
||||
let cdn_path = if req.cdn_download_url.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(req.cdn_download_url.as_str())
|
||||
};
|
||||
|
||||
self
|
||||
.backup_manager
|
||||
.restore_backup(&req.server_uuid, &req.backup_id, cdn_path)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("Failed to restore backup: {e}")))?;
|
||||
|
||||
Ok(Response::new(Empty {}))
|
||||
}
|
||||
|
||||
async fn delete_backup(
|
||||
@@ -409,8 +618,15 @@ impl DaemonService for DaemonServiceImpl {
|
||||
request: Request<BackupIdentifier>,
|
||||
) -> Result<Response<Empty>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
// TODO: implement backup deletion
|
||||
Err(Status::unimplemented("Not yet implemented"))
|
||||
let req = request.into_inner();
|
||||
|
||||
self
|
||||
.backup_manager
|
||||
.delete_backup(&req.server_uuid, &req.backup_id, None)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("Failed to delete backup: {e}")))?;
|
||||
|
||||
Ok(Response::new(Empty {}))
|
||||
}
|
||||
|
||||
// === Stats ===
|
||||
@@ -482,14 +698,267 @@ impl DaemonService for DaemonServiceImpl {
|
||||
request: Request<ServerIdentifier>,
|
||||
) -> Result<Response<PlayerList>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
// TODO: implement game-specific player queries (RCON)
|
||||
let uuid = request.into_inner().uuid;
|
||||
|
||||
let fs = self.get_fs(&uuid);
|
||||
let properties = match fs.read_file("server.properties").await {
|
||||
Ok(data) => parse_properties_map(&String::from_utf8_lossy(&data)),
|
||||
Err(_) => HashMap::new(),
|
||||
};
|
||||
let max_from_properties = properties
|
||||
.get("max-players")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let rcon_enabled_from_properties = properties
|
||||
.get("enable-rcon")
|
||||
.map(|v| v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
let rcon_password_from_properties = properties
|
||||
.get("rcon.password")
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.cloned();
|
||||
let rcon_port_from_properties = properties
|
||||
.get("rcon.port")
|
||||
.and_then(|v| v.parse::<u16>().ok())
|
||||
.unwrap_or(25575);
|
||||
|
||||
// Try game-specific player discovery using runtime metadata (works even after daemon restart).
|
||||
let mut max_from_runtime_env = 0;
|
||||
if let Some((image, env)) = self.get_server_runtime(&uuid).await {
|
||||
let image = image.to_lowercase();
|
||||
|
||||
if image.contains("minecraft") {
|
||||
let password = Self::env_value(&env, &["RCON_PASSWORD", "MCRCON_PASSWORD"])
|
||||
.or_else(|| {
|
||||
if rcon_enabled_from_properties {
|
||||
rcon_password_from_properties.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(password) = password {
|
||||
let host = Self::env_value(&env, &["RCON_HOST"])
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let port = Self::env_u16(&env, &["RCON_PORT"])
|
||||
.unwrap_or(rcon_port_from_properties);
|
||||
let address = format!("{}:{}", host, port);
|
||||
|
||||
match crate::game::minecraft::get_players(&address, &password).await {
|
||||
Ok((players, max)) => {
|
||||
let mapped = players
|
||||
.into_iter()
|
||||
.map(|p| Player {
|
||||
name: p.name,
|
||||
uuid: String::new(),
|
||||
connected_at: 0,
|
||||
})
|
||||
.collect();
|
||||
return Ok(Response::new(PlayerList {
|
||||
players: mapped,
|
||||
max_players: max as i32,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(uuid = %uuid, error = %e, "Minecraft RCON player query failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if image.contains("csgo") || image.contains("cs2") {
|
||||
max_from_runtime_env = Self::env_i32(&env, &["CS2_MAXPLAYERS", "SRCDS_MAXPLAYERS"])
|
||||
.unwrap_or(0);
|
||||
|
||||
let host = Self::env_value(&env, &["RCON_HOST"])
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015);
|
||||
let password = Self::cs2_rcon_password(&env);
|
||||
let address = format!("{}:{}", host, port);
|
||||
|
||||
match crate::game::cs2::get_players(&address, &password).await {
|
||||
Ok((players, max)) => {
|
||||
let mapped = players
|
||||
.into_iter()
|
||||
.map(|p| Player {
|
||||
name: p.name,
|
||||
uuid: p.steamid,
|
||||
connected_at: 0,
|
||||
})
|
||||
.collect();
|
||||
let max_players = if max > 0 { max as i32 } else { max_from_runtime_env };
|
||||
return Ok(Response::new(PlayerList {
|
||||
players: mapped,
|
||||
max_players,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(uuid = %uuid, error = %e, "CS2 RCON player query failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for restarted daemon / missing runtime spec:
|
||||
// try querying `rcon-cli list` inside the container and parse output.
|
||||
if let Ok(output) = self.server_manager.docker().rcon_command(&uuid, "list").await {
|
||||
let (names, max) = parse_minecraft_list_output(&output);
|
||||
if !names.is_empty() || max > 0 {
|
||||
let mapped = names
|
||||
.into_iter()
|
||||
.map(|name| Player {
|
||||
name,
|
||||
uuid: String::new(),
|
||||
connected_at: 0,
|
||||
})
|
||||
.collect();
|
||||
return Ok(Response::new(PlayerList {
|
||||
players: mapped,
|
||||
max_players: if max > 0 { max } else { max_from_properties },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Response::new(PlayerList {
|
||||
players: vec![],
|
||||
max_players: 0,
|
||||
max_players: if max_from_runtime_env > 0 {
|
||||
max_from_runtime_env
|
||||
} else {
|
||||
max_from_properties
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CpuSample {
|
||||
total: u64,
|
||||
idle: u64,
|
||||
}
|
||||
|
||||
fn read_node_stats(data_root: &Path, previous_cpu: &mut Option<CpuSample>) -> NodeStats {
|
||||
let current_cpu = read_cpu_sample();
|
||||
let cpu_percent = match (*previous_cpu, current_cpu) {
|
||||
(Some(prev), Some(current)) => calculate_node_cpu_percent(prev, current),
|
||||
_ => 0.0,
|
||||
};
|
||||
*previous_cpu = current_cpu;
|
||||
|
||||
let (memory_used, memory_total) = read_memory_stats().unwrap_or((0, 0));
|
||||
let (disk_used, disk_total) = read_disk_stats(data_root).unwrap_or((0, 0));
|
||||
|
||||
NodeStats {
|
||||
cpu_percent,
|
||||
memory_used,
|
||||
memory_total,
|
||||
disk_used,
|
||||
disk_total,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_cpu_sample() -> Option<CpuSample> {
|
||||
let content = std::fs::read_to_string("/proc/stat").ok()?;
|
||||
let line = content.lines().next()?;
|
||||
if !line.starts_with("cpu ") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut values = line
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.filter_map(|value| value.parse::<u64>().ok());
|
||||
|
||||
let user = values.next()?;
|
||||
let nice = values.next()?;
|
||||
let system = values.next()?;
|
||||
let idle = values.next()?;
|
||||
let iowait = values.next().unwrap_or(0);
|
||||
let irq = values.next().unwrap_or(0);
|
||||
let softirq = values.next().unwrap_or(0);
|
||||
let steal = values.next().unwrap_or(0);
|
||||
|
||||
let total_idle = idle.saturating_add(iowait);
|
||||
let total = user
|
||||
.saturating_add(nice)
|
||||
.saturating_add(system)
|
||||
.saturating_add(total_idle)
|
||||
.saturating_add(irq)
|
||||
.saturating_add(softirq)
|
||||
.saturating_add(steal);
|
||||
|
||||
Some(CpuSample {
|
||||
total,
|
||||
idle: total_idle,
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_node_cpu_percent(previous: CpuSample, current: CpuSample) -> f64 {
|
||||
let total_delta = current.total.saturating_sub(previous.total) as f64;
|
||||
let idle_delta = current.idle.saturating_sub(previous.idle) as f64;
|
||||
if total_delta <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
((total_delta - idle_delta) / total_delta * 100.0).clamp(0.0, 100.0)
|
||||
}
|
||||
|
||||
fn read_memory_stats() -> Option<(i64, i64)> {
|
||||
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let mut total_kib: Option<u64> = None;
|
||||
let mut available_kib: Option<u64> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("MemTotal:") {
|
||||
total_kib = line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
} else if line.starts_with("MemAvailable:") {
|
||||
available_kib = line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
}
|
||||
|
||||
if total_kib.is_some() && available_kib.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let total_bytes = total_kib?.saturating_mul(1024);
|
||||
let available_bytes = available_kib?.saturating_mul(1024);
|
||||
let used_bytes = total_bytes.saturating_sub(available_bytes);
|
||||
|
||||
Some((
|
||||
used_bytes.min(i64::MAX as u64) as i64,
|
||||
total_bytes.min(i64::MAX as u64) as i64,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_disk_stats(path: &Path) -> Option<(i64, i64)> {
|
||||
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
|
||||
let mut stats: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stats) } != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let block_size = if stats.f_frsize > 0 {
|
||||
stats.f_frsize as u128
|
||||
} else {
|
||||
stats.f_bsize as u128
|
||||
};
|
||||
|
||||
let total = block_size.saturating_mul(stats.f_blocks as u128);
|
||||
let available = block_size.saturating_mul(stats.f_bavail as u128);
|
||||
let used = total.saturating_sub(available);
|
||||
let max = i64::MAX as u128;
|
||||
|
||||
Some((used.min(max) as i64, total.min(max) as i64))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn read_disk_stats(_path: &Path) -> Option<(i64, i64)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Calculate CPU percentage from Docker stats.
|
||||
fn calculate_cpu_percent(stats: &bollard::container::Stats) -> f64 {
|
||||
let cpu_delta = stats.cpu_stats.cpu_usage.total_usage as f64
|
||||
@@ -509,3 +978,55 @@ fn calculate_cpu_percent(stats: &bollard::container::Stats) -> f64 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_properties_map(content: &str) -> HashMap<String, String> {
|
||||
let mut props = HashMap::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') {
|
||||
continue;
|
||||
}
|
||||
let mut parts = trimmed.splitn(2, '=');
|
||||
let Some(key) = parts.next() else { continue };
|
||||
let Some(value) = parts.next() else { continue };
|
||||
props.insert(key.trim().to_string(), value.trim().to_string());
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
fn parse_minecraft_list_output(output: &str) -> (Vec<String>, i32) {
|
||||
let mut max_players = 0i32;
|
||||
let mut names = Vec::new();
|
||||
|
||||
// Typical response:
|
||||
// "There are 1 of a max of 20 players online: player1, player2"
|
||||
let parts: Vec<&str> = output.splitn(2, ':').collect();
|
||||
|
||||
if let Some(header) = parts.first() {
|
||||
let mut first_number_seen = false;
|
||||
for token in header.split_whitespace() {
|
||||
if let Ok(value) = token.parse::<i32>() {
|
||||
if !first_number_seen {
|
||||
first_number_seen = true;
|
||||
} else {
|
||||
max_players = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parts.len() > 1 {
|
||||
let players = parts[1].trim();
|
||||
if !players.is_empty() {
|
||||
for name in players.split(',') {
|
||||
let clean = name.trim();
|
||||
if !clean.is_empty() {
|
||||
names.push(clean.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(names, max_players)
|
||||
}
|
||||
|
||||
+26
-1
@@ -6,19 +6,25 @@ use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod auth;
|
||||
mod backup;
|
||||
mod command;
|
||||
mod config;
|
||||
mod docker;
|
||||
mod error;
|
||||
mod filesystem;
|
||||
mod game;
|
||||
mod grpc;
|
||||
mod managed_mysql;
|
||||
mod scheduler;
|
||||
mod server;
|
||||
|
||||
use crate::docker::DockerManager;
|
||||
use crate::grpc::DaemonServiceImpl;
|
||||
use crate::grpc::service::pb::daemon_service_server::DaemonServiceServer;
|
||||
use crate::managed_mysql::ManagedMysqlManager;
|
||||
use crate::server::ServerManager;
|
||||
use crate::command::CommandDispatcher;
|
||||
|
||||
const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -43,10 +49,24 @@ async fn main() -> Result<()> {
|
||||
let server_manager = Arc::new(ServerManager::new(docker, &config));
|
||||
info!("Server manager initialized");
|
||||
|
||||
let recovered_servers = server_manager.recover_existing_servers().await?;
|
||||
info!(recovered_servers, "Recovered managed servers from Docker");
|
||||
|
||||
// Initialize shared command dispatcher (single command pipeline for all games/sources)
|
||||
let command_dispatcher = Arc::new(CommandDispatcher::new(server_manager.clone()));
|
||||
info!("Command dispatcher initialized");
|
||||
|
||||
let managed_mysql = Arc::new(ManagedMysqlManager::new(config.managed_mysql.clone())?);
|
||||
info!(enabled = managed_mysql.is_enabled(), "Managed MySQL initialized");
|
||||
|
||||
// Create gRPC service
|
||||
let daemon_service = DaemonServiceImpl::new(
|
||||
server_manager.clone(),
|
||||
command_dispatcher.clone(),
|
||||
config.node_token.clone(),
|
||||
config.backup_path.clone(),
|
||||
config.api_url.clone(),
|
||||
managed_mysql.clone(),
|
||||
);
|
||||
|
||||
// Start gRPC server
|
||||
@@ -64,6 +84,7 @@ async fn main() -> Result<()> {
|
||||
// Scheduler task
|
||||
let sched = Arc::new(scheduler::Scheduler::new(
|
||||
server_manager.clone(),
|
||||
command_dispatcher.clone(),
|
||||
config.api_url.clone(),
|
||||
config.node_token.clone(),
|
||||
));
|
||||
@@ -73,8 +94,12 @@ async fn main() -> Result<()> {
|
||||
info!("Scheduler initialized");
|
||||
|
||||
// Start serving
|
||||
let daemon_service = DaemonServiceServer::new(daemon_service)
|
||||
.max_decoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES)
|
||||
.max_encoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES);
|
||||
|
||||
Server::builder()
|
||||
.add_service(DaemonServiceServer::new(daemon_service))
|
||||
.add_service(daemon_service)
|
||||
.serve_with_shutdown(addr, async {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
info!("Shutdown signal received");
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
use std::io::ErrorKind;
|
||||
use std::process::Stdio;
|
||||
|
||||
use reqwest::Url;
|
||||
use thiserror::Error;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use tonic::Status;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::ManagedMysqlConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ManagedMysqlRuntimeConfig {
|
||||
admin_database: String,
|
||||
admin_host: String,
|
||||
admin_password: String,
|
||||
admin_port: u16,
|
||||
admin_username: String,
|
||||
client_bin: Option<String>,
|
||||
connection_host: String,
|
||||
connection_port: u16,
|
||||
phpmyadmin_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ManagedMysqlDatabase {
|
||||
pub database_name: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub phpmyadmin_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManagedMysqlError {
|
||||
#[error("Managed MySQL is not configured on this node")]
|
||||
NotConfigured,
|
||||
|
||||
#[error("Managed MySQL configuration is invalid: {0}")]
|
||||
InvalidConfig(String),
|
||||
|
||||
#[error("Managed MySQL client binary is not installed on this node")]
|
||||
ClientMissing,
|
||||
|
||||
#[error("Managed MySQL command failed: {0}")]
|
||||
CommandFailed(String),
|
||||
|
||||
#[error("Managed MySQL I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<ManagedMysqlError> for Status {
|
||||
fn from(error: ManagedMysqlError) -> Self {
|
||||
match error {
|
||||
ManagedMysqlError::NotConfigured | ManagedMysqlError::ClientMissing => {
|
||||
Status::failed_precondition(error.to_string())
|
||||
}
|
||||
ManagedMysqlError::InvalidConfig(_) => Status::internal(error.to_string()),
|
||||
ManagedMysqlError::CommandFailed(_) => Status::internal(error.to_string()),
|
||||
ManagedMysqlError::Io(_) => Status::internal(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ManagedMysqlManager {
|
||||
config: Option<ManagedMysqlRuntimeConfig>,
|
||||
}
|
||||
|
||||
impl ManagedMysqlManager {
|
||||
pub fn new(config: Option<ManagedMysqlConfig>) -> Result<Self, ManagedMysqlError> {
|
||||
let runtime = match config {
|
||||
Some(config) => Some(resolve_runtime_config(config)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Self { config: runtime })
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.config.is_some()
|
||||
}
|
||||
|
||||
pub async fn create_database(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
label: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<ManagedMysqlDatabase, ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
let label = label.trim();
|
||||
if label.is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"Database name is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let database_name = build_database_name(server_uuid, label);
|
||||
let username = build_username(server_uuid);
|
||||
let password = build_password(password);
|
||||
|
||||
self.run_sql(
|
||||
config,
|
||||
&format!(
|
||||
"CREATE DATABASE {} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||
escape_identifier(&database_name)
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(error) = self
|
||||
.run_sql(
|
||||
config,
|
||||
&format!(
|
||||
"CREATE USER {}@'%' IDENTIFIED BY {};GRANT ALL PRIVILEGES ON {}.* TO {}@'%'",
|
||||
escape_string(&username),
|
||||
escape_string(&password),
|
||||
escape_identifier(&database_name),
|
||||
escape_string(&username),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = self
|
||||
.run_sql(
|
||||
config,
|
||||
&format!("DROP DATABASE IF EXISTS {}", escape_identifier(&database_name)),
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(ManagedMysqlDatabase {
|
||||
database_name: database_name.clone(),
|
||||
username,
|
||||
password,
|
||||
host: config.connection_host.clone(),
|
||||
port: config.connection_port,
|
||||
phpmyadmin_url: build_phpmyadmin_url(config.phpmyadmin_url.as_deref(), &database_name),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_password(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
let password = password.trim();
|
||||
if password.is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"Database password is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.run_sql(
|
||||
config,
|
||||
&format!(
|
||||
"ALTER USER {}@'%' IDENTIFIED BY {}",
|
||||
escape_string(username),
|
||||
escape_string(password),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn import_sql(
|
||||
&self,
|
||||
database_name: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
let database_name = database_name.trim();
|
||||
if database_name.is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"Database name is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if sql.trim().is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"SQL payload is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.run_sql_script(config, database_name, sql).await
|
||||
}
|
||||
|
||||
pub async fn delete_database(
|
||||
&self,
|
||||
database_name: &str,
|
||||
username: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
|
||||
self.run_sql(
|
||||
config,
|
||||
&format!(
|
||||
"DROP DATABASE IF EXISTS {};DROP USER IF EXISTS {}@'%'",
|
||||
escape_identifier(database_name),
|
||||
escape_string(username),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_sql(
|
||||
&self,
|
||||
config: &ManagedMysqlRuntimeConfig,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let binaries = match config.client_bin.as_deref() {
|
||||
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
|
||||
_ => vec!["mariadb".to_string(), "mysql".to_string()],
|
||||
};
|
||||
|
||||
let mut missing_binary = false;
|
||||
|
||||
for binary in binaries {
|
||||
let output = Command::new(&binary)
|
||||
.args([
|
||||
"--protocol=TCP",
|
||||
"--batch",
|
||||
"--skip-column-names",
|
||||
"-h",
|
||||
&config.admin_host,
|
||||
"-P",
|
||||
&config.admin_port.to_string(),
|
||||
"-u",
|
||||
&config.admin_username,
|
||||
&config.admin_database,
|
||||
"-e",
|
||||
sql,
|
||||
])
|
||||
.env("MYSQL_PWD", &config.admin_password)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => return Ok(()),
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let message = if !stderr.is_empty() {
|
||||
stderr
|
||||
} else if !stdout.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{} exited with status {}", binary, output.status)
|
||||
};
|
||||
return Err(ManagedMysqlError::CommandFailed(message));
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
missing_binary = true;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(ManagedMysqlError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
if missing_binary {
|
||||
return Err(ManagedMysqlError::ClientMissing);
|
||||
}
|
||||
|
||||
Err(ManagedMysqlError::ClientMissing)
|
||||
}
|
||||
|
||||
async fn run_sql_script(
|
||||
&self,
|
||||
config: &ManagedMysqlRuntimeConfig,
|
||||
database_name: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let binaries = match config.client_bin.as_deref() {
|
||||
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
|
||||
_ => vec!["mariadb".to_string(), "mysql".to_string()],
|
||||
};
|
||||
|
||||
let mut missing_binary = false;
|
||||
|
||||
for binary in binaries {
|
||||
let child = Command::new(&binary)
|
||||
.args([
|
||||
"--protocol=TCP",
|
||||
"--batch",
|
||||
"--skip-column-names",
|
||||
"-h",
|
||||
&config.admin_host,
|
||||
"-P",
|
||||
&config.admin_port.to_string(),
|
||||
"-u",
|
||||
&config.admin_username,
|
||||
database_name,
|
||||
])
|
||||
.env("MYSQL_PWD", &config.admin_password)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
match child {
|
||||
Ok(mut child) => {
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(sql.as_bytes()).await?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().await?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let message = if !stderr.is_empty() {
|
||||
stderr
|
||||
} else if !stdout.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{} exited with status {}", binary, output.status)
|
||||
};
|
||||
return Err(ManagedMysqlError::CommandFailed(message));
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
missing_binary = true;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(ManagedMysqlError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
if missing_binary {
|
||||
return Err(ManagedMysqlError::ClientMissing);
|
||||
}
|
||||
|
||||
Err(ManagedMysqlError::ClientMissing)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_runtime_config(
|
||||
config: ManagedMysqlConfig,
|
||||
) -> Result<ManagedMysqlRuntimeConfig, ManagedMysqlError> {
|
||||
let parsed = Url::parse(&config.url)
|
||||
.map_err(|error| ManagedMysqlError::InvalidConfig(error.to_string()))?;
|
||||
|
||||
if parsed.scheme() != "mysql" && parsed.scheme() != "mariadb" {
|
||||
return Err(ManagedMysqlError::InvalidConfig(
|
||||
"url must use mysql:// or mariadb://".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let admin_host = parsed.host_str().unwrap_or_default().trim().to_string();
|
||||
let admin_username = parsed.username().trim().to_string();
|
||||
|
||||
if admin_host.is_empty() || admin_username.is_empty() {
|
||||
return Err(ManagedMysqlError::InvalidConfig(
|
||||
"url must include host and username".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let admin_database = {
|
||||
let trimmed = parsed.path().trim_start_matches('/').trim();
|
||||
if trimmed.is_empty() {
|
||||
"mysql".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ManagedMysqlRuntimeConfig {
|
||||
admin_database,
|
||||
admin_host: admin_host.clone(),
|
||||
admin_password: parsed.password().unwrap_or_default().to_string(),
|
||||
admin_port: parsed.port().unwrap_or(3306),
|
||||
admin_username,
|
||||
client_bin: config.bin,
|
||||
connection_host: config.connection_host.unwrap_or(admin_host),
|
||||
connection_port: config.connection_port.unwrap_or(parsed.port().unwrap_or(3306)),
|
||||
phpmyadmin_url: config.phpmyadmin_url,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String {
|
||||
let mut normalized = String::with_capacity(value.len());
|
||||
|
||||
for ch in value.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
normalized.push(ch.to_ascii_lowercase());
|
||||
} else if !normalized.ends_with('_') {
|
||||
normalized.push('_');
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = normalized.trim_matches('_');
|
||||
if trimmed.is_empty() {
|
||||
return fallback.to_string();
|
||||
}
|
||||
|
||||
trimmed
|
||||
.chars()
|
||||
.take(max_len)
|
||||
.collect::<String>()
|
||||
.trim_end_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_database_name(server_uuid: &str, label: &str) -> String {
|
||||
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 12);
|
||||
let label_token = normalize_token(label, "db", 16);
|
||||
let suffix = Uuid::new_v4().simple().to_string();
|
||||
format!("srv_{}_{}_{}", server_token, label_token, &suffix[..8])
|
||||
.chars()
|
||||
.take(64)
|
||||
.collect::<String>()
|
||||
.trim_end_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_username(server_uuid: &str) -> String {
|
||||
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 8);
|
||||
let suffix = Uuid::new_v4().simple().to_string();
|
||||
format!("u_{}_{}", server_token, &suffix[..8])
|
||||
.chars()
|
||||
.take(32)
|
||||
.collect::<String>()
|
||||
.trim_end_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_password(password: Option<&str>) -> String {
|
||||
match password {
|
||||
Some(password) if !password.trim().is_empty() => password.trim().to_string(),
|
||||
_ => {
|
||||
let first = Uuid::new_v4().simple().to_string();
|
||||
let second = Uuid::new_v4().simple().to_string();
|
||||
format!("{}{}", first, second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_identifier(value: &str) -> String {
|
||||
format!("`{}`", value.replace('`', "``"))
|
||||
}
|
||||
|
||||
fn escape_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn build_phpmyadmin_url(base_url: Option<&str>, database_name: &str) -> Option<String> {
|
||||
let base_url = base_url?.trim();
|
||||
if base_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match Url::parse(base_url) {
|
||||
Ok(mut url) => {
|
||||
url.query_pairs_mut().append_pair("db", database_name);
|
||||
Some(url.to_string())
|
||||
}
|
||||
Err(_) => Some(base_url.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use tokio::time::{interval, Duration};
|
||||
use tracing::{info, error, warn};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::command::CommandDispatcher;
|
||||
use crate::server::ServerManager;
|
||||
|
||||
/// A scheduled task received from the panel API.
|
||||
@@ -21,6 +22,7 @@ pub struct ScheduledTask {
|
||||
/// Scheduler that polls the panel API for due tasks and executes them.
|
||||
pub struct Scheduler {
|
||||
server_manager: Arc<ServerManager>,
|
||||
command_dispatcher: Arc<CommandDispatcher>,
|
||||
api_url: String,
|
||||
node_token: String,
|
||||
poll_interval_secs: u64,
|
||||
@@ -29,11 +31,13 @@ pub struct Scheduler {
|
||||
impl Scheduler {
|
||||
pub fn new(
|
||||
server_manager: Arc<ServerManager>,
|
||||
command_dispatcher: Arc<CommandDispatcher>,
|
||||
api_url: String,
|
||||
node_token: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
server_manager,
|
||||
command_dispatcher,
|
||||
api_url,
|
||||
node_token,
|
||||
poll_interval_secs: 15,
|
||||
@@ -117,9 +121,7 @@ impl Scheduler {
|
||||
|
||||
match task.action.as_str() {
|
||||
"command" => {
|
||||
// Send command to server's stdin via Docker exec
|
||||
let docker = self.server_manager.docker();
|
||||
docker
|
||||
self.command_dispatcher
|
||||
.send_command(&task.server_uuid, &task.payload)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, error, warn};
|
||||
use anyhow::Result;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use crate::config::DaemonConfig;
|
||||
use crate::docker::DockerManager;
|
||||
@@ -18,6 +20,27 @@ pub struct ServerManager {
|
||||
}
|
||||
|
||||
impl ServerManager {
|
||||
async fn ensure_server_data_dir(&self, data_path: &PathBuf) -> Result<(), DaemonError> {
|
||||
tokio::fs::create_dir_all(data_path)
|
||||
.await
|
||||
.map_err(DaemonError::Io)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Containers may run with non-root users (e.g. steam uid 1000).
|
||||
// Keep server directory writable to avoid install/start failures.
|
||||
let permissions = std::fs::Permissions::from_mode(0o777);
|
||||
tokio::fs::set_permissions(data_path, permissions)
|
||||
.await
|
||||
.map_err(DaemonError::Io)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_running_state(state: &str) -> bool {
|
||||
matches!(state, "running" | "restarting")
|
||||
}
|
||||
|
||||
pub fn new(docker: Arc<DockerManager>, config: &DaemonConfig) -> Self {
|
||||
Self {
|
||||
servers: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -26,6 +49,32 @@ impl ServerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild in-memory server specs from existing managed Docker containers.
|
||||
pub async fn recover_existing_servers(&self) -> Result<usize, DaemonError> {
|
||||
let recovered = self
|
||||
.docker
|
||||
.recover_managed_server_specs(&self.data_root)
|
||||
.await
|
||||
.map_err(|error| DaemonError::Internal(format!("Failed to recover managed containers: {}", error)))?;
|
||||
|
||||
let recovered_count = recovered.len();
|
||||
let mut servers = self.servers.write().await;
|
||||
servers.clear();
|
||||
|
||||
for spec in recovered {
|
||||
self.ensure_server_data_dir(&spec.data_path).await?;
|
||||
info!(
|
||||
uuid = %spec.uuid,
|
||||
state = %spec.state,
|
||||
image = %spec.docker_image,
|
||||
"Recovered managed server from Docker runtime"
|
||||
);
|
||||
servers.insert(spec.uuid.clone(), spec);
|
||||
}
|
||||
|
||||
Ok(recovered_count)
|
||||
}
|
||||
|
||||
/// Get server spec by UUID.
|
||||
pub async fn get_server(&self, uuid: &str) -> Result<ServerSpec, DaemonError> {
|
||||
let servers = self.servers.read().await;
|
||||
@@ -59,11 +108,7 @@ impl ServerManager {
|
||||
}
|
||||
|
||||
let data_path = self.data_root.join(&uuid);
|
||||
|
||||
// Create data directory
|
||||
tokio::fs::create_dir_all(&data_path)
|
||||
.await
|
||||
.map_err(DaemonError::Io)?;
|
||||
self.ensure_server_data_dir(&data_path).await?;
|
||||
|
||||
let spec = ServerSpec {
|
||||
uuid: uuid.clone(),
|
||||
@@ -98,6 +143,121 @@ impl ServerManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recreate a server container with updated runtime configuration while preserving data files.
|
||||
pub async fn update_server(
|
||||
&self,
|
||||
uuid: String,
|
||||
docker_image: String,
|
||||
memory_limit: i64,
|
||||
disk_limit: i64,
|
||||
cpu_limit: i32,
|
||||
startup_command: String,
|
||||
environment: HashMap<String, String>,
|
||||
ports: Vec<PortMap>,
|
||||
) -> Result<ServerState, DaemonError> {
|
||||
let existing = {
|
||||
let servers = self.servers.read().await;
|
||||
servers.get(&uuid).cloned()
|
||||
};
|
||||
|
||||
if matches!(existing.as_ref().map(|spec| &spec.state), Some(ServerState::Installing)) {
|
||||
return Err(DaemonError::InvalidStateTransition {
|
||||
current: "installing".to_string(),
|
||||
requested: "update".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let runtime_state = self
|
||||
.docker
|
||||
.container_state(&uuid)
|
||||
.await
|
||||
.map_err(|e| DaemonError::Internal(format!("Failed to inspect container: {}", e)))?;
|
||||
|
||||
if existing.is_none() && runtime_state.is_none() {
|
||||
return Err(DaemonError::ServerNotFound(uuid));
|
||||
}
|
||||
|
||||
let should_restart = runtime_state
|
||||
.as_deref()
|
||||
.map(Self::is_running_state)
|
||||
.unwrap_or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.map(|spec| matches!(spec.state, ServerState::Running | ServerState::Starting))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let data_path = existing
|
||||
.as_ref()
|
||||
.map(|spec| spec.data_path.clone())
|
||||
.unwrap_or_else(|| self.data_root.join(&uuid));
|
||||
self.ensure_server_data_dir(&data_path).await?;
|
||||
|
||||
let mut desired_spec = ServerSpec {
|
||||
uuid: uuid.clone(),
|
||||
docker_image,
|
||||
memory_limit,
|
||||
disk_limit,
|
||||
cpu_limit,
|
||||
startup_command,
|
||||
environment,
|
||||
ports,
|
||||
data_path,
|
||||
state: ServerState::Stopped,
|
||||
container_id: None,
|
||||
};
|
||||
|
||||
if runtime_state
|
||||
.as_deref()
|
||||
.map(Self::is_running_state)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Err(stop_error) = self.docker.stop_container(&uuid, 30).await {
|
||||
warn!(uuid = %uuid, error = %stop_error, "Graceful stop failed during server update, forcing kill");
|
||||
self.docker.kill_container(&uuid).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to stop running container during update: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
if runtime_state.is_some() {
|
||||
self.docker.remove_container(&uuid).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to remove existing container during update: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
self.docker.pull_image(&desired_spec.docker_image).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to pull updated image during server update: {}", e))
|
||||
})?;
|
||||
|
||||
match self.docker.create_container(&desired_spec).await {
|
||||
Ok(container_id) => {
|
||||
desired_spec.container_id = Some(container_id);
|
||||
}
|
||||
Err(error) => {
|
||||
desired_spec.state = ServerState::Error;
|
||||
let mut servers = self.servers.write().await;
|
||||
servers.insert(uuid.clone(), desired_spec);
|
||||
return Err(DaemonError::Internal(format!(
|
||||
"Failed to recreate container during update: {}",
|
||||
error
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut servers = self.servers.write().await;
|
||||
servers.insert(uuid.clone(), desired_spec);
|
||||
}
|
||||
|
||||
if should_restart {
|
||||
self.start_server(&uuid).await?;
|
||||
return Ok(ServerState::Running);
|
||||
}
|
||||
|
||||
Ok(ServerState::Stopped)
|
||||
}
|
||||
|
||||
/// Install a server: pull image, create container.
|
||||
async fn install_server(
|
||||
docker: Arc<DockerManager>,
|
||||
@@ -130,58 +290,92 @@ impl ServerManager {
|
||||
|
||||
/// Start a server.
|
||||
pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> {
|
||||
let mut managed = false;
|
||||
let mut previous_state: Option<ServerState> = None;
|
||||
{
|
||||
let mut servers = self.servers.write().await;
|
||||
let spec = servers
|
||||
.get_mut(uuid)
|
||||
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
|
||||
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
// Recover from stale transitional state left by a previous failed start attempt.
|
||||
if spec.state == ServerState::Starting {
|
||||
warn!(uuid = %uuid, "Recovering stale starting state");
|
||||
spec.state = ServerState::Stopped;
|
||||
}
|
||||
if !spec.can_transition_to(&ServerState::Starting) {
|
||||
return Err(DaemonError::InvalidStateTransition {
|
||||
current: spec.state.to_string(),
|
||||
requested: "starting".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
previous_state = Some(spec.state.clone());
|
||||
spec.state = ServerState::Starting;
|
||||
drop(servers);
|
||||
managed = true;
|
||||
}
|
||||
}
|
||||
|
||||
self.docker.start_container(uuid).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to start container: {}", e))
|
||||
})?;
|
||||
if let Err(e) = self.docker.start_container(uuid).await {
|
||||
if managed {
|
||||
let mut servers = self.servers.write().await;
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
spec.state = previous_state.unwrap_or(ServerState::Error);
|
||||
}
|
||||
}
|
||||
return Err(DaemonError::Internal(format!("Failed to start container: {}", e)));
|
||||
}
|
||||
|
||||
if managed {
|
||||
let mut servers = self.servers.write().await;
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
spec.state = ServerState::Running;
|
||||
}
|
||||
} else {
|
||||
info!(uuid = %uuid, "Started container without managed runtime state");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop a server.
|
||||
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> {
|
||||
let mut managed = false;
|
||||
let mut previous_state: Option<ServerState> = None;
|
||||
{
|
||||
let mut servers = self.servers.write().await;
|
||||
let spec = servers
|
||||
.get_mut(uuid)
|
||||
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
|
||||
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
// Recover from stale transitional state left by a previous failed stop attempt.
|
||||
if spec.state == ServerState::Stopping {
|
||||
warn!(uuid = %uuid, "Recovering stale stopping state");
|
||||
spec.state = ServerState::Running;
|
||||
}
|
||||
if !spec.can_transition_to(&ServerState::Stopping) {
|
||||
return Err(DaemonError::InvalidStateTransition {
|
||||
current: spec.state.to_string(),
|
||||
requested: "stopping".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
previous_state = Some(spec.state.clone());
|
||||
spec.state = ServerState::Stopping;
|
||||
drop(servers);
|
||||
managed = true;
|
||||
}
|
||||
}
|
||||
|
||||
self.docker.stop_container(uuid, 30).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to stop container: {}", e))
|
||||
})?;
|
||||
if let Err(e) = self.docker.stop_container(uuid, 30).await {
|
||||
if managed {
|
||||
let mut servers = self.servers.write().await;
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
spec.state = previous_state.unwrap_or(ServerState::Error);
|
||||
}
|
||||
}
|
||||
return Err(DaemonError::Internal(format!("Failed to stop container: {}", e)));
|
||||
}
|
||||
|
||||
if managed {
|
||||
let mut servers = self.servers.write().await;
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
spec.state = ServerState::Stopped;
|
||||
}
|
||||
} else {
|
||||
info!(uuid = %uuid, "Stopped container without managed runtime state");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# --- Build ---
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL=/api
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
RUN pnpm --filter @source/shared build && \
|
||||
pnpm --filter @source/ui build && \
|
||||
pnpm --filter @source/web build
|
||||
|
||||
# --- Production (nginx) ---
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost/health || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,54 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 '{"status":"ok"}';
|
||||
add_header Content-Type application/json;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Socket.IO proxy
|
||||
location /socket.io/ {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Static assets caching
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -4,6 +4,7 @@ import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router';
|
||||
import { Toaster } from 'sonner';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ErrorBoundary } from '@/components/error-boundary';
|
||||
|
||||
// Layouts
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
@@ -16,6 +17,7 @@ import { RegisterPage } from '@/pages/auth/register';
|
||||
// App pages
|
||||
import { OrganizationsPage } from '@/pages/organizations/index';
|
||||
import { DashboardPage } from '@/pages/dashboard/index';
|
||||
import { ServersPage } from '@/pages/servers/index';
|
||||
import { CreateServerPage } from '@/pages/servers/create';
|
||||
import { NodesPage } from '@/pages/nodes/index';
|
||||
import { NodeDetailPage } from '@/pages/nodes/detail';
|
||||
@@ -29,12 +31,16 @@ import { SchedulesPage } from '@/pages/server/schedules';
|
||||
import { ConfigPage } from '@/pages/server/config';
|
||||
import { PluginsPage } from '@/pages/server/plugins';
|
||||
import { PlayersPage } from '@/pages/server/players';
|
||||
import { DatabasesPage } from '@/pages/server/databases';
|
||||
import { ServerSettingsPage } from '@/pages/server/settings';
|
||||
|
||||
// Admin pages
|
||||
import { AdminUsersPage } from '@/pages/admin/users';
|
||||
import { AdminGamesPage } from '@/pages/admin/games';
|
||||
import { AdminPluginsPage } from '@/pages/admin/plugins';
|
||||
import { AdminNodesPage } from '@/pages/admin/nodes';
|
||||
import { AdminAuditLogsPage } from '@/pages/admin/audit-logs';
|
||||
import { AccountSecurityPage } from '@/pages/account/security';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -69,6 +75,7 @@ function AuthGuard() {
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<BrowserRouter>
|
||||
@@ -85,17 +92,23 @@ export function App() {
|
||||
|
||||
{/* Org-scoped routes */}
|
||||
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/org/:orgId/servers" element={<ServersPage />} />
|
||||
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
|
||||
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
|
||||
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
|
||||
<Route path="/org/:orgId/settings" element={<Navigate to="members" replace />} />
|
||||
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
|
||||
|
||||
{/* Account */}
|
||||
<Route path="/account/security" element={<AccountSecurityPage />} />
|
||||
|
||||
{/* Server detail */}
|
||||
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
|
||||
<Route index element={<Navigate to="console" replace />} />
|
||||
<Route path="console" element={<ConsolePage />} />
|
||||
<Route path="files" element={<FilesPage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="databases" element={<DatabasesPage />} />
|
||||
<Route path="plugins" element={<PluginsPage />} />
|
||||
<Route path="backups" element={<BackupsPage />} />
|
||||
<Route path="schedules" element={<SchedulesPage />} />
|
||||
@@ -106,7 +119,8 @@ export function App() {
|
||||
{/* Admin */}
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/games" element={<AdminGamesPage />} />
|
||||
<Route path="/admin/nodes" element={<NodesPage />} />
|
||||
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
|
||||
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
||||
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
@@ -118,5 +132,6 @@ export function App() {
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, type ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info.componentStack);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4 p-8">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-semibold">Something went wrong</h2>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Outlet, useParams, Link, useLocation } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2 } from 'lucide-react';
|
||||
import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2, Database as DatabaseIcon } from 'lucide-react';
|
||||
import { cn } from '@source/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -26,6 +26,7 @@ const tabs = [
|
||||
{ label: 'Console', path: 'console', icon: Terminal },
|
||||
{ label: 'Files', path: 'files', icon: FolderOpen },
|
||||
{ label: 'Config', path: 'config', icon: Settings2 },
|
||||
{ label: 'Databases', path: 'databases', icon: DatabaseIcon },
|
||||
{ label: 'Plugins', path: 'plugins', icon: Puzzle },
|
||||
{ label: 'Backups', path: 'backups', icon: HardDrive },
|
||||
{ label: 'Schedules', path: 'schedules', icon: Calendar },
|
||||
@@ -40,6 +41,7 @@ export function ServerLayout() {
|
||||
const { data: server } = useQuery({
|
||||
queryKey: ['server', orgId, serverId],
|
||||
queryFn: () => api.get<ServerDetail>(`/organizations/${orgId}/servers/${serverId}`),
|
||||
refetchInterval: 3_000,
|
||||
});
|
||||
|
||||
const currentTab = location.pathname.split('/').pop();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Users,
|
||||
Shield,
|
||||
Gamepad2,
|
||||
Puzzle,
|
||||
ScrollText,
|
||||
ChevronLeft,
|
||||
} from 'lucide-react';
|
||||
@@ -32,8 +33,7 @@ export function Sidebar() {
|
||||
{ label: 'Dashboard', href: `/org/${orgId}/dashboard`, icon: LayoutDashboard },
|
||||
{ label: 'Servers', href: `/org/${orgId}/servers`, icon: Server },
|
||||
{ label: 'Nodes', href: `/org/${orgId}/nodes`, icon: Network },
|
||||
{ label: 'Members', href: `/org/${orgId}/settings/members`, icon: Users },
|
||||
{ label: 'Settings', href: `/org/${orgId}/settings`, icon: Settings },
|
||||
{ label: 'Settings', href: `/org/${orgId}/settings/members`, icon: Settings },
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -41,6 +41,7 @@ export function Sidebar() {
|
||||
? [
|
||||
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||
{ label: 'Games', href: '/admin/games', icon: Gamepad2 },
|
||||
{ label: 'Plugins', href: '/admin/plugins', icon: Puzzle },
|
||||
{ label: 'Nodes', href: '/admin/nodes', icon: Network },
|
||||
{ label: 'Audit Logs', href: '/admin/audit-logs', icon: ScrollText },
|
||||
]
|
||||
|
||||
@@ -19,14 +19,38 @@ interface PowerControlsProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
interface CachedServerDetail {
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function PowerControls({ serverId, orgId, status }: PowerControlsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const serverQueryKey = ['server', orgId, serverId] as const;
|
||||
|
||||
const powerMutation = useMutation({
|
||||
mutationFn: (action: string) =>
|
||||
mutationFn: (action: PowerAction) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/power`, { action }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] });
|
||||
onMutate: (action) => {
|
||||
const nextStatusByAction: Record<PowerAction, string> = {
|
||||
start: 'starting',
|
||||
stop: 'stopping',
|
||||
restart: 'stopping',
|
||||
kill: 'stopped',
|
||||
};
|
||||
|
||||
queryClient.setQueryData<CachedServerDetail | undefined>(serverQueryKey, (current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
status: nextStatusByAction[action],
|
||||
};
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serverQueryKey });
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,7 +49,23 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(circle at 0% 0%, hsl(var(--primary) / 0.18), transparent 34%),
|
||||
radial-gradient(circle at 88% 10%, hsl(var(--ring) / 0.12), transparent 28%),
|
||||
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.72) 100%);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
+46
-6
@@ -1,9 +1,29 @@
|
||||
const API_BASE = '/api';
|
||||
const RAW_API_BASE = (
|
||||
(import.meta.env.VITE_API_URL as string | undefined) ??
|
||||
(import.meta.env.VITE_API_BASE_URL as string | undefined) ??
|
||||
'/api'
|
||||
).trim();
|
||||
const API_BASE = (RAW_API_BASE || '/api').replace(/\/+$/, '');
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
function toRequestBody(body: unknown): BodyInit | undefined {
|
||||
if (body === undefined || body === null) return undefined;
|
||||
|
||||
if (
|
||||
body instanceof FormData ||
|
||||
body instanceof Blob ||
|
||||
body instanceof URLSearchParams ||
|
||||
body instanceof ArrayBuffer
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
@@ -36,14 +56,28 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...fetchOptions, headers });
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
credentials: fetchOptions.credentials ?? 'include',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
const shouldHandle401WithRefresh =
|
||||
res.status === 401 &&
|
||||
path !== '/auth/login' &&
|
||||
path !== '/auth/register' &&
|
||||
path !== '/auth/refresh';
|
||||
|
||||
if (shouldHandle401WithRefresh) {
|
||||
// Try refresh
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`;
|
||||
const retry = await fetch(url, { ...fetchOptions, headers });
|
||||
const retry = await fetch(url, {
|
||||
...fetchOptions,
|
||||
credentials: fetchOptions.credentials ?? 'include',
|
||||
headers,
|
||||
});
|
||||
if (!retry.ok) throw new ApiError(retry.status, await retry.json().catch(() => null));
|
||||
if (retry.status === 204) return undefined as T;
|
||||
return retry.json();
|
||||
@@ -83,13 +117,19 @@ export const api = {
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: toRequestBody(body),
|
||||
}),
|
||||
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: toRequestBody(body),
|
||||
}),
|
||||
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PATCH',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: toRequestBody(body),
|
||||
}),
|
||||
|
||||
delete: <T>(path: string) =>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Shield, Key } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export function AccountSecurityPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: (body: { currentPassword: string; newPassword: string }) =>
|
||||
api.post('/auth/change-password', body),
|
||||
onSuccess: () => {
|
||||
toast.success('Password changed successfully');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to change password. Check your current password.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
changePasswordMutation.mutate({ currentPassword, newPassword });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Account Settings</h1>
|
||||
<p className="text-muted-foreground">Manage your account security</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Your account information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Username</span>
|
||||
<span className="font-medium">{user?.username}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Email</span>
|
||||
<span className="font-medium">{user?.email}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Role</span>
|
||||
<span className="font-medium">{user?.isSuperAdmin ? 'Super Admin' : 'User'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
<div>
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>Update your account password</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">Current Password</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">New Password</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={changePasswordMutation.isPending}>
|
||||
{changePasswordMutation.isPending ? 'Changing...' : 'Change Password'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
username: string;
|
||||
userName: string;
|
||||
ipAddress: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
@@ -36,7 +36,7 @@ export function AdminAuditLogsPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline">{log.action}</Badge>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">{log.username}</span>
|
||||
<span className="font-medium">{log.userName}</span>
|
||||
{log.ipAddress && (
|
||||
<span className="text-muted-foreground"> from {log.ipAddress}</span>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Gamepad2 } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -23,16 +24,55 @@ interface Game {
|
||||
dockerImage: string;
|
||||
defaultPort: number;
|
||||
startupCommand: string;
|
||||
automationRules: unknown[];
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number };
|
||||
interface GamesResponse {
|
||||
data: Game[];
|
||||
}
|
||||
|
||||
function extractApiMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||
return maybeMessage;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatAutomationRules(value: unknown): string {
|
||||
if (!Array.isArray(value)) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
function parseAutomationRules(raw: string): { rules: unknown[]; error: string | null } {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { rules: [], error: 'Automation JSON must be an array.' };
|
||||
}
|
||||
return { rules: parsed, error: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
return { rules: [], error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminGamesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [automationOpen, setAutomationOpen] = useState(false);
|
||||
const [selectedGame, setSelectedGame] = useState<Game | null>(null);
|
||||
const [automationJson, setAutomationJson] = useState('[]');
|
||||
const [automationError, setAutomationError] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [dockerImage, setDockerImage] = useState('');
|
||||
@@ -41,7 +81,7 @@ export function AdminGamesPage() {
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-games'],
|
||||
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
|
||||
queryFn: () => api.get<GamesResponse>('/admin/games'),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
@@ -53,11 +93,70 @@ export function AdminGamesPage() {
|
||||
setSlug('');
|
||||
setDockerImage('');
|
||||
setStartupCommand('');
|
||||
toast.success('Game created');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to create game'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateAutomationMutation = useMutation({
|
||||
mutationFn: ({ gameId, rules }: { gameId: string; rules: unknown[] }) =>
|
||||
api.patch(`/admin/games/${gameId}`, { automationRules: rules }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-games'] });
|
||||
setAutomationOpen(false);
|
||||
setSelectedGame(null);
|
||||
setAutomationError(null);
|
||||
toast.success('Automation rules updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to save automation rules'));
|
||||
},
|
||||
});
|
||||
|
||||
const games = data?.data ?? [];
|
||||
|
||||
const openAutomationDialog = (game: Game) => {
|
||||
setSelectedGame(game);
|
||||
setAutomationJson(formatAutomationRules(game.automationRules));
|
||||
setAutomationError(null);
|
||||
setAutomationOpen(true);
|
||||
};
|
||||
|
||||
const saveAutomationRules = () => {
|
||||
if (!selectedGame) return;
|
||||
|
||||
const parsed = parseAutomationRules(automationJson);
|
||||
if (parsed.error) {
|
||||
setAutomationError(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setAutomationError(null);
|
||||
updateAutomationMutation.mutate({
|
||||
gameId: selectedGame.id,
|
||||
rules: parsed.rules,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutomationTabKey = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
event.preventDefault();
|
||||
const textarea = event.currentTarget;
|
||||
const selectionStart = textarea.selectionStart;
|
||||
const selectionEnd = textarea.selectionEnd;
|
||||
const nextValue = `${automationJson.slice(0, selectionStart)} ${automationJson.slice(selectionEnd)}`;
|
||||
const nextCursor = selectionStart + 2;
|
||||
|
||||
setAutomationJson(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(nextCursor, nextCursor);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -142,11 +241,65 @@ export function AdminGamesPage() {
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
|
||||
<p>Port: {game.defaultPort}</p>
|
||||
<p>Automation: {Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => openAutomationDialog(game)}
|
||||
>
|
||||
Manage Automation
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={automationOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setAutomationOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setSelectedGame(null);
|
||||
setAutomationError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Automation Rules
|
||||
{selectedGame ? ` - ${selectedGame.name}` : ''}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>JSON</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported events: server.created, server.install.completed, server.power.started, server.power.stopped
|
||||
</p>
|
||||
<textarea
|
||||
value={automationJson}
|
||||
onChange={(event) => setAutomationJson(event.target.value)}
|
||||
onKeyDown={handleAutomationTabKey}
|
||||
spellCheck={false}
|
||||
className="min-h-[320px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{automationError && <p className="text-sm text-destructive">{automationError}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveAutomationRules}
|
||||
disabled={updateAutomationMutation.isPending || !selectedGame}
|
||||
>
|
||||
{updateAutomationMutation.isPending ? 'Saving...' : 'Save Automation'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Network, Wifi, WifiOff } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface NodeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
fqdn: string;
|
||||
daemonPort: number;
|
||||
grpcPort: number;
|
||||
memoryTotal: number;
|
||||
diskTotal: number;
|
||||
isOnline: boolean;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export function AdminNodesPage() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-nodes'],
|
||||
queryFn: () => api.get<{ data: NodeItem[] }>('/admin/nodes'),
|
||||
});
|
||||
|
||||
const nodes = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">All Nodes</h1>
|
||||
<p className="text-muted-foreground">{nodes.length} nodes across all organizations</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{nodes.map((node) => (
|
||||
<Card key={node.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{node.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||
{node.isOnline ? (
|
||||
<><Wifi className="mr-1 h-3 w-3" /> Online</>
|
||||
) : (
|
||||
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
|
||||
)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{node.fqdn}:{node.daemonPort}</p>
|
||||
<div className="mt-3 flex gap-4 text-sm">
|
||||
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
||||
<span>{formatBytes(node.diskTotal)} Disk</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{nodes.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
No nodes registered
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, UploadCloud, Puzzle, Rocket, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GamesResponse {
|
||||
data: Game[];
|
||||
}
|
||||
|
||||
interface GlobalPlugin {
|
||||
id: string;
|
||||
gameId: string;
|
||||
gameName: string;
|
||||
gameSlug: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
source: 'manual' | 'spiget';
|
||||
isGlobal: boolean;
|
||||
}
|
||||
|
||||
interface GlobalPluginsResponse {
|
||||
data: GlobalPlugin[];
|
||||
}
|
||||
|
||||
interface PluginRelease {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
channel: 'stable' | 'beta' | 'alpha';
|
||||
artifactType: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination: string | null;
|
||||
fileName: string | null;
|
||||
changelog: string | null;
|
||||
installSchema: unknown[];
|
||||
configTemplates: unknown[];
|
||||
isPublished: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface PluginReleaseResponse {
|
||||
plugin: GlobalPlugin;
|
||||
releases: PluginRelease[];
|
||||
}
|
||||
|
||||
type ReleaseInputMode = 'url' | 'upload';
|
||||
|
||||
function extractApiMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||
return maybeMessage;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function prettyJson(input: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonArray(raw: string): unknown[] {
|
||||
if (raw.trim() === '') return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('JSON value must be an array');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function parseJsonArrayFile(file: File, label: string): Promise<unknown[]> {
|
||||
let raw = await file.text();
|
||||
if (raw.charCodeAt(0) === 0xfeff) {
|
||||
raw = raw.slice(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('JSON value must be an array');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
throw new Error(`${label}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminPluginsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedGameId, setSelectedGameId] = useState<string>('');
|
||||
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
|
||||
|
||||
const [createPluginOpen, setCreatePluginOpen] = useState(false);
|
||||
const [createPluginName, setCreatePluginName] = useState('');
|
||||
const [createPluginSlug, setCreatePluginSlug] = useState('');
|
||||
const [createPluginDescription, setCreatePluginDescription] = useState('');
|
||||
|
||||
const [createReleaseOpen, setCreateReleaseOpen] = useState(false);
|
||||
const [releaseInputMode, setReleaseInputMode] = useState<ReleaseInputMode>('upload');
|
||||
const [releaseVersion, setReleaseVersion] = useState('');
|
||||
const [releaseChannel, setReleaseChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
|
||||
const [releaseArtifactType, setReleaseArtifactType] = useState<'file' | 'zip'>('file');
|
||||
const [releaseArtifactUrl, setReleaseArtifactUrl] = useState('');
|
||||
const [releaseDestination, setReleaseDestination] = useState('');
|
||||
const [releaseFileName, setReleaseFileName] = useState('');
|
||||
const [releaseChangelog, setReleaseChangelog] = useState('');
|
||||
const [releaseInstallSchemaJson, setReleaseInstallSchemaJson] = useState('[]');
|
||||
const [releaseTemplatesJson, setReleaseTemplatesJson] = useState('[]');
|
||||
const [releaseInstallSchemaFile, setReleaseInstallSchemaFile] = useState<File | null>(null);
|
||||
const [releaseTemplatesFile, setReleaseTemplatesFile] = useState<File | null>(null);
|
||||
const [releaseInstallSchemaFileInputKey, setReleaseInstallSchemaFileInputKey] = useState(0);
|
||||
const [releaseTemplatesFileInputKey, setReleaseTemplatesFileInputKey] = useState(0);
|
||||
const [releaseArtifactFiles, setReleaseArtifactFiles] = useState<File[]>([]);
|
||||
|
||||
const { data: gamesData } = useQuery({
|
||||
queryKey: ['admin-games'],
|
||||
queryFn: () => api.get<GamesResponse>('/admin/games'),
|
||||
});
|
||||
|
||||
const games = gamesData?.data ?? [];
|
||||
|
||||
const { data: pluginsData } = useQuery({
|
||||
queryKey: ['admin-plugins', selectedGameId],
|
||||
queryFn: () =>
|
||||
api.get<GlobalPluginsResponse>(
|
||||
'/admin/plugins',
|
||||
selectedGameId ? { gameId: selectedGameId } : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
const plugins = pluginsData?.data ?? [];
|
||||
|
||||
const selectedPlugin = useMemo(
|
||||
() => plugins.find((plugin) => plugin.id === selectedPluginId) ?? null,
|
||||
[plugins, selectedPluginId],
|
||||
);
|
||||
|
||||
const { data: releaseData } = useQuery({
|
||||
queryKey: ['admin-plugin-releases', selectedPluginId],
|
||||
enabled: Boolean(selectedPluginId),
|
||||
queryFn: () => api.get<PluginReleaseResponse>(`/admin/plugins/${selectedPluginId}/releases`),
|
||||
});
|
||||
|
||||
const releases = releaseData?.releases ?? [];
|
||||
|
||||
const resetReleaseForm = () => {
|
||||
setCreateReleaseOpen(false);
|
||||
setReleaseInputMode('upload');
|
||||
setReleaseVersion('');
|
||||
setReleaseChannel('stable');
|
||||
setReleaseArtifactType('file');
|
||||
setReleaseArtifactUrl('');
|
||||
setReleaseDestination('');
|
||||
setReleaseFileName('');
|
||||
setReleaseChangelog('');
|
||||
setReleaseInstallSchemaJson('[]');
|
||||
setReleaseTemplatesJson('[]');
|
||||
setReleaseInstallSchemaFile(null);
|
||||
setReleaseTemplatesFile(null);
|
||||
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||
setReleaseArtifactFiles([]);
|
||||
};
|
||||
|
||||
const appendReleaseFiles = (incoming: FileList | null) => {
|
||||
if (!incoming || incoming.length === 0) return;
|
||||
|
||||
setReleaseArtifactFiles((prev) => {
|
||||
const map = new Map<string, File>();
|
||||
|
||||
for (const item of prev) {
|
||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||
}
|
||||
|
||||
for (const item of Array.from(incoming)) {
|
||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
};
|
||||
|
||||
const createPluginMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
gameId: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
}) => api.post('/admin/plugins', body),
|
||||
onSuccess: () => {
|
||||
toast.success('Global plugin created');
|
||||
setCreatePluginOpen(false);
|
||||
setCreatePluginName('');
|
||||
setCreatePluginSlug('');
|
||||
setCreatePluginDescription('');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to create plugin'));
|
||||
},
|
||||
});
|
||||
|
||||
const createReleaseMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
version: string;
|
||||
channel: 'stable' | 'beta' | 'alpha';
|
||||
artifactType: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination?: string;
|
||||
fileName?: string;
|
||||
changelog?: string;
|
||||
installSchema?: unknown[];
|
||||
configTemplates?: unknown[];
|
||||
}) => {
|
||||
if (!selectedPluginId) {
|
||||
throw new Error('No plugin selected');
|
||||
}
|
||||
return api.post(`/admin/plugins/${selectedPluginId}/releases`, body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Release published');
|
||||
resetReleaseForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to publish release'));
|
||||
},
|
||||
});
|
||||
|
||||
const createUploadReleaseMutation = useMutation({
|
||||
mutationFn: (formData: FormData) => {
|
||||
if (!selectedPluginId) {
|
||||
throw new Error('No plugin selected');
|
||||
}
|
||||
return api.post(`/admin/plugins/${selectedPluginId}/releases/upload`, formData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Release uploaded and published');
|
||||
resetReleaseForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to upload release'));
|
||||
},
|
||||
});
|
||||
|
||||
const togglePublishedMutation = useMutation({
|
||||
mutationFn: ({ releaseId, isPublished }: { releaseId: string; isPublished: boolean }) => {
|
||||
if (!selectedPluginId) {
|
||||
throw new Error('No plugin selected');
|
||||
}
|
||||
return api.patch(`/admin/plugins/${selectedPluginId}/releases/${releaseId}`, { isPublished });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to update release'));
|
||||
},
|
||||
});
|
||||
|
||||
const openReleaseDialogFrom = (release?: PluginRelease) => {
|
||||
setReleaseVersion('');
|
||||
setReleaseChannel('stable');
|
||||
setReleaseArtifactType('file');
|
||||
setReleaseArtifactUrl('');
|
||||
setReleaseDestination('');
|
||||
setReleaseFileName('');
|
||||
setReleaseChangelog('');
|
||||
setReleaseInstallSchemaJson('[]');
|
||||
setReleaseTemplatesJson('[]');
|
||||
setReleaseInstallSchemaFile(null);
|
||||
setReleaseTemplatesFile(null);
|
||||
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||
setReleaseArtifactFiles([]);
|
||||
setReleaseInputMode(release ? 'url' : 'upload');
|
||||
|
||||
if (release) {
|
||||
setReleaseChannel(release.channel);
|
||||
setReleaseArtifactType(release.artifactType);
|
||||
setReleaseArtifactUrl(release.artifactUrl);
|
||||
setReleaseDestination(release.destination ?? '');
|
||||
setReleaseFileName(release.fileName ?? '');
|
||||
setReleaseChangelog(release.changelog ?? '');
|
||||
setReleaseInstallSchemaJson(prettyJson(release.installSchema));
|
||||
setReleaseTemplatesJson(prettyJson(release.configTemplates));
|
||||
}
|
||||
setCreateReleaseOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Global Plugins</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Oyun bazında global plugin tanımla, release yayınla, install ayar şemasını yönet.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createPluginOpen} onOpenChange={setCreatePluginOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" /> Add Global Plugin
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Global Plugin</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedGameId) {
|
||||
toast.error('Select a game first');
|
||||
return;
|
||||
}
|
||||
createPluginMutation.mutate({
|
||||
gameId: selectedGameId,
|
||||
name: createPluginName,
|
||||
slug: createPluginSlug || undefined,
|
||||
description: createPluginDescription || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Game</Label>
|
||||
<Input
|
||||
value={games.find((game) => game.id === selectedGameId)?.name ?? ''}
|
||||
readOnly
|
||||
placeholder="Select game from filter above"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={createPluginName} onChange={(e) => setCreatePluginName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug (optional)</Label>
|
||||
<Input value={createPluginSlug} onChange={(e) => setCreatePluginSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input value={createPluginDescription} onChange={(e) => setCreatePluginDescription(e.target.value)} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createPluginMutation.isPending}>
|
||||
{createPluginMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label className="min-w-20">Game Filter</Label>
|
||||
<select
|
||||
className="h-10 rounded-md border bg-background px-3 text-sm"
|
||||
value={selectedGameId}
|
||||
onChange={(e) => {
|
||||
setSelectedGameId(e.target.value);
|
||||
setSelectedPluginId(null);
|
||||
}}
|
||||
>
|
||||
<option value="">All Games</option>
|
||||
{games.map((game) => (
|
||||
<option key={game.id} value={game.id}>
|
||||
{game.name} ({game.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plugins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{plugins.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No plugins found for this filter.</p>
|
||||
)}
|
||||
{plugins.map((plugin) => (
|
||||
<button
|
||||
key={plugin.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPluginId(plugin.id)}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left transition ${
|
||||
selectedPluginId === plugin.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Puzzle className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">{plugin.name}</span>
|
||||
<Badge variant="outline">{plugin.gameSlug}</Badge>
|
||||
<Badge variant="secondary">{plugin.source}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{plugin.slug}</p>
|
||||
{plugin.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Releases</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openReleaseDialogFrom(releases[0])}
|
||||
disabled={!selectedPlugin}
|
||||
>
|
||||
<Copy className="h-4 w-4" /> Clone Latest
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openReleaseDialogFrom()}
|
||||
disabled={!selectedPlugin}
|
||||
>
|
||||
<UploadCloud className="h-4 w-4" /> New Release
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{!selectedPlugin && (
|
||||
<p className="text-sm text-muted-foreground">Select a plugin to manage releases.</p>
|
||||
)}
|
||||
{selectedPlugin && releases.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No releases published yet.</p>
|
||||
)}
|
||||
{releases.map((release) => (
|
||||
<div key={release.id} className="rounded-md border px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">v{release.version}</span>
|
||||
<Badge variant="outline">{release.channel}</Badge>
|
||||
<Badge variant="secondary">{release.artifactType}</Badge>
|
||||
{!release.isPublished && <Badge variant="destructive">Unpublished</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{release.artifactUrl}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0} fields • Templates:{' '}
|
||||
{Array.isArray(release.configTemplates) ? release.configTemplates.length : 0}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
togglePublishedMutation.mutate({
|
||||
releaseId: release.id,
|
||||
isPublished: !release.isPublished,
|
||||
})
|
||||
}
|
||||
disabled={togglePublishedMutation.isPending}
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
{release.isPublished ? 'Unpublish' : 'Publish'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={createReleaseOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setCreateReleaseOpen(true);
|
||||
return;
|
||||
}
|
||||
resetReleaseForm();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const installSchema = releaseInstallSchemaFile
|
||||
? await parseJsonArrayFile(releaseInstallSchemaFile, 'Install schema file')
|
||||
: parseJsonArray(releaseInstallSchemaJson);
|
||||
const configTemplates = releaseTemplatesFile
|
||||
? await parseJsonArrayFile(releaseTemplatesFile, 'Config templates file')
|
||||
: parseJsonArray(releaseTemplatesJson);
|
||||
|
||||
if (releaseInputMode === 'upload') {
|
||||
if (releaseArtifactFiles.length === 0) {
|
||||
toast.error('Select at least one file or folder');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('version', releaseVersion);
|
||||
formData.append('channel', releaseChannel);
|
||||
if (releaseDestination.trim()) formData.append('destination', releaseDestination.trim());
|
||||
if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim());
|
||||
if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog);
|
||||
if (releaseInstallSchemaFile) {
|
||||
formData.append(
|
||||
'installSchemaFile',
|
||||
releaseInstallSchemaFile,
|
||||
releaseInstallSchemaFile.name,
|
||||
);
|
||||
} else {
|
||||
formData.append('installSchema', JSON.stringify(installSchema));
|
||||
}
|
||||
if (releaseTemplatesFile) {
|
||||
formData.append(
|
||||
'configTemplatesFile',
|
||||
releaseTemplatesFile,
|
||||
releaseTemplatesFile.name,
|
||||
);
|
||||
} else {
|
||||
formData.append('configTemplates', JSON.stringify(configTemplates));
|
||||
}
|
||||
|
||||
for (const file of releaseArtifactFiles) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
formData.append('relativePath', relativePath && relativePath.length > 0 ? relativePath : file.name);
|
||||
formData.append('files', file, file.name);
|
||||
}
|
||||
|
||||
createUploadReleaseMutation.mutate(formData);
|
||||
return;
|
||||
}
|
||||
|
||||
createReleaseMutation.mutate({
|
||||
version: releaseVersion,
|
||||
channel: releaseChannel,
|
||||
artifactType: releaseArtifactType,
|
||||
artifactUrl: releaseArtifactUrl,
|
||||
destination: releaseDestination || undefined,
|
||||
fileName: releaseFileName || undefined,
|
||||
changelog: releaseChangelog || undefined,
|
||||
installSchema,
|
||||
configTemplates,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
toast.error(`Release JSON error: ${message}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Version</Label>
|
||||
<Input value={releaseVersion} onChange={(e) => setReleaseVersion(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Channel</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
value={releaseChannel}
|
||||
onChange={(e) => setReleaseChannel(e.target.value as 'stable' | 'beta' | 'alpha')}
|
||||
>
|
||||
<option value="stable">stable</option>
|
||||
<option value="beta">beta</option>
|
||||
<option value="alpha">alpha</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Release Source</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={releaseInputMode === 'upload' ? 'default' : 'outline'}
|
||||
onClick={() => setReleaseInputMode('upload')}
|
||||
>
|
||||
CDN Upload
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={releaseInputMode === 'url' ? 'default' : 'outline'}
|
||||
onClick={() => setReleaseInputMode('url')}
|
||||
>
|
||||
URL
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{releaseInputMode === 'url' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Artifact Type</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
value={releaseArtifactType}
|
||||
onChange={(e) => setReleaseArtifactType(e.target.value as 'file' | 'zip')}
|
||||
>
|
||||
<option value="file">file</option>
|
||||
<option value="zip">zip</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Artifact URL</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={releaseArtifactUrl}
|
||||
onChange={(e) => setReleaseArtifactUrl(e.target.value)}
|
||||
required={releaseInputMode === 'url'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{releaseInputMode === 'upload' && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen otomatik zip
|
||||
yapilip CDN'e yuklenir.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Files</Label>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="block w-full text-sm"
|
||||
onChange={(e) => appendReleaseFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Folder</Label>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
{...({ webkitdirectory: '', directory: '' } as Record<string, string>)}
|
||||
className="block w-full text-sm"
|
||||
onChange={(e) => appendReleaseFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Selected: {releaseArtifactFiles.length} file(s)</p>
|
||||
{releaseArtifactFiles.length > 0 && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setReleaseArtifactFiles([])}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{releaseArtifactFiles.length > 0 && (
|
||||
<div className="max-h-28 space-y-1 overflow-auto rounded bg-muted/40 p-2 text-xs">
|
||||
{releaseArtifactFiles.map((file, index) => {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
return (
|
||||
<p key={`${relativePath || file.name}-${index}`} className="truncate">
|
||||
{relativePath || file.name}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Destination (optional)</Label>
|
||||
<Input
|
||||
value={releaseDestination}
|
||||
onChange={(e) => setReleaseDestination(e.target.value)}
|
||||
placeholder="/game/csgo/addons"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>File Name (optional)</Label>
|
||||
<Input
|
||||
value={releaseFileName}
|
||||
onChange={(e) => setReleaseFileName(e.target.value)}
|
||||
placeholder="plugin.dll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Changelog (optional)</Label>
|
||||
<textarea
|
||||
value={releaseChangelog}
|
||||
onChange={(e) => setReleaseChangelog(e.target.value)}
|
||||
className="min-h-[90px] w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Install Schema JSON (array)</Label>
|
||||
<input
|
||||
key={releaseInstallSchemaFileInputKey}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="block w-full text-xs"
|
||||
onChange={(e) => setReleaseInstallSchemaFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{releaseInstallSchemaFile && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>File secili: {releaseInstallSchemaFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReleaseInstallSchemaFile(null);
|
||||
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={releaseInstallSchemaJson}
|
||||
onChange={(e) => setReleaseInstallSchemaJson(e.target.value)}
|
||||
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Config Templates JSON (array)</Label>
|
||||
<input
|
||||
key={releaseTemplatesFileInputKey}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="block w-full text-xs"
|
||||
onChange={(e) => setReleaseTemplatesFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{releaseTemplatesFile && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>File secili: {releaseTemplatesFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReleaseTemplatesFile(null);
|
||||
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={releaseTemplatesJson}
|
||||
onChange={(e) => setReleaseTemplatesJson(e.target.value)}
|
||||
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
createReleaseMutation.isPending ||
|
||||
createUploadReleaseMutation.isPending ||
|
||||
!selectedPlugin
|
||||
}
|
||||
>
|
||||
{(createReleaseMutation.isPending || createUploadReleaseMutation.isPending)
|
||||
? 'Publishing...'
|
||||
: 'Publish Release'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-transparent p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
|
||||
|
||||
@@ -36,7 +36,7 @@ export function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-transparent p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
|
||||
|
||||
@@ -37,7 +37,7 @@ export function DashboardPage() {
|
||||
|
||||
const servers = serversData?.data ?? [];
|
||||
const running = servers.filter((s) => s.status === 'running').length;
|
||||
const totalNodes = nodesData?.meta.total ?? 0;
|
||||
const totalNodes = nodesData?.meta?.total ?? nodesData?.data?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Network,
|
||||
@@ -9,14 +10,26 @@ import {
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Server,
|
||||
Activity,
|
||||
Plus,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface NodeDetail {
|
||||
id: string;
|
||||
@@ -51,8 +64,20 @@ interface ServerSummary {
|
||||
gameName: string;
|
||||
}
|
||||
|
||||
interface Allocation {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
serverId: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function NodeDetailPage() {
|
||||
const { orgId, nodeId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [allocOpen, setAllocOpen] = useState(false);
|
||||
const [allocIp, setAllocIp] = useState('0.0.0.0');
|
||||
const [allocPorts, setAllocPorts] = useState('');
|
||||
|
||||
const { data: node } = useQuery({
|
||||
queryKey: ['node', orgId, nodeId],
|
||||
@@ -73,6 +98,40 @@ export function NodeDetailPage() {
|
||||
),
|
||||
});
|
||||
|
||||
const { data: allocData } = useQuery({
|
||||
queryKey: ['allocations', orgId, nodeId],
|
||||
queryFn: () =>
|
||||
api.get<{ data: Allocation[] }>(
|
||||
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
|
||||
),
|
||||
});
|
||||
|
||||
const allocations = allocData?.data ?? [];
|
||||
|
||||
const createAllocMutation = useMutation({
|
||||
mutationFn: (body: { ip: string; ports: number[] }) =>
|
||||
api.post(`/organizations/${orgId}/nodes/${nodeId}/allocations`, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['allocations', orgId, nodeId] });
|
||||
setAllocOpen(false);
|
||||
setAllocPorts('');
|
||||
toast.success('Allocations created');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to create allocations');
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddAllocations = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const ports = parsePorts(allocPorts);
|
||||
if (ports.length === 0) {
|
||||
toast.error('Enter valid ports (e.g. 25565, 25566-25570)');
|
||||
return;
|
||||
}
|
||||
createAllocMutation.mutate({ ip: allocIp, ports });
|
||||
};
|
||||
|
||||
const servers = serversData?.data ?? [];
|
||||
|
||||
if (!node) {
|
||||
@@ -83,10 +142,10 @@ export function NodeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const memPercent = stats
|
||||
const memPercent = stats && stats.memoryTotal > 0
|
||||
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
|
||||
: 0;
|
||||
const diskPercent = stats
|
||||
const diskPercent = stats && stats.diskTotal > 0
|
||||
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
|
||||
: 0;
|
||||
|
||||
@@ -234,10 +293,102 @@ export function NodeDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Allocations */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
<CardTitle>Allocations</CardTitle>
|
||||
</div>
|
||||
<Dialog open={allocOpen} onOpenChange={setAllocOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4" /> Add Ports
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Allocations</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleAddAllocations} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>IP Address</Label>
|
||||
<Input
|
||||
value={allocIp}
|
||||
onChange={(e) => setAllocIp(e.target.value)}
|
||||
placeholder="0.0.0.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Ports</Label>
|
||||
<Input
|
||||
value={allocPorts}
|
||||
onChange={(e) => setAllocPorts(e.target.value)}
|
||||
placeholder="25565, 25566-25570"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated ports or ranges (e.g. 25565, 25566-25570)
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createAllocMutation.isPending}>
|
||||
{createAllocMutation.isPending ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{allocations.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No allocations yet. Add ports to assign to servers.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{allocations.map((alloc) => (
|
||||
<div
|
||||
key={alloc.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="font-mono text-sm">
|
||||
{alloc.ip}:{alloc.port}
|
||||
</div>
|
||||
<Badge variant={alloc.serverId ? 'default' : 'outline'}>
|
||||
{alloc.serverId ? 'In use' : 'Available'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse port input like "25565, 25566-25570, 27015" into flat number array */
|
||||
function parsePorts(input: string): number[] {
|
||||
const ports: number[] = [];
|
||||
const parts = input.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (part.includes('-')) {
|
||||
const [startStr, endStr] = part.split('-');
|
||||
const start = parseInt(startStr!, 10);
|
||||
const end = parseInt(endStr!, 10);
|
||||
if (isNaN(start) || isNaN(end) || start > end || start < 1 || end > 65535) continue;
|
||||
for (let p = start; p <= end; p++) ports.push(p);
|
||||
} else {
|
||||
const p = parseInt(part, 10);
|
||||
if (!isNaN(p) && p >= 1 && p <= 65535) ports.push(p);
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Network, Wifi, WifiOff } from 'lucide-react';
|
||||
import { Plus, Network, Wifi, WifiOff, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface NodeItem {
|
||||
@@ -29,6 +31,10 @@ interface NodeItem {
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
interface CreatedNode extends NodeItem {
|
||||
daemonToken: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number };
|
||||
@@ -38,6 +44,9 @@ export function NodesPage() {
|
||||
const { orgId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tokenDialog, setTokenDialog] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [fqdn, setFqdn] = useState('');
|
||||
const [daemonPort, setDaemonPort] = useState(8443);
|
||||
@@ -52,17 +61,28 @@ export function NodesPage() {
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post(`/organizations/${orgId}/nodes`, body),
|
||||
onSuccess: () => {
|
||||
api.post<CreatedNode>(`/organizations/${orgId}/nodes`, body),
|
||||
onSuccess: (node) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nodes', orgId] });
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setFqdn('');
|
||||
// Show token dialog
|
||||
setCreatedToken(node.daemonToken);
|
||||
setTokenDialog(true);
|
||||
setCopied(false);
|
||||
},
|
||||
});
|
||||
|
||||
const nodes = data?.data ?? [];
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
await navigator.clipboard.writeText(createdToken);
|
||||
setCopied(true);
|
||||
toast.success('Token copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -151,6 +171,45 @@ export function NodesPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Token display dialog */}
|
||||
<Dialog open={tokenDialog} onOpenChange={setTokenDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Node Created Successfully</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save this daemon token now. It will not be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Label>Daemon Token</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={createdToken}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyToken}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use this token in your daemon configuration file (config.yml) to authenticate with the panel.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setTokenDialog(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{nodes.map((node) => (
|
||||
<Link key={node.id} to={`/org/${orgId}/nodes/${node.id}`}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Settings2, FileText, Save } from 'lucide-react';
|
||||
@@ -30,6 +30,24 @@ interface ConfigDetail {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
function mergeConfigEntries(
|
||||
entries: ConfigEntry[],
|
||||
editableKeys: string[] | null,
|
||||
): ConfigEntry[] {
|
||||
if (!editableKeys || editableKeys.length === 0) return entries;
|
||||
|
||||
const existing = new Map(entries.map((entry) => [entry.key, entry]));
|
||||
const merged = [...entries];
|
||||
|
||||
for (const key of editableKeys) {
|
||||
if (!existing.has(key)) {
|
||||
merged.push({ key, value: '' });
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function ConfigPage() {
|
||||
const { orgId, serverId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -102,17 +120,15 @@ function ConfigEditor({
|
||||
});
|
||||
|
||||
const [entries, setEntries] = useState<ConfigEntry[]>([]);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Initialize entries from server data
|
||||
if (detail && !initialized) {
|
||||
setEntries(detail.entries);
|
||||
setInitialized(true);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!detail) return;
|
||||
setEntries(mergeConfigEntries(detail.entries, configFile.editableKeys));
|
||||
}, [detail, configFile.editableKeys]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: { entries: ConfigEntry[] }) =>
|
||||
api.patch(
|
||||
api.put(
|
||||
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
|
||||
data,
|
||||
),
|
||||
@@ -129,10 +145,6 @@ function ConfigEditor({
|
||||
);
|
||||
};
|
||||
|
||||
const displayEntries = configFile.editableKeys
|
||||
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
|
||||
: entries;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
@@ -143,8 +155,8 @@ function ConfigEditor({
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{configFile.editableKeys
|
||||
? `${configFile.editableKeys.length} editable keys`
|
||||
: 'All keys editable'}
|
||||
? `${configFile.editableKeys.length} allowed additions, plus existing keys`
|
||||
: 'All detected keys editable'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -157,13 +169,13 @@ function ConfigEditor({
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{displayEntries.length === 0 ? (
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{detail ? 'No entries found. The server may need to be started first to generate config files.' : 'Loading...'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{displayEntries.map((entry) => (
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.key} className="grid gap-1.5">
|
||||
<Label className="font-mono text-xs text-muted-foreground">
|
||||
{entry.key}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useOutletContext, useParams } from 'react-router';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
@@ -10,17 +10,50 @@ import { Button } from '@/components/ui/button';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
interface ConsoleOutletContext {
|
||||
server?: {
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ConsolePage() {
|
||||
const { orgId, serverId } = useParams();
|
||||
const { server } = useOutletContext<ConsoleOutletContext>();
|
||||
const termRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const serverStatusRef = useRef<string | null>(server?.status ?? null);
|
||||
const rejoinTimeoutRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
const [command, setCommand] = useState('');
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!termRef.current) return;
|
||||
serverStatusRef.current = server?.status ?? null;
|
||||
}, [server?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!termRef.current || !serverId) return;
|
||||
|
||||
const joinConsole = () => {
|
||||
connectSocket();
|
||||
const socket = getSocket();
|
||||
socket.emit('server:console:join', { serverId });
|
||||
};
|
||||
|
||||
const scheduleRejoin = (delayMs = 1_000) => {
|
||||
const status = serverStatusRef.current;
|
||||
if (status !== 'starting' && status !== 'running') return;
|
||||
|
||||
if (rejoinTimeoutRef.current) {
|
||||
window.clearTimeout(rejoinTimeoutRef.current);
|
||||
}
|
||||
|
||||
rejoinTimeoutRef.current = window.setTimeout(() => {
|
||||
rejoinTimeoutRef.current = null;
|
||||
joinConsole();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
@@ -48,33 +81,72 @@ export function ConsolePage() {
|
||||
|
||||
terminal.writeln('\x1b[90m--- Console connected ---\x1b[0m');
|
||||
|
||||
// Socket.IO connection
|
||||
connectSocket();
|
||||
const socket = getSocket();
|
||||
|
||||
socket.emit('server:console:join', { serverId });
|
||||
const handleConnect = () => {
|
||||
joinConsole();
|
||||
};
|
||||
|
||||
const handleOutput = (data: { line: string }) => {
|
||||
terminal.writeln(data.line);
|
||||
if (data.line === '[console] Stream ended') {
|
||||
scheduleRejoin();
|
||||
}
|
||||
};
|
||||
const handleCommandAck = (data: { ok: boolean; error?: string }) => {
|
||||
if (!data.ok && data.error) {
|
||||
terminal.writeln(`[error] ${data.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('server:console:output', handleOutput);
|
||||
socket.on('server:console:command:ack', handleCommandAck);
|
||||
|
||||
const handleResize = () => fitAddon.fit();
|
||||
window.addEventListener('resize', handleResize);
|
||||
joinConsole();
|
||||
|
||||
return () => {
|
||||
if (rejoinTimeoutRef.current) {
|
||||
window.clearTimeout(rejoinTimeoutRef.current);
|
||||
rejoinTimeoutRef.current = null;
|
||||
}
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('server:console:output', handleOutput);
|
||||
socket.off('server:console:command:ack', handleCommandAck);
|
||||
socket.emit('server:console:leave', { serverId });
|
||||
window.removeEventListener('resize', handleResize);
|
||||
terminal.dispose();
|
||||
};
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
|
||||
const status = server?.status;
|
||||
if (status !== 'starting' && status !== 'running') {
|
||||
if (rejoinTimeoutRef.current) {
|
||||
window.clearTimeout(rejoinTimeoutRef.current);
|
||||
rejoinTimeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
connectSocket();
|
||||
const socket = getSocket();
|
||||
socket.emit('server:console:join', { serverId });
|
||||
}, [server?.status, serverId]);
|
||||
|
||||
const sendCommand = () => {
|
||||
if (!command.trim()) return;
|
||||
const socket = getSocket();
|
||||
socket.emit('server:console:command', { serverId, orgId, command: command.trim() });
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
socket.emit('server:console:command', {
|
||||
serverId,
|
||||
orgId,
|
||||
command: command.trim(),
|
||||
requestId,
|
||||
});
|
||||
setHistory((prev) => [...prev, command.trim()]);
|
||||
setHistoryIndex(-1);
|
||||
setCommand('');
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Database, ExternalLink, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ApiError, api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface ManagedDatabase {
|
||||
id: string;
|
||||
name: string;
|
||||
databaseName: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
phpMyAdminUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function extractApiMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||
return maybeMessage;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabasesPage() {
|
||||
const { orgId, serverId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createPassword, setCreatePassword] = useState('');
|
||||
const [editingDatabase, setEditingDatabase] = useState<ManagedDatabase | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editPassword, setEditPassword] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['server-databases', orgId, serverId],
|
||||
queryFn: () =>
|
||||
api.get<{ data: ManagedDatabase[] }>(
|
||||
`/organizations/${orgId}/servers/${serverId}/databases`,
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingDatabase) return;
|
||||
setEditName(editingDatabase.name);
|
||||
setEditPassword('');
|
||||
}, [editingDatabase]);
|
||||
|
||||
const databases = data?.data ?? [];
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setCreateName('');
|
||||
setCreatePassword('');
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: { name: string; password?: string }) =>
|
||||
api.post<ManagedDatabase>(`/organizations/${orgId}/servers/${serverId}/databases`, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
|
||||
setCreateOpen(false);
|
||||
resetCreateForm();
|
||||
toast.success('Database created');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to create database'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: { name?: string; password?: string }) =>
|
||||
api.patch<ManagedDatabase>(
|
||||
`/organizations/${orgId}/servers/${serverId}/databases/${editingDatabase!.id}`,
|
||||
body,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
|
||||
setEditingDatabase(null);
|
||||
setEditPassword('');
|
||||
toast.success('Database updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to update database'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (databaseId: string) =>
|
||||
api.delete(`/organizations/${orgId}/servers/${serverId}/databases/${databaseId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
|
||||
toast.success('Database deleted');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to delete database'));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Databases</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unlimited MySQL databases for this server, with password rotation and phpMyAdmin links.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) resetCreateForm();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" /> Create Database
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create MySQL Database</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createMutation.mutate({
|
||||
name: createName,
|
||||
password: createPassword.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
placeholder="LuckPerms"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Password (Optional)</Label>
|
||||
<Input
|
||||
value={createPassword}
|
||||
onChange={(event) => setCreatePassword(event.target.value)}
|
||||
minLength={8}
|
||||
placeholder="Leave empty to auto-generate"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If left empty, the panel generates a strong password automatically.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(editingDatabase)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingDatabase(null);
|
||||
setEditPassword('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Database</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
updateMutation.mutate({
|
||||
name: editName !== editingDatabase?.name ? editName : undefined,
|
||||
password: editPassword.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(event) => setEditName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>New Password (Optional)</Label>
|
||||
<Input
|
||||
value={editPassword}
|
||||
onChange={(event) => setEditPassword(event.target.value)}
|
||||
minLength={8}
|
||||
placeholder="Leave empty to keep the current password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Entering a value rotates the MySQL user password immediately.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : databases.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
No databases yet. Create one for plugins, web panels, or server-side data.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{databases.map((database) => (
|
||||
<Card key={database.id}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{database.name}</CardTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created {new Date(database.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{database.phpMyAdminUrl ? (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={database.phpMyAdminUrl} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="h-4 w-4" /> phpMyAdmin
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditingDatabase(database)}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
const confirmed = window.confirm(
|
||||
`Delete "${database.name}" and permanently drop ${database.databaseName}?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
deleteMutation.mutate(database.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<InfoRow label="Host" value={database.host} />
|
||||
<InfoRow label="Port" value={String(database.port)} />
|
||||
<InfoRow label="Database" value={database.databaseName} />
|
||||
<InfoRow label="Username" value={database.username} />
|
||||
</div>
|
||||
<InfoRow label="Password" value={database.password} />
|
||||
<InfoRow
|
||||
label="Connection URI"
|
||||
value={`mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}`}
|
||||
/>
|
||||
{!database.phpMyAdminUrl ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
phpMyAdmin link is not configured. Set `managed_mysql.phpmyadmin_url` in the daemon config for this node.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Folder,
|
||||
FileText,
|
||||
ArrowUp,
|
||||
Trash2,
|
||||
Plus,
|
||||
Download,
|
||||
Upload,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { ApiError, api } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface FileEntry {
|
||||
@@ -34,23 +36,115 @@ interface FileEntry {
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
interface FileReadResponse {
|
||||
data: string;
|
||||
encoding: 'utf8' | 'base64';
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface EditingFile {
|
||||
path: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
}
|
||||
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
targetPath: string;
|
||||
}
|
||||
|
||||
function extractApiMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError) {
|
||||
const payload = error.data as { message?: string } | null;
|
||||
if (payload?.message) return payload.message;
|
||||
}
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function joinRemotePath(basePath: string, relativePath: string): string {
|
||||
const safeSegments = relativePath
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||
|
||||
const baseSegments = basePath
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
|
||||
return `/${[...baseSegments, ...safeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('Failed to encode file'));
|
||||
return;
|
||||
}
|
||||
|
||||
const commaIndex = reader.result.indexOf(',');
|
||||
resolve(commaIndex >= 0 ? reader.result.slice(commaIndex + 1) : reader.result);
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(reader.error ?? new Error(`Failed to read file: ${file.name}`));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
|
||||
function isLikelyBinaryText(content: string): boolean {
|
||||
const sample = content.slice(0, 4096);
|
||||
return sample.includes('\u0000');
|
||||
}
|
||||
|
||||
export function FilesPage() {
|
||||
const { orgId, serverId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [editingFile, setEditingFile] = useState<{ path: string; content: string } | null>(null);
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||
const [showNewFile, setShowNewFile] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<FileEntry | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const hasUnsavedChanges =
|
||||
!!editingFile && editingFile.content !== editingFile.originalContent;
|
||||
const isUploading = !!uploadProgress;
|
||||
|
||||
const filesQuery = useQuery({
|
||||
queryKey: ['files', orgId, serverId, currentPath],
|
||||
enabled: Boolean(orgId && serverId) && !editingFile,
|
||||
queryFn: () =>
|
||||
api.get<{ files: FileEntry[] }>(
|
||||
`/organizations/${orgId}/servers/${serverId}/files`,
|
||||
{ path: currentPath },
|
||||
),
|
||||
enabled: !editingFile,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -59,38 +153,132 @@ export function FilesPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||
setDeleteTarget(null);
|
||||
toast.success('Deleted successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Delete failed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({ path, data }: { path: string; data: string }) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }),
|
||||
onSuccess: () => {
|
||||
setEditingFile(null);
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
|
||||
path,
|
||||
data,
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
onSuccess: (_result, variables) => {
|
||||
setEditingFile((prev) =>
|
||||
prev && prev.path === variables.path
|
||||
? {
|
||||
...prev,
|
||||
content: variables.data,
|
||||
originalContent: variables.data,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
toast.success('File saved');
|
||||
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Save failed'));
|
||||
},
|
||||
});
|
||||
|
||||
const createFileMutation = useMutation({
|
||||
mutationFn: ({ path, data }: { path: string; data: string }) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }),
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
|
||||
path,
|
||||
data,
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setShowNewFile(false);
|
||||
setNewFileName('');
|
||||
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||
toast.success('File created');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to create file'));
|
||||
},
|
||||
});
|
||||
|
||||
const createFolderMutation = useMutation({
|
||||
mutationFn: async (folderPath: string) => {
|
||||
const markerName = `.gp_create_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const markerPath = `${folderPath.replace(/\/+$/g, '')}/${markerName}`;
|
||||
|
||||
await api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
|
||||
path: markerPath,
|
||||
data: '',
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
await api.post(`/organizations/${orgId}/servers/${serverId}/files/delete`, {
|
||||
paths: [markerPath],
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setShowNewFolder(false);
|
||||
setNewFolderName('');
|
||||
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||
toast.success('Folder created');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to create folder'));
|
||||
},
|
||||
});
|
||||
|
||||
const files = filesQuery.data?.files ?? [];
|
||||
const breadcrumbs = currentPath.split('/').filter(Boolean);
|
||||
|
||||
const openFile = async (file: FileEntry) => {
|
||||
if (!orgId || !serverId) return;
|
||||
|
||||
if (file.isDirectory) {
|
||||
setCurrentPath(file.path);
|
||||
return;
|
||||
}
|
||||
const res = await api.get<{ data: string }>(
|
||||
|
||||
try {
|
||||
const res = await api.get<FileReadResponse>(
|
||||
`/organizations/${orgId}/servers/${serverId}/files/read`,
|
||||
{ path: file.path },
|
||||
{ path: file.path, encoding: 'utf8' },
|
||||
);
|
||||
setEditingFile({ path: file.path, content: res.data });
|
||||
|
||||
if (isLikelyBinaryText(res.data)) {
|
||||
toast.error('This file looks binary. Use download instead of editor.');
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingFile({
|
||||
path: file.path,
|
||||
content: res.data,
|
||||
originalContent: res.data,
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(extractApiMessage(error, 'Failed to open file'));
|
||||
}
|
||||
};
|
||||
|
||||
const saveCurrentFile = () => {
|
||||
if (!editingFile) return;
|
||||
saveMutation.mutate({ path: editingFile.path, data: editingFile.content });
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
const shouldClose = window.confirm(
|
||||
'You have unsaved changes. Close the editor and discard them?',
|
||||
);
|
||||
if (!shouldClose) return;
|
||||
}
|
||||
|
||||
setEditingFile(null);
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
@@ -100,28 +288,258 @@ export function FilesPage() {
|
||||
setCurrentPath('/' + parts.join('/'));
|
||||
};
|
||||
|
||||
const breadcrumbs = currentPath.split('/').filter(Boolean);
|
||||
const applyEditorContent = (
|
||||
nextValue: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
textarea: HTMLTextAreaElement,
|
||||
) => {
|
||||
setEditingFile((prev) => (prev ? { ...prev, content: nextValue } : prev));
|
||||
|
||||
const files = filesQuery.data?.files ?? [];
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(selectionStart, selectionEnd);
|
||||
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(textarea).lineHeight) || 20;
|
||||
const lineBeforeCursor = textarea.value.slice(0, selectionEnd).split('\n').length - 1;
|
||||
const caretTop = lineBeforeCursor * lineHeight;
|
||||
const viewportTop = textarea.scrollTop;
|
||||
const viewportBottom = viewportTop + textarea.clientHeight;
|
||||
|
||||
if (caretTop < viewportTop + lineHeight) {
|
||||
textarea.scrollTop = Math.max(0, caretTop - lineHeight);
|
||||
} else if (caretTop > viewportBottom - lineHeight * 2) {
|
||||
textarea.scrollTop = Math.max(0, caretTop - textarea.clientHeight + lineHeight * 2);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!editingFile) return;
|
||||
|
||||
const textarea = event.currentTarget;
|
||||
const value = editingFile.content;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
if (hasUnsavedChanges && !saveMutation.isPending) {
|
||||
saveCurrentFile();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
|
||||
if (start !== end) {
|
||||
const blockStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const rawBlockEnd = value.indexOf('\n', end);
|
||||
const blockEnd = rawBlockEnd === -1 ? value.length : rawBlockEnd;
|
||||
const block = value.slice(blockStart, blockEnd);
|
||||
const lines = block.split('\n');
|
||||
|
||||
if (event.shiftKey) {
|
||||
let firstLineRemoved = 0;
|
||||
let totalRemoved = 0;
|
||||
|
||||
const outdented = lines.map((line, index) => {
|
||||
let remove = 0;
|
||||
if (line.startsWith('\t')) remove = 1;
|
||||
else if (line.startsWith(' ')) remove = 2;
|
||||
else if (line.startsWith(' ')) remove = 1;
|
||||
|
||||
if (index === 0) {
|
||||
firstLineRemoved = remove;
|
||||
}
|
||||
totalRemoved += remove;
|
||||
return line.slice(remove);
|
||||
});
|
||||
|
||||
const replacement = outdented.join('\n');
|
||||
const nextValue = `${value.slice(0, blockStart)}${replacement}${value.slice(blockEnd)}`;
|
||||
const nextStart = Math.max(blockStart, start - firstLineRemoved);
|
||||
const nextEnd = Math.max(nextStart, end - totalRemoved);
|
||||
applyEditorContent(nextValue, nextStart, nextEnd, textarea);
|
||||
return;
|
||||
}
|
||||
|
||||
const indented = lines.map((line) => ` ${line}`).join('\n');
|
||||
const nextValue = `${value.slice(0, blockStart)}${indented}${value.slice(blockEnd)}`;
|
||||
const nextStart = start + 2;
|
||||
const nextEnd = end + lines.length * 2;
|
||||
applyEditorContent(nextValue, nextStart, nextEnd, textarea);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const beforeCursor = value.slice(lineStart, start);
|
||||
|
||||
let remove = 0;
|
||||
if (beforeCursor.endsWith('\t')) remove = 1;
|
||||
else if (beforeCursor.endsWith(' ')) remove = 2;
|
||||
else if (beforeCursor.endsWith(' ')) remove = 1;
|
||||
|
||||
if (remove > 0) {
|
||||
const nextValue = `${value.slice(0, start - remove)}${value.slice(end)}`;
|
||||
const nextPos = start - remove;
|
||||
applyEditorContent(nextValue, nextPos, nextPos, textarea);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = `${value.slice(0, start)} ${value.slice(end)}`;
|
||||
const nextPos = start + 2;
|
||||
applyEditorContent(nextValue, nextPos, nextPos, textarea);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const currentLine = value.slice(lineStart, start);
|
||||
const leadingWhitespace = currentLine.match(/^\s*/)?.[0] ?? '';
|
||||
const shouldIndentMore = /[{[(]$/.test(currentLine.trimEnd());
|
||||
const insertion = `\n${leadingWhitespace}${shouldIndentMore ? ' ' : ''}`;
|
||||
|
||||
const nextValue = `${value.slice(0, start)}${insertion}${value.slice(end)}`;
|
||||
const nextPos = start + insertion.length;
|
||||
applyEditorContent(nextValue, nextPos, nextPos, textarea);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerUploadFiles = () => {
|
||||
const input = fileInputRef.current;
|
||||
if (!input) return;
|
||||
input.value = '';
|
||||
input.click();
|
||||
};
|
||||
|
||||
const triggerUploadFolder = () => {
|
||||
const input = folderInputRef.current;
|
||||
if (!input) return;
|
||||
input.value = '';
|
||||
input.setAttribute('webkitdirectory', '');
|
||||
input.setAttribute('directory', '');
|
||||
input.click();
|
||||
};
|
||||
|
||||
const uploadItems = async (items: UploadItem[]) => {
|
||||
if (!orgId || !serverId || items.length === 0) return;
|
||||
|
||||
setUploadProgress({ done: 0, total: items.length });
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index]!;
|
||||
|
||||
try {
|
||||
const data = await fileToBase64(item.file);
|
||||
await api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
|
||||
path: item.targetPath,
|
||||
data,
|
||||
encoding: 'base64',
|
||||
});
|
||||
successCount += 1;
|
||||
} catch {
|
||||
failedCount += 1;
|
||||
}
|
||||
|
||||
setUploadProgress({ done: index + 1, total: items.length });
|
||||
}
|
||||
|
||||
setUploadProgress(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||
|
||||
if (failedCount === 0) {
|
||||
toast.success(`${successCount} item uploaded`);
|
||||
} else {
|
||||
toast.error(`${failedCount} item failed to upload`);
|
||||
}
|
||||
};
|
||||
|
||||
const onFilesPicked = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
if (selected.length === 0) return;
|
||||
|
||||
const uploadList: UploadItem[] = selected.map((file) => ({
|
||||
file,
|
||||
targetPath: joinRemotePath(currentPath, file.name),
|
||||
}));
|
||||
|
||||
await uploadItems(uploadList);
|
||||
};
|
||||
|
||||
const onFolderPicked = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
if (selected.length === 0) return;
|
||||
|
||||
const uploadList: UploadItem[] = selected.map((file) => {
|
||||
const pathFromFolder =
|
||||
(file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
|
||||
return {
|
||||
file,
|
||||
targetPath: joinRemotePath(currentPath, pathFromFolder),
|
||||
};
|
||||
});
|
||||
|
||||
await uploadItems(uploadList);
|
||||
};
|
||||
|
||||
const downloadFile = async (file: FileEntry) => {
|
||||
if (!orgId || !serverId || file.isDirectory) return;
|
||||
|
||||
try {
|
||||
const response = await api.get<FileReadResponse>(
|
||||
`/organizations/${orgId}/servers/${serverId}/files/read`,
|
||||
{ path: file.path, encoding: 'base64' },
|
||||
);
|
||||
|
||||
const blob = new Blob([base64ToArrayBuffer(response.data)], {
|
||||
type: response.mimeType || 'application/octet-stream',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = file.name;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error(extractApiMessage(error, 'Download failed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (editingFile) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{editingFile ? (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-sm font-mono">{editingFile.path}</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tab/Shift+Tab indent, Enter auto-indent, Ctrl/Cmd+S save
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-xs text-amber-600">Unsaved changes</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
saveMutation.mutate({ path: editingFile.path, data: editingFile.content })
|
||||
}
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={saveCurrentFile}
|
||||
disabled={saveMutation.isPending || !hasUnsavedChanges}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingFile(null)}>
|
||||
<Button size="sm" variant="outline" onClick={closeEditor}>
|
||||
<X className="h-4 w-4" />
|
||||
Close
|
||||
</Button>
|
||||
@@ -129,53 +547,128 @@ export function FilesPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
value={editingFile.content}
|
||||
onChange={(e) => setEditingFile({ ...editingFile, content: e.target.value })}
|
||||
className="min-h-[500px] w-full rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
onChange={(event) =>
|
||||
setEditingFile((prev) =>
|
||||
prev ? { ...prev, content: event.target.value } : prev,
|
||||
)
|
||||
}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
className="min-h-[560px] w-full resize-y rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
spellCheck={false}
|
||||
style={{ tabSize: 2 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void onFilesPicked(event);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void onFolderPicked(event);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Button variant="ghost" size="icon" onClick={goUp} disabled={currentPath === '/'}>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
setCurrentPath('/' + breadcrumbs.slice(0, i + 1).join('/'))
|
||||
}
|
||||
className="text-muted-foreground transition hover:text-foreground"
|
||||
onClick={() => setCurrentPath('/')}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<button
|
||||
className="text-muted-foreground transition hover:text-foreground"
|
||||
onClick={() => setCurrentPath('/' + breadcrumbs.slice(0, index + 1).join('/'))}
|
||||
>
|
||||
{crumb}
|
||||
</button>
|
||||
{i < breadcrumbs.length - 1 && (
|
||||
<span className="text-muted-foreground">/</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowNewFile(true)}>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={triggerUploadFiles}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Files
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={triggerUploadFolder}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Folder
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowNewFolder(true)}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
New Folder
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowNewFile(true)}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUploading && uploadProgress && (
|
||||
<Card>
|
||||
<CardContent className="py-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Uploading... {uploadProgress.done}/{uploadProgress.total}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showNewFile && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
placeholder="filename.txt"
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newFileName) {
|
||||
const path =
|
||||
currentPath === '/' ? `/${newFileName}` : `${currentPath}/${newFileName}`;
|
||||
onChange={(event) => setNewFileName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && newFileName.trim()) {
|
||||
const path = joinRemotePath(currentPath, newFileName.trim());
|
||||
createFileMutation.mutate({ path, data: '' });
|
||||
}
|
||||
}}
|
||||
@@ -183,11 +676,11 @@ export function FilesPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!newFileName) return;
|
||||
const path =
|
||||
currentPath === '/' ? `/${newFileName}` : `${currentPath}/${newFileName}`;
|
||||
if (!newFileName.trim()) return;
|
||||
const path = joinRemotePath(currentPath, newFileName.trim());
|
||||
createFileMutation.mutate({ path, data: '' });
|
||||
}}
|
||||
disabled={createFileMutation.isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
@@ -204,42 +697,119 @@ export function FilesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNewFolder && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
placeholder="folder-name"
|
||||
value={newFolderName}
|
||||
onChange={(event) => setNewFolderName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && newFolderName.trim()) {
|
||||
const folderPath = joinRemotePath(currentPath, newFolderName.trim());
|
||||
createFolderMutation.mutate(folderPath);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!newFolderName.trim()) return;
|
||||
const folderPath = joinRemotePath(currentPath, newFolderName.trim());
|
||||
createFolderMutation.mutate(folderPath);
|
||||
}}
|
||||
disabled={createFolderMutation.isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowNewFolder(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{files.length === 0 && (
|
||||
{filesQuery.isLoading && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
Loading files...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filesQuery.isError && (
|
||||
<div className="space-y-2 py-8 text-center">
|
||||
<p className="text-sm text-destructive">Failed to load directory</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => filesQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!filesQuery.isLoading && !filesQuery.isError && files.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
This directory is empty
|
||||
</div>
|
||||
)}
|
||||
{files.map((file) => (
|
||||
|
||||
{!filesQuery.isLoading &&
|
||||
!filesQuery.isError &&
|
||||
files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex cursor-pointer items-center justify-between px-4 py-2.5 hover:bg-muted/50"
|
||||
onClick={() => openFile(file)}
|
||||
onClick={() => {
|
||||
void openFile(file);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{file.isDirectory ? (
|
||||
<Folder className="h-4 w-4 text-blue-400" />
|
||||
<Folder className="h-4 w-4 shrink-0 text-blue-400" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm">{file.name}</span>
|
||||
<span className="truncate text-sm">{file.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
<div className="ml-3 flex items-center gap-2">
|
||||
{!file.isDirectory && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
)}
|
||||
{!file.isDirectory && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(file.path);
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void downloadFile(file);
|
||||
}}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDeleteTarget(file);
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
@@ -253,10 +823,11 @@ export function FilesPage() {
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete File</DialogTitle>
|
||||
<DialogTitle>{deleteTarget?.isDirectory ? 'Delete Folder' : 'Delete File'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete <code className="font-mono">{deleteTarget}</code>?
|
||||
Are you sure you want to delete{' '}
|
||||
<code className="font-mono">{deleteTarget?.path}</code>?
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
@@ -264,7 +835,10 @@ export function FilesPage() {
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteTarget && deleteMutation.mutate([deleteTarget])}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
deleteMutation.mutate([deleteTarget.path]);
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
@@ -272,8 +846,6 @@ export function FilesPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,40 +1,353 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useOutletContext } from 'react-router';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useOutletContext, useParams } from 'react-router';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ApiError, api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
|
||||
interface ServerDetail {
|
||||
id: string;
|
||||
gameId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
memoryLimit: number;
|
||||
diskLimit: number;
|
||||
cpuLimit: number;
|
||||
startupOverride?: string;
|
||||
startupOverride?: string | null;
|
||||
environment?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GameEnvironmentVar {
|
||||
key: string;
|
||||
label?: string;
|
||||
default?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
inputType?: 'text' | 'boolean';
|
||||
composeInto?: string;
|
||||
flagValue?: string;
|
||||
enabledLabel?: string;
|
||||
disabledLabel?: string;
|
||||
}
|
||||
|
||||
interface GameDefinition {
|
||||
id: string;
|
||||
startupCommand: string;
|
||||
environmentVars?: GameEnvironmentVar[];
|
||||
}
|
||||
|
||||
interface EnvironmentField {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
inputType: 'text' | 'boolean';
|
||||
composeInto?: string;
|
||||
flagValue?: string;
|
||||
enabledLabel?: string;
|
||||
disabledLabel?: string;
|
||||
isCustom: boolean;
|
||||
}
|
||||
|
||||
type AutomationEvent =
|
||||
| 'server.created'
|
||||
| 'server.install.completed'
|
||||
| 'server.power.started'
|
||||
| 'server.power.stopped';
|
||||
|
||||
interface AutomationRunResult {
|
||||
workflowsMatched: number;
|
||||
workflowsExecuted: number;
|
||||
workflowsSkipped: number;
|
||||
workflowsFailed: number;
|
||||
actionFailures: number;
|
||||
failures: Array<{
|
||||
level: 'action' | 'workflow';
|
||||
workflowId: string;
|
||||
actionId?: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface AutomationRunResponse {
|
||||
success: boolean;
|
||||
event: AutomationEvent;
|
||||
force: boolean;
|
||||
result: AutomationRunResult;
|
||||
}
|
||||
|
||||
const AUTOMATION_EVENTS: AutomationEvent[] = [
|
||||
'server.created',
|
||||
'server.install.completed',
|
||||
'server.power.started',
|
||||
'server.power.stopped',
|
||||
];
|
||||
|
||||
function extractApiMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||
return maybeMessage;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeStringRecord(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalizedKey = key.trim();
|
||||
if (!normalizedKey) continue;
|
||||
normalized[normalizedKey] = String(entryValue ?? '');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildEnvironmentFields(
|
||||
game: GameDefinition | undefined,
|
||||
serverEnvironment: unknown,
|
||||
): EnvironmentField[] {
|
||||
const overrides = normalizeStringRecord(serverEnvironment);
|
||||
const fields: EnvironmentField[] = [];
|
||||
const knownKeys = new Set<string>();
|
||||
|
||||
for (const variable of game?.environmentVars ?? []) {
|
||||
const key = variable.key?.trim();
|
||||
if (!key) continue;
|
||||
|
||||
const composeInto = variable.composeInto?.trim();
|
||||
const flagValue = variable.flagValue?.trim();
|
||||
if (!composeInto) {
|
||||
knownKeys.add(key);
|
||||
}
|
||||
|
||||
if (composeInto && flagValue) {
|
||||
const baseValue = overrides[composeInto] ?? '';
|
||||
const tokens = baseValue.trim() ? baseValue.trim().split(/\s+/) : [];
|
||||
fields.push({
|
||||
key,
|
||||
label: variable.label?.trim() || key,
|
||||
value: tokens.includes(flagValue) ? 'true' : 'false',
|
||||
defaultValue: 'false',
|
||||
description: variable.description ?? '',
|
||||
required: Boolean(variable.required),
|
||||
inputType: variable.inputType === 'boolean' ? 'boolean' : 'text',
|
||||
composeInto,
|
||||
flagValue,
|
||||
enabledLabel: variable.enabledLabel,
|
||||
disabledLabel: variable.disabledLabel,
|
||||
isCustom: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
fields.push({
|
||||
key,
|
||||
label: variable.label?.trim() || key,
|
||||
value: overrides[key] ?? String(variable.default ?? ''),
|
||||
defaultValue: String(variable.default ?? ''),
|
||||
description: variable.description ?? '',
|
||||
required: Boolean(variable.required),
|
||||
inputType: variable.inputType === 'boolean' ? 'boolean' : 'text',
|
||||
composeInto,
|
||||
flagValue,
|
||||
enabledLabel: variable.enabledLabel,
|
||||
disabledLabel: variable.disabledLabel,
|
||||
isCustom: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (knownKeys.has(key)) continue;
|
||||
fields.push({
|
||||
key,
|
||||
label: key,
|
||||
value,
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
required: false,
|
||||
inputType: 'text',
|
||||
isCustom: true,
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function buildEnvironmentPayload(fields: EnvironmentField[]): Record<string, string> {
|
||||
const payload: Record<string, string> = {};
|
||||
const defaults = new Map<string, string>();
|
||||
|
||||
for (const field of fields) {
|
||||
const key = field.key.trim();
|
||||
if (!key) continue;
|
||||
if (!field.isCustom) {
|
||||
defaults.set(key, field.defaultValue);
|
||||
}
|
||||
|
||||
if (field.isCustom) {
|
||||
payload[key] = field.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.composeInto) continue;
|
||||
|
||||
if (field.value !== field.defaultValue) {
|
||||
payload[key] = field.value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.isCustom || !field.composeInto || !field.flagValue) continue;
|
||||
|
||||
const targetKey = field.composeInto.trim();
|
||||
if (!targetKey) continue;
|
||||
|
||||
const defaultValue = defaults.get(targetKey) ?? '';
|
||||
const currentValue = payload[targetKey] ?? defaultValue;
|
||||
const tokens = currentValue.trim() ? currentValue.trim().split(/\s+/) : [];
|
||||
const nextTokens = tokens.filter((token) => token !== field.flagValue);
|
||||
if (field.value === 'true') {
|
||||
nextTokens.push(field.flagValue);
|
||||
}
|
||||
|
||||
const nextValue = nextTokens.join(' ').trim();
|
||||
if (!nextValue || nextValue === defaultValue) {
|
||||
delete payload[targetKey];
|
||||
continue;
|
||||
}
|
||||
|
||||
payload[targetKey] = nextValue;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function ServerSettingsPage() {
|
||||
const { orgId, serverId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { server } = useOutletContext<{ server?: ServerDetail }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [name, setName] = useState(server?.name ?? '');
|
||||
const [description, setDescription] = useState(server?.description ?? '');
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [startupOverride, setStartupOverride] = useState('');
|
||||
const [environmentFields, setEnvironmentFields] = useState<EnvironmentField[]>([]);
|
||||
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>('server.install.completed');
|
||||
const [forceAutomationRun, setForceAutomationRun] = useState(false);
|
||||
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(null);
|
||||
|
||||
const { data: gamesData } = useQuery({
|
||||
queryKey: ['games'],
|
||||
queryFn: () => api.get<{ data: GameDefinition[] }>('/games'),
|
||||
});
|
||||
|
||||
const activeGame = (gamesData?.data ?? []).find((game) => game.id === server?.gameId);
|
||||
const serverEnvironmentJson = JSON.stringify(server?.environment ?? {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!server) return;
|
||||
setName(server.name);
|
||||
setDescription(server.description ?? '');
|
||||
}, [server?.id, server?.name, server?.description]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!server) return;
|
||||
setStartupOverride(server.startupOverride ?? '');
|
||||
setEnvironmentFields(buildEnvironmentFields(activeGame, server.environment));
|
||||
}, [server?.id, server?.startupOverride, serverEnvironmentJson, activeGame]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.patch(`/organizations/${orgId}/servers/${serverId}`, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] });
|
||||
toast.success('Server settings saved');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to save server settings'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/organizations/${orgId}/servers/${serverId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
|
||||
navigate(`/org/${orgId}/servers`);
|
||||
},
|
||||
});
|
||||
|
||||
const automationRunMutation = useMutation({
|
||||
mutationFn: (body: { event: AutomationEvent; force: boolean }) =>
|
||||
api.post<AutomationRunResponse>(`/organizations/${orgId}/servers/${serverId}/automation/run`, body),
|
||||
onSuccess: (response) => {
|
||||
setLastAutomationResult(response.result);
|
||||
if (response.result.workflowsFailed > 0 || response.result.actionFailures > 0) {
|
||||
const firstFailure = response.result.failures[0]?.message;
|
||||
toast.error(
|
||||
firstFailure
|
||||
? `Automation failed: ${firstFailure}`
|
||||
: `Automation completed with errors (${response.result.workflowsFailed} workflow failures)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Automation completed: ${response.result.workflowsExecuted} workflows executed`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to run automation'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateEnvironmentField = (
|
||||
index: number,
|
||||
patch: Partial<Pick<EnvironmentField, 'key' | 'value'>>,
|
||||
) => {
|
||||
setEnvironmentFields((prev) =>
|
||||
prev.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)),
|
||||
);
|
||||
};
|
||||
|
||||
const addCustomEnvironmentField = () => {
|
||||
setEnvironmentFields((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: '',
|
||||
label: '',
|
||||
value: '',
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
required: false,
|
||||
inputType: 'text',
|
||||
isCustom: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEnvironmentField = (index: number) => {
|
||||
setEnvironmentFields((prev) => prev.filter((_, fieldIndex) => fieldIndex !== index));
|
||||
};
|
||||
|
||||
const saveStartupSettings = () => {
|
||||
updateMutation.mutate({
|
||||
startupOverride: startupOverride.trim(),
|
||||
environment: buildEnvironmentPayload(environmentFields),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -87,13 +400,253 @@ export function ServerSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Startup</CardTitle>
|
||||
<CardDescription>
|
||||
Saving these values recreates the container with the same files and restarts it if it
|
||||
was running.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Startup Override</Label>
|
||||
<Input
|
||||
value={startupOverride}
|
||||
onChange={(e) => setStartupOverride(e.target.value)}
|
||||
placeholder={activeGame?.startupCommand || 'Use image default command'}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the game default startup command or the image entrypoint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Environment Variables</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add custom keys for image-specific startup switches such as extra launch args.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addCustomEnvironmentField}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{environmentFields.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
This game does not define any startup variables yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{environmentFields.map((field, index) => (
|
||||
field.isCustom ? (
|
||||
<div
|
||||
key={`custom-${index}`}
|
||||
className="grid gap-2 rounded-md border p-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<Input
|
||||
value={field.key}
|
||||
onChange={(e) => updateEnvironmentField(index, { key: e.target.value })}
|
||||
placeholder="ENV_KEY"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => updateEnvironmentField(index, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeEnvironmentField(index)}
|
||||
aria-label="Remove environment variable"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div key={field.key} className="grid gap-1.5 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="font-mono text-xs text-muted-foreground">
|
||||
{field.label}
|
||||
</Label>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Default: <span className="font-mono">{field.defaultValue || 'empty'}</span>
|
||||
</span>
|
||||
</div>
|
||||
{field.inputType === 'boolean' ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === 'true' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updateEnvironmentField(index, { value: 'true' })}
|
||||
>
|
||||
{field.enabledLabel ?? 'Enabled'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === 'false' ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updateEnvironmentField(index, { value: 'false' })}
|
||||
>
|
||||
{field.disabledLabel ?? 'Disabled'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => updateEnvironmentField(index, { value: e.target.value })}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
{(field.description || field.required) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{field.description || 'Required startup variable'}
|
||||
{field.required ? ' Required.' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={saveStartupSettings}
|
||||
disabled={updateMutation.isPending || !server}
|
||||
>
|
||||
{updateMutation.isPending ? 'Applying...' : 'Save Startup Settings'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Automation</CardTitle>
|
||||
<CardDescription>Manually trigger an automation event for this server</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Event</Label>
|
||||
<Select value={automationEvent} onValueChange={(value) => setAutomationEvent(value as AutomationEvent)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTOMATION_EVENTS.map((eventName) => (
|
||||
<SelectItem key={eventName} value={eventName}>
|
||||
{eventName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={forceAutomationRun ? 'default' : 'outline'}
|
||||
onClick={() => setForceAutomationRun((prev) => !prev)}
|
||||
>
|
||||
{forceAutomationRun ? 'Force: ON' : 'Force: OFF'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => automationRunMutation.mutate({ event: automationEvent, force: forceAutomationRun })}
|
||||
disabled={automationRunMutation.isPending}
|
||||
>
|
||||
{automationRunMutation.isPending ? 'Running...' : 'Run Automation Event'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enabling force will rerun workflows that are marked runOncePerServer.
|
||||
</p>
|
||||
|
||||
{lastAutomationResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Matched</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.workflowsMatched}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Executed</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.workflowsExecuted}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Skipped</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.workflowsSkipped}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Failed</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.workflowsFailed}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Action Failures</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.actionFailures}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastAutomationResult.failures.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-destructive/40 bg-destructive/5 p-3">
|
||||
<p className="text-sm font-medium text-destructive">Failure Details</p>
|
||||
<div className="space-y-1">
|
||||
{lastAutomationResult.failures.slice(0, 5).map((failure, index) => (
|
||||
<p key={`${failure.workflowId}-${failure.actionId ?? 'workflow'}-${index}`} className="text-xs text-destructive">
|
||||
[{failure.workflowId}{failure.actionId ? ` > ${failure.actionId}` : ''}] {failure.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length === 0 && (
|
||||
<p className="text-xs text-green-600">Automation run completed successfully.</p>
|
||||
)}
|
||||
|
||||
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length > 0 && (
|
||||
<p className="text-xs text-destructive">
|
||||
Automation run completed with {lastAutomationResult.failures.length} error(s).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{automationRunMutation.isError && (
|
||||
<p className="text-xs text-destructive">
|
||||
Failed to run automation event.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>Irreversible actions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="destructive">Delete Server</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!window.confirm('Delete this server permanently? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete Server'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -7,15 +7,34 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface Game {
|
||||
environmentVars?: GameEnvironmentVar[];
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
dockerImage: string;
|
||||
}
|
||||
|
||||
interface GameEnvironmentVar {
|
||||
key: string;
|
||||
label?: string;
|
||||
default?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
inputType?: 'text' | 'boolean';
|
||||
composeInto?: string;
|
||||
enabledLabel?: string;
|
||||
disabledLabel?: string;
|
||||
}
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -36,6 +55,28 @@ interface PaginatedResponse<T> {
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
interface AdditionalPortRequirement {
|
||||
key: string;
|
||||
label: string;
|
||||
defaultPort: number;
|
||||
protocols: Array<'tcp' | 'udp'>;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] {
|
||||
if (gameSlug.trim().toLowerCase() !== 'satisfactory') return [];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'satisfactory-messaging',
|
||||
label: 'Messaging Port',
|
||||
defaultPort: 8888,
|
||||
protocols: ['tcp'],
|
||||
description: 'Required by the Satisfactory server messaging API.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function CreateServerPage() {
|
||||
const { orgId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -46,13 +87,17 @@ export function CreateServerPage() {
|
||||
const [gameId, setGameId] = useState('');
|
||||
const [nodeId, setNodeId] = useState('');
|
||||
const [allocationId, setAllocationId] = useState('');
|
||||
const [additionalAllocationIds, setAdditionalAllocationIds] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [memoryLimit, setMemoryLimit] = useState(1024);
|
||||
const [diskLimit, setDiskLimit] = useState(5120);
|
||||
const [cpuLimit, setCpuLimit] = useState(100);
|
||||
const [environment, setEnvironment] = useState<Record<string, string>>({});
|
||||
|
||||
const { data: gamesData } = useQuery({
|
||||
queryKey: ['admin-games'],
|
||||
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
|
||||
queryKey: ['games'],
|
||||
queryFn: () => api.get<PaginatedResponse<Game>>('/games'),
|
||||
});
|
||||
|
||||
const { data: nodesData } = useQuery({
|
||||
@@ -63,9 +108,7 @@ export function CreateServerPage() {
|
||||
const { data: allocationsData } = useQuery({
|
||||
queryKey: ['allocations', orgId, nodeId],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<Allocation>>(
|
||||
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
|
||||
),
|
||||
api.get<PaginatedResponse<Allocation>>(`/organizations/${orgId}/nodes/${nodeId}/allocations`),
|
||||
enabled: !!nodeId,
|
||||
});
|
||||
|
||||
@@ -81,8 +124,60 @@ export function CreateServerPage() {
|
||||
|
||||
const games = gamesData?.data ?? [];
|
||||
const nodes = nodesData?.data ?? [];
|
||||
const activeGame = games.find((game) => game.id === gameId);
|
||||
const additionalPortRequirements = activeGame
|
||||
? additionalPortRequirementsForGame(activeGame.slug)
|
||||
: [];
|
||||
const visibleEnvironmentVars = (activeGame?.environmentVars ?? []).filter((variable) => {
|
||||
const key = variable.key?.trim();
|
||||
return Boolean(key) && !variable.composeInto?.trim();
|
||||
});
|
||||
const missingRequiredEnvironment = visibleEnvironmentVars.some((variable) => {
|
||||
const key = variable.key.trim();
|
||||
const currentValue = environment[key] ?? String(variable.default ?? '');
|
||||
return Boolean(variable.required) && !currentValue.trim();
|
||||
});
|
||||
const missingRequiredAdditionalPorts = additionalPortRequirements.some(
|
||||
(requirement) => !additionalAllocationIds[requirement.key],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeGame) {
|
||||
setEnvironment({});
|
||||
return;
|
||||
}
|
||||
|
||||
setEnvironment((current) => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const variable of activeGame.environmentVars ?? []) {
|
||||
const key = variable.key?.trim();
|
||||
if (!key || variable.composeInto?.trim()) continue;
|
||||
next[key] = current[key] ?? String(variable.default ?? '');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAdditionalAllocationIds({});
|
||||
if (activeGame.slug.trim().toLowerCase() === 'satisfactory') {
|
||||
setMemoryLimit((current) => Math.max(current, 8192));
|
||||
setDiskLimit((current) => Math.max(current, 12288));
|
||||
}
|
||||
}, [activeGame?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setAdditionalAllocationIds((current) => {
|
||||
const allowedKeys = new Set(additionalPortRequirements.map((requirement) => requirement.key));
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(current).filter(([key]) => allowedKeys.has(key)),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}, [activeGame?.slug]);
|
||||
|
||||
const handleCreate = () => {
|
||||
const environmentPayload = Object.fromEntries(
|
||||
Object.entries(environment).filter(([, value]) => value.trim() !== ''),
|
||||
);
|
||||
|
||||
createMutation.mutate({
|
||||
name,
|
||||
description: description || undefined,
|
||||
@@ -92,9 +187,30 @@ export function CreateServerPage() {
|
||||
memoryLimit: memoryLimit * 1024 * 1024,
|
||||
diskLimit: diskLimit * 1024 * 1024,
|
||||
cpuLimit,
|
||||
additionalAllocationIds:
|
||||
additionalPortRequirements.length > 0
|
||||
? additionalPortRequirements
|
||||
.map((requirement) => additionalAllocationIds[requirement.key])
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
environment: Object.keys(environmentPayload).length > 0 ? environmentPayload : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const allocationOptionsForRequirement = (requirementKey: string) => {
|
||||
const selectedByOtherRequirements = new Set(
|
||||
Object.entries(additionalAllocationIds)
|
||||
.filter(([key]) => key !== requirementKey)
|
||||
.map(([, id]) => id)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
return freeAllocations.filter(
|
||||
(allocation) =>
|
||||
allocation.id !== allocationId && !selectedByOtherRequirements.has(allocation.id),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
@@ -149,7 +265,69 @@ export function CreateServerPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={() => setStep(2)} disabled={!name || !gameId}>
|
||||
{visibleEnvironmentVars.map((variable) => {
|
||||
const key = variable.key.trim();
|
||||
const label = variable.label?.trim() || key;
|
||||
const value = environment[key] ?? String(variable.default ?? '');
|
||||
const isSecret =
|
||||
key.toLowerCase().includes('password') || key.toLowerCase().includes('license');
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>
|
||||
{label}
|
||||
{variable.required ? ' *' : ''}
|
||||
</Label>
|
||||
{variable.inputType === 'boolean' ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === 'true' ? 'default' : 'outline'}
|
||||
onClick={() =>
|
||||
setEnvironment((current) => ({
|
||||
...current,
|
||||
[key]: 'true',
|
||||
}))
|
||||
}
|
||||
>
|
||||
{variable.enabledLabel ?? 'Enabled'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === 'false' ? 'secondary' : 'outline'}
|
||||
onClick={() =>
|
||||
setEnvironment((current) => ({
|
||||
...current,
|
||||
[key]: 'false',
|
||||
}))
|
||||
}
|
||||
>
|
||||
{variable.disabledLabel ?? 'Disabled'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={isSecret ? 'password' : 'text'}
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
setEnvironment((current) => ({
|
||||
...current,
|
||||
[key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={variable.description || label}
|
||||
/>
|
||||
)}
|
||||
{variable.description && (
|
||||
<p className="text-xs text-muted-foreground">{variable.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!name || !gameId || missingRequiredEnvironment}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -170,6 +348,7 @@ export function CreateServerPage() {
|
||||
onValueChange={(v) => {
|
||||
setNodeId(v);
|
||||
setAllocationId('');
|
||||
setAdditionalAllocationIds({});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -187,7 +366,17 @@ export function CreateServerPage() {
|
||||
{nodeId && (
|
||||
<div className="space-y-2">
|
||||
<Label>Port Allocation</Label>
|
||||
<Select value={allocationId} onValueChange={setAllocationId}>
|
||||
<Select
|
||||
value={allocationId}
|
||||
onValueChange={(value) => {
|
||||
setAllocationId(value);
|
||||
setAdditionalAllocationIds((current) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(current).filter(([, selectedId]) => selectedId !== value),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a port" />
|
||||
</SelectTrigger>
|
||||
@@ -204,11 +393,53 @@ export function CreateServerPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{nodeId &&
|
||||
additionalPortRequirements.map((requirement) => {
|
||||
const options = allocationOptionsForRequirement(requirement.key);
|
||||
const protocols = requirement.protocols.join('/').toUpperCase();
|
||||
|
||||
return (
|
||||
<div key={requirement.key} className="space-y-2">
|
||||
<Label>
|
||||
{requirement.label} ({protocols})
|
||||
</Label>
|
||||
<Select
|
||||
value={additionalAllocationIds[requirement.key] ?? ''}
|
||||
onValueChange={(value) =>
|
||||
setAdditionalAllocationIds((current) => ({
|
||||
...current,
|
||||
[requirement.key]: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select port ${requirement.defaultPort}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((allocation) => (
|
||||
<SelectItem key={allocation.id} value={allocation.id}>
|
||||
{allocation.ip}:{allocation.port}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{requirement.description}</p>
|
||||
{options.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
No free allocation available for this port
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep(1)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={() => setStep(3)} disabled={!nodeId || !allocationId}>
|
||||
<Button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!nodeId || !allocationId || missingRequiredAdditionalPorts}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Server, Plus } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { statusBadgeVariant } from '@/lib/utils';
|
||||
|
||||
interface ServerSummary {
|
||||
id: string;
|
||||
uuid: string;
|
||||
name: string;
|
||||
status: string;
|
||||
gameName: string;
|
||||
nodeName: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number; page: number; perPage: number; totalPages: number };
|
||||
}
|
||||
|
||||
export function ServersPage() {
|
||||
const { orgId } = useParams();
|
||||
|
||||
const { data: serversData } = useQuery({
|
||||
queryKey: ['servers', orgId],
|
||||
queryFn: () => api.get<PaginatedResponse<ServerSummary>>(`/organizations/${orgId}/servers`),
|
||||
});
|
||||
|
||||
const servers = serversData?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Servers</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{servers.length} server{servers.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link to={`/org/${orgId}/servers/new`}>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Server
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Server className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">No servers yet</p>
|
||||
<Link to={`/org/${orgId}/servers/new`}>
|
||||
<Button variant="outline" className="mt-4">
|
||||
Create your first server
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{servers.map((server) => (
|
||||
<Link key={server.id} to={`/org/${orgId}/servers/${server.id}/console`}>
|
||||
<Card className="transition-colors hover:border-primary/50">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{server.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{server.gameName} · {server.nodeName} · :{server.port}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,32 @@ interface Member {
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
customPermissions: Record<string, boolean>;
|
||||
}
|
||||
|
||||
type MembershipPreset = 'admin' | 'moderator' | 'user';
|
||||
|
||||
const MODERATOR_PERMISSIONS: Record<string, boolean> = {
|
||||
'plugin.manage': true,
|
||||
};
|
||||
|
||||
function getMemberPreset(member: Member): MembershipPreset {
|
||||
if (member.role === 'admin') return 'admin';
|
||||
if (member.customPermissions?.['plugin.manage']) return 'moderator';
|
||||
return 'user';
|
||||
}
|
||||
|
||||
function buildPresetPayload(preset: MembershipPreset): {
|
||||
role: 'admin' | 'user';
|
||||
customPermissions: Record<string, boolean>;
|
||||
} {
|
||||
if (preset === 'admin') {
|
||||
return { role: 'admin', customPermissions: {} };
|
||||
}
|
||||
if (preset === 'moderator') {
|
||||
return { role: 'user', customPermissions: MODERATOR_PERMISSIONS };
|
||||
}
|
||||
return { role: 'user', customPermissions: {} };
|
||||
}
|
||||
|
||||
export function MembersPage() {
|
||||
@@ -32,12 +58,15 @@ export function MembersPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||
const [updatingMemberId, setUpdatingMemberId] = useState<string | null>(null);
|
||||
|
||||
const { data: members } = useQuery({
|
||||
const { data: membersData } = useQuery({
|
||||
queryKey: ['members', orgId],
|
||||
queryFn: () => api.get<Member[]>(`/organizations/${orgId}/members`),
|
||||
queryFn: () => api.get<{ data: Member[] }>(`/organizations/${orgId}/members`),
|
||||
});
|
||||
|
||||
const members = membersData?.data ?? [];
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (body: { email: string; role: string }) =>
|
||||
api.post(`/organizations/${orgId}/members`, body),
|
||||
@@ -56,6 +85,26 @@ export function MembersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
memberId,
|
||||
preset,
|
||||
}: {
|
||||
memberId: string;
|
||||
preset: MembershipPreset;
|
||||
}) =>
|
||||
api.patch(`/organizations/${orgId}/members/${memberId}`, buildPresetPayload(preset)),
|
||||
onMutate: ({ memberId }) => {
|
||||
setUpdatingMemberId(memberId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['members', orgId] });
|
||||
},
|
||||
onSettled: () => {
|
||||
setUpdatingMemberId(null);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -114,16 +163,35 @@ export function MembersPage() {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{(members ?? []).map((member) => (
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium">{member.username}</p>
|
||||
<p className="text-sm text-muted-foreground">{member.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={member.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{member.role}
|
||||
<Badge variant={getMemberPreset(member) === 'admin' ? 'default' : 'secondary'}>
|
||||
{getMemberPreset(member)}
|
||||
</Badge>
|
||||
<Select
|
||||
value={getMemberPreset(member)}
|
||||
onValueChange={(value) =>
|
||||
updateMutation.mutate({
|
||||
memberId: member.id,
|
||||
preset: value as MembershipPreset,
|
||||
})
|
||||
}
|
||||
disabled={updateMutation.isPending && updatingMemberId === member.id}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="moderator">Moderator</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -56,8 +56,8 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
fetchUser: async () => {
|
||||
try {
|
||||
const user = await api.get<User>('/auth/me');
|
||||
set({ user, isAuthenticated: true, isLoading: false });
|
||||
const data = await api.get<{ user: User }>('/auth/me');
|
||||
set({ user: data.user, isAuthenticated: true, isLoading: false });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
=== CONDUIT CRASH REPORT ==========================================
|
||||
Framework : Conduit 0.1.0-dev
|
||||
Time : 2026-06-13 11:34:58 (local)
|
||||
Process : pid=36
|
||||
Signal : 11 (Segmentation fault), fault address (nil)
|
||||
|
||||
--- ATTRIBUTION ---------------------------------------------------
|
||||
1. owner=crash_test_plugin callsite=conduit_crash_test
|
||||
>>> Most likely culprit: 'crash_test_plugin' (in 'conduit_crash_test')
|
||||
|
||||
--- STACK TRACE ---------------------------------------------------
|
||||
#00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbfb64) [0x7fdf9e694b64]
|
||||
#01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050]
|
||||
#02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf66c) [0x7fdf9e69466c]
|
||||
#03 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902]
|
||||
|
||||
--- RECENT EVENTS (oldest first) ----------------------------------
|
||||
[-1767.736s] [INFO] perf: heartbeat: 1921 frames, avg 2.22 ms, max 20.85 ms (last 30 s)
|
||||
[-1737.735s] [INFO] perf: heartbeat: 1920 frames, avg 2.16 ms, max 5.31 ms (last 30 s)
|
||||
[-1707.720s] [INFO] perf: heartbeat: 1921 frames, avg 2.12 ms, max 4.39 ms (last 30 s)
|
||||
[-1677.719s] [INFO] perf: heartbeat: 1920 frames, avg 2.20 ms, max 5.59 ms (last 30 s)
|
||||
[-1647.706s] [INFO] perf: heartbeat: 1921 frames, avg 2.39 ms, max 23.56 ms (last 30 s)
|
||||
[-1617.705s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 7.46 ms (last 30 s)
|
||||
[-1587.691s] [INFO] perf: heartbeat: 1921 frames, avg 2.37 ms, max 8.58 ms (last 30 s)
|
||||
[-1557.680s] [INFO] perf: heartbeat: 1921 frames, avg 2.33 ms, max 4.84 ms (last 30 s)
|
||||
[-1527.679s] [INFO] perf: heartbeat: 1920 frames, avg 2.25 ms, max 5.09 ms (last 30 s)
|
||||
[-1497.674s] [INFO] perf: heartbeat: 1920 frames, avg 2.18 ms, max 19.43 ms (last 30 s)
|
||||
[-1467.673s] [INFO] perf: heartbeat: 1920 frames, avg 2.30 ms, max 6.87 ms (last 30 s)
|
||||
[-1437.669s] [INFO] perf: heartbeat: 1920 frames, avg 2.23 ms, max 34.46 ms (last 30 s)
|
||||
[-1407.668s] [INFO] perf: heartbeat: 1920 frames, avg 2.45 ms, max 6.17 ms (last 30 s)
|
||||
[-1377.653s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.25 ms (last 30 s)
|
||||
[-1347.653s] [INFO] perf: heartbeat: 1920 frames, avg 2.53 ms, max 5.44 ms (last 30 s)
|
||||
[-1317.644s] [INFO] perf: heartbeat: 1921 frames, avg 2.51 ms, max 20.77 ms (last 30 s)
|
||||
[-1287.631s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.45 ms (last 30 s)
|
||||
[-1257.614s] [INFO] perf: heartbeat: 1921 frames, avg 2.57 ms, max 5.90 ms (last 30 s)
|
||||
[-1227.600s] [INFO] perf: heartbeat: 1921 frames, avg 2.54 ms, max 5.79 ms (last 30 s)
|
||||
[-1197.583s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 6.94 ms (last 30 s)
|
||||
[-1167.578s] [INFO] perf: heartbeat: 1920 frames, avg 2.46 ms, max 20.36 ms (last 30 s)
|
||||
[-1137.562s] [INFO] perf: heartbeat: 1921 frames, avg 2.47 ms, max 7.86 ms (last 30 s)
|
||||
[-1107.548s] [INFO] perf: heartbeat: 1921 frames, avg 2.53 ms, max 6.38 ms (last 30 s)
|
||||
[-1077.547s] [INFO] perf: heartbeat: 1920 frames, avg 2.54 ms, max 4.09 ms (last 30 s)
|
||||
[-1047.543s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 33.69 ms (last 30 s)
|
||||
[-1017.532s] [INFO] perf: heartbeat: 1921 frames, avg 2.44 ms, max 5.97 ms (last 30 s)
|
||||
[- 987.516s] [INFO] perf: heartbeat: 1921 frames, avg 2.59 ms, max 5.71 ms (last 30 s)
|
||||
[- 957.501s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 7.43 ms (last 30 s)
|
||||
[- 927.501s] [INFO] perf: heartbeat: 1920 frames, avg 2.60 ms, max 9.17 ms (last 30 s)
|
||||
[- 897.490s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 24.84 ms (last 30 s)
|
||||
[- 867.475s] [INFO] perf: heartbeat: 1921 frames, avg 2.58 ms, max 6.20 ms (last 30 s)
|
||||
[- 837.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 5.57 ms (last 30 s)
|
||||
[- 807.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 6.15 ms (last 30 s)
|
||||
[- 777.460s] [INFO] perf: heartbeat: 1921 frames, avg 2.70 ms, max 5.47 ms (last 30 s)
|
||||
[- 747.449s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 24.22 ms (last 30 s)
|
||||
[- 717.434s] [INFO] perf: heartbeat: 1921 frames, avg 2.80 ms, max 8.64 ms (last 30 s)
|
||||
[- 687.419s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.28 ms (last 30 s)
|
||||
[- 657.418s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 6.34 ms (last 30 s)
|
||||
[- 627.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 31.89 ms (last 30 s)
|
||||
[- 597.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 6.72 ms (last 30 s)
|
||||
[- 567.400s] [INFO] perf: heartbeat: 1921 frames, avg 2.67 ms, max 6.53 ms (last 30 s)
|
||||
[- 537.384s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.66 ms (last 30 s)
|
||||
[- 507.369s] [INFO] perf: heartbeat: 1921 frames, avg 2.55 ms, max 5.30 ms (last 30 s)
|
||||
[- 477.365s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 34.05 ms (last 30 s)
|
||||
[- 447.364s] [INFO] perf: heartbeat: 1920 frames, avg 2.59 ms, max 5.42 ms (last 30 s)
|
||||
[- 417.349s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.55 ms (last 30 s)
|
||||
[- 387.334s] [INFO] perf: heartbeat: 1921 frames, avg 2.34 ms, max 4.63 ms (last 30 s)
|
||||
[- 357.334s] [INFO] perf: heartbeat: 1920 frames, avg 2.77 ms, max 6.65 ms (last 30 s)
|
||||
[- 327.332s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 26.14 ms (last 30 s)
|
||||
[- 312.170s] detour 'detour_self_test' installed at 0x7fdf9e693cd0
|
||||
[- 312.170s] [INFO] hooks: detour 'detour_self_test' installed at 0x7fdf9e693cd0
|
||||
[- 312.169s] detour 'detour_self_test' removed
|
||||
[- 312.169s] [INFO] hooks: detour 'detour_self_test' removed
|
||||
[- 297.318s] [INFO] perf: heartbeat: 1921 frames, avg 2.73 ms, max 6.96 ms (last 30 s)
|
||||
[- 267.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.58 ms, max 6.68 ms (last 30 s)
|
||||
[- 237.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 7.08 ms (last 30 s)
|
||||
[- 207.296s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 25.68 ms (last 30 s)
|
||||
[- 177.293s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 5.12 ms (last 30 s)
|
||||
[- 147.280s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.80 ms (last 30 s)
|
||||
[- 117.280s] [INFO] perf: heartbeat: 1920 frames, avg 2.64 ms, max 6.24 ms (last 30 s)
|
||||
[- 87.265s] [INFO] perf: heartbeat: 1921 frames, avg 2.78 ms, max 5.95 ms (last 30 s)
|
||||
[- 57.263s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 31.12 ms (last 30 s)
|
||||
[- 27.248s] [INFO] perf: heartbeat: 1921 frames, avg 2.72 ms, max 7.34 ms (last 30 s)
|
||||
[- 0.001s] [WARN] core: conduit_crash_test invoked — crashing deliberately
|
||||
===================================================================
|
||||
@@ -0,0 +1,374 @@
|
||||
# Conduit Linux Accumulated Re-run - 2026-06-15
|
||||
|
||||
## Scope
|
||||
|
||||
- Pulled all accumulated commits from `origin/main`.
|
||||
- Inspected the changed files and current `docs/linux-bringup.md`.
|
||||
- Rebuilt Conduit on Linux with GCC 12.
|
||||
- Deployed to CS2 server `Test Sunucusu`.
|
||||
- Verified the new timer, chat interception, usermessage/chat-send, and console adopt paths.
|
||||
- Cleaned temporary test subscriptions/timers/commands.
|
||||
- Satisfactory server was not modified.
|
||||
|
||||
## Git
|
||||
|
||||
Previous local HEAD before this run:
|
||||
|
||||
```text
|
||||
bb58461 Phase 2: dynamic console commands + convar access
|
||||
```
|
||||
|
||||
Pulled commits:
|
||||
|
||||
```text
|
||||
43855ff PLAN: chat ships plain (white) via UTIL_SayTextFilter; color is a follow-up
|
||||
8afbeec chat: send via UTIL_SayTextFilter again (plain text works; SayText2 shows nothing)
|
||||
faded69 docs: refresh README (Phase 2 status + what works) and PLAN chat-send entry
|
||||
18e7970 chat colors: prefer UTIL_SayText2Filter (renders color codes)
|
||||
1743147 PLAN: chat send visually verified on Windows (msgType 0 = chat); colors pending
|
||||
4b3d322 PLAN: correct the chat-send entry (native UTIL_SayTextFilter, SDK drift finding)
|
||||
4ba6019 chat sending: call native UTIL_SayTextFilter instead of building the usermessage
|
||||
a4af862 Phase 2: usermessages + chat sending (SayText2)
|
||||
6985830 Phase 2: chat interception (say / say_team)
|
||||
a375e05 Phase 2: game-thread timers driven by GameFrame
|
||||
62e4ca0 Close out Linux verification of the Commands & ConVars slice
|
||||
15cc25e console: track adopted convars under their owner
|
||||
```
|
||||
|
||||
Diff summary:
|
||||
|
||||
```text
|
||||
PLAN.md | 8 +-
|
||||
README.md | 17 +++-
|
||||
core/CMakeLists.txt | 3 +
|
||||
core/src/conduit/chat.cpp | 165 ++++++++++++++++++++++++++++++++++++++
|
||||
core/src/conduit/chat.h | 46 +++++++++++
|
||||
core/src/conduit/commands.cpp | 131 ++++++++++++++++++++++++++++++
|
||||
core/src/conduit/console.cpp | 23 +++++-
|
||||
core/src/conduit/timers.cpp | 181 ++++++++++++++++++++++++++++++++++++++++++
|
||||
core/src/conduit/timers.h | 47 +++++++++++
|
||||
core/src/conduit/usermsg.cpp | 147 ++++++++++++++++++++++++++++++++++
|
||||
core/src/conduit/usermsg.h | 23 ++++++
|
||||
core/src/plugin.cpp | 16 ++++
|
||||
docs/linux-bringup.md | 76 +++++++++++++++---
|
||||
gamedata/core.json | 15 +++-
|
||||
```
|
||||
|
||||
Working tree after the run:
|
||||
|
||||
```text
|
||||
## main...origin/main
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Command:
|
||||
|
||||
```sh
|
||||
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||
-DCMAKE_C_COMPILER=gcc-12 \
|
||||
-DCMAKE_CXX_COMPILER=g++-12
|
||||
cmake --build /root/codex/Conduit/build/linux-rerun
|
||||
```
|
||||
|
||||
Result: build and link succeeded.
|
||||
|
||||
```text
|
||||
-- Configuring done
|
||||
-- Generating done
|
||||
-- Build files have been written to: /root/codex/Conduit/build/linux-rerun
|
||||
[1/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/timers.cpp.o
|
||||
[2/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o
|
||||
[3/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/usermsg.cpp.o
|
||||
[4/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/console.cpp.o
|
||||
[5/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/chat.cpp.o
|
||||
[6/7] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o
|
||||
[7/7] Linking CXX shared library package/addons/conduit/bin/conduit.so
|
||||
```
|
||||
|
||||
Only warning observed:
|
||||
|
||||
```text
|
||||
/root/codex/Conduit/core/src/conduit/commands.cpp:521:13: warning: compound assignment with volatile-qualified left operand is deprecated [-Wvolatile]
|
||||
```
|
||||
|
||||
No failed CMake/Ninja output exists because the build passed.
|
||||
|
||||
## Deploy And Load
|
||||
|
||||
Deployed:
|
||||
|
||||
```text
|
||||
/root/codex/Conduit/build/linux-rerun/package/.
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```text
|
||||
/var/lib/gamepanel/servers/c2b47a90/game/csgo/
|
||||
```
|
||||
|
||||
Load checks:
|
||||
|
||||
```text
|
||||
--- meta list ---
|
||||
Listing 1 plugin:
|
||||
[01] Conduit (0.1.0-dev) by Conduit Contributors
|
||||
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 73.6 s
|
||||
game frames : 3983
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260615.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
```
|
||||
|
||||
Gamedata and usermessage function resolution:
|
||||
|
||||
```text
|
||||
--- conduit_gamedata ---
|
||||
gamedata: 1 files, 2 signatures, 0 offsets
|
||||
[ok] UTIL_SayText2Filter server 0x7fd2de250b30 (core.json)
|
||||
[ok] UTIL_SayTextFilter server 0x7fd2de2508d0 (core.json)
|
||||
|
||||
--- conduit_usermsg ---
|
||||
[Conduit] [INFO] usermsg: chat sending live via UTIL_SayTextFilter (0x7fd2de2508d0) - plain text, no color yet
|
||||
usermessages: chat sending live (SayText2 resolved, SayText resolved)
|
||||
```
|
||||
|
||||
Startup log:
|
||||
|
||||
```text
|
||||
2026-06-15 10:50:26 [INFO] gamedata: 1 files loaded: 2 signatures, 0 offsets, all valid
|
||||
2026-06-15 10:50:27 [INFO] entity: armed via CGameEntitySystem vtable 0x7fd2dec4ffd8
|
||||
2026-06-15 10:50:27 [INFO] gameevents: armed via CGameEventManager vtable 0x7fd2dec50f50
|
||||
2026-06-15 10:50:27 [INFO] chat: ready - hook installs on first subscriber
|
||||
2026-06-15 10:50:27 [INFO] core: Conduit 0.1.0-dev loaded - crash reporter armed, profiler on
|
||||
2026-06-15 10:50:27 [INFO] gameevents: game event manager connected (0x7fd2defd0460)
|
||||
2026-06-15 10:50:37 [INFO] core: first GameFrame observed - hook dispatch confirmed
|
||||
```
|
||||
|
||||
Entity system still resolves on Linux at `GameResourceServiceServerV001+0x50`:
|
||||
|
||||
```text
|
||||
--- conduit_entity ---
|
||||
[Conduit] [INFO] entity: entity system connected (0x7fd2da446000) via GameResourceServiceServerV001+0x50 - vtable matches CGameEntitySystem
|
||||
entity system: connected
|
||||
slot 0 cs_player_controller team=2 pawn=player health=100
|
||||
slot 1 cs_player_controller team=3 pawn=player health=100
|
||||
```
|
||||
|
||||
## Timer Tests
|
||||
|
||||
Schedule/list:
|
||||
|
||||
```text
|
||||
--- conduit_timer_after 2 ---
|
||||
[Conduit] one-shot timer #1 scheduled in 2.00 s
|
||||
--- conduit_timer_every 1 ---
|
||||
[Conduit] repeating timer #2 every 1.00 s (stops after 5)
|
||||
--- conduit_timers ---
|
||||
timers: 2 active
|
||||
#1 owner=timer_test once next in 1938 ms
|
||||
#2 owner=timer_test repeat next in 969 ms
|
||||
```
|
||||
|
||||
Log block:
|
||||
|
||||
```text
|
||||
2026-06-15 10:51:55 [INFO] timer_test: repeat timer tick 1
|
||||
2026-06-15 10:51:56 [INFO] timer_test: one-shot timer fired
|
||||
2026-06-15 10:51:56 [INFO] timer_test: repeat timer tick 2
|
||||
2026-06-15 10:51:57 [INFO] timer_test: repeat timer tick 3
|
||||
2026-06-15 10:51:58 [INFO] timer_test: repeat timer tick 4
|
||||
2026-06-15 10:51:59 [INFO] timer_test: repeat timer tick 5
|
||||
2026-06-15 10:51:59 [INFO] timer_test: repeat timer self-cancelled after 5 ticks
|
||||
```
|
||||
|
||||
After firing:
|
||||
|
||||
```text
|
||||
--- conduit_timers ---
|
||||
timers: 0 active
|
||||
--- conduit_prof ---
|
||||
timer_test timer 6 71.5us 131.1us 131.1us 101.4us 429.0us
|
||||
```
|
||||
|
||||
Group cancel:
|
||||
|
||||
```text
|
||||
--- conduit_timer_every 1 ---
|
||||
[Conduit] repeating timer #3 every 1.00 s (stops after 5)
|
||||
--- conduit_timer_after 30 ---
|
||||
[Conduit] one-shot timer #4 scheduled in 30.00 s
|
||||
--- conduit_timers ---
|
||||
timers: 2 active
|
||||
#3 owner=timer_test repeat next in 937 ms
|
||||
#4 owner=timer_test once next in 29969 ms
|
||||
--- conduit_timer_stop ---
|
||||
[Conduit] cancelled 2 test timer(s)
|
||||
--- conduit_timers ---
|
||||
timers: 0 active
|
||||
```
|
||||
|
||||
Result: timers passed, including self-cancel from inside callback and owner group cancel.
|
||||
|
||||
## Chat Interception Tests
|
||||
|
||||
RCON output:
|
||||
|
||||
```text
|
||||
--- conduit_chat ---
|
||||
chat: 0 subscriber(s), interception hook off
|
||||
--- conduit_chat_listen ---
|
||||
[Conduit] [INFO] chat: 'chat_test' subscribed to chat
|
||||
[Conduit] listening to chat
|
||||
--- conduit_chat ---
|
||||
chat: 1 subscriber(s), interception hook on
|
||||
owner=chat_test hits=0
|
||||
--- say "hello from server" ---
|
||||
[Conduit] [INFO] chat_test: say slot=-1: "hello from server"
|
||||
[All Chat][Console (0)]: "hello from server"
|
||||
L 06/15/2026 - 10:53:03: "Console<0>" say ""hello from server""
|
||||
--- say "this has badword inside" ---
|
||||
[Conduit] [INFO] chat_test: say slot=-1: "this has badword inside" [SUPPRESSED]
|
||||
--- say_team "x" ---
|
||||
[Conduit] [INFO] chat_test: say_team slot=-1: "x"
|
||||
--- conduit_prof ---
|
||||
chat_test chat 3 97.0us 131.1us 131.1us 127.4us 290.9us
|
||||
```
|
||||
|
||||
Conduit log:
|
||||
|
||||
```text
|
||||
2026-06-15 10:53:03 [INFO] chat: 'chat_test' subscribed to chat
|
||||
2026-06-15 10:53:03 [INFO] chat_test: say slot=-1: "hello from server"
|
||||
2026-06-15 10:53:03 [INFO] chat_test: say slot=-1: "this has badword inside" [SUPPRESSED]
|
||||
2026-06-15 10:53:03 [INFO] chat_test: say_team slot=-1: "x"
|
||||
```
|
||||
|
||||
The suppressed `badword` message did not echo as `[All Chat]` in the RCON output.
|
||||
|
||||
Cleanup:
|
||||
|
||||
```text
|
||||
--- conduit_chat_stop ---
|
||||
[Conduit] removed 1 chat subscriber(s)
|
||||
--- conduit_chat ---
|
||||
chat: 0 subscriber(s), interception hook off
|
||||
--- say "after stop visible" ---
|
||||
[All Chat][Console (0)]: "after stop visible"
|
||||
L 06/15/2026 - 10:53:20: "Console<0>" say ""after stop visible""
|
||||
--- conduit_chat ---
|
||||
chat: 0 subscriber(s), interception hook off
|
||||
```
|
||||
|
||||
Result: chat interception, suppression, say_team distinction, profiler scope, and hook removal all passed.
|
||||
|
||||
## Chat Sending / UserMessages
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
--- conduit_usermsg ---
|
||||
usermessages: chat sending live (SayText2 resolved, SayText resolved)
|
||||
--- conduit_say linux broadcast test ---
|
||||
[Conduit] PrintToAll sent
|
||||
--- conduit_say_player 0 private_ping ---
|
||||
[Conduit] PrintToPlayer(0) sent
|
||||
--- conduit_usermsg ---
|
||||
usermessages: chat sending live (SayText2 resolved, SayText resolved)
|
||||
```
|
||||
|
||||
Result: Linux signatures resolve and native send calls return `sent`. This verifies the server-side path and no crash. I cannot visually confirm client rendering from this environment; per latest plan this path sends plain white text through `UTIL_SayTextFilter`.
|
||||
|
||||
## Console Adopt Fix
|
||||
|
||||
This re-tested the previously observed adopted-convar listing issue.
|
||||
|
||||
```text
|
||||
--- conduit_console ---
|
||||
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
|
||||
[Conduit] [INFO] console: convar 'conduit_demo_value' created (owner 'console_demo')
|
||||
console: ready, 1 command(s), 1 plugin convar(s)
|
||||
cmd conduit_demo owner=console_demo
|
||||
cvar conduit_demo_value owner=console_demo value=7
|
||||
--- conduit_cvar conduit_demo_value 42 ---
|
||||
[Conduit] set 'conduit_demo_value' = '42'
|
||||
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
|
||||
--- conduit_concmd_stop ---
|
||||
[Conduit] removed 2 demo registration(s)
|
||||
--- conduit_console ---
|
||||
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
|
||||
[Conduit] [INFO] console: convar 'conduit_demo_value' already registered - adopting (owner 'console_demo')
|
||||
console: ready, 1 command(s), 1 plugin convar(s)
|
||||
cmd conduit_demo owner=console_demo
|
||||
cvar conduit_demo_value owner=console_demo value=42
|
||||
```
|
||||
|
||||
Final cleanup:
|
||||
|
||||
```text
|
||||
--- conduit_concmd_stop ---
|
||||
[Conduit] removed 2 demo registration(s)
|
||||
--- conduit_demo x ---
|
||||
|
||||
```
|
||||
|
||||
Result: adopt tracking is fixed. Adopted convar now appears under `1 plugin convar(s)` and owner cleanup removes 2 registrations.
|
||||
|
||||
## Final Runtime State
|
||||
|
||||
```text
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 248.6 s
|
||||
game frames : 15184
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260615.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
--- conduit_timers ---
|
||||
timers: 0 active
|
||||
--- conduit_chat ---
|
||||
chat: 0 subscriber(s), interception hook off
|
||||
--- conduit_events ---
|
||||
game events: manager connected, 0 subscription(s), 0 event(s) registered, FireEvent detour off
|
||||
--- conduit_usermsg ---
|
||||
usermessages: chat sending live (SayText2 resolved, SayText resolved)
|
||||
```
|
||||
|
||||
Final profiler:
|
||||
|
||||
```text
|
||||
engine GameFrame 13848 2.77ms 4.19ms 8.39ms 39.34ms 38.34s
|
||||
timer_test timer 6 71.5us 131.1us 131.1us 101.4us 429.0us
|
||||
chat_test chat 3 97.0us 131.1us 131.1us 127.4us 290.9us
|
||||
core SchemaFindField 3 13.2us 8.2us 32.8us 31.7us 39.5us
|
||||
```
|
||||
|
||||
## Final Server State
|
||||
|
||||
Docker:
|
||||
|
||||
```text
|
||||
/gp_c2b47a90 running running=true exit=0 oom=false
|
||||
/gp_d8f13411 exited running=false exit=130 oom=false
|
||||
```
|
||||
|
||||
Panel DB:
|
||||
|
||||
```text
|
||||
uuid | name | status | slug | port
|
||||
----------+----------------+---------+--------------+-------
|
||||
c2b47a90 | Test Sunucusu | running | cs2 | 27015
|
||||
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- No Conduit/Metamod/signal-specific load failure or crash was observed.
|
||||
- Server console contained ordinary CS2/Steam/map warnings and warmup long-frame messages.
|
||||
- No watchdog stall block appeared during this run.
|
||||
- CS2 panel status was `stopped` after Docker restart; I updated only the CS2 row back to `running`. Satisfactory remained `stopped`.
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# Conduit Linux Bring-up Report - 2026-06-13
|
||||
|
||||
Target server:
|
||||
- CS2 container: `gp_c2b47a90`
|
||||
- CS2 server UUID: `c2b47a90`
|
||||
- CS2 name: `Test Sunucusu`
|
||||
- Satisfactory container: `gp_d8f13411` (left untouched)
|
||||
|
||||
Artifacts copied next to this report:
|
||||
- `conduit-20260613.log`
|
||||
- `conduit-crash-20260613-113458.txt`
|
||||
|
||||
Final service state after crash test recovery:
|
||||
|
||||
```text
|
||||
/gp_c2b47a90 running running=true exit=0 oom=false
|
||||
/gp_d8f13411 exited running=false exit=130 oom=false
|
||||
```
|
||||
|
||||
Panel DB state after recovery:
|
||||
|
||||
```text
|
||||
uuid | name | status | slug | port
|
||||
----------+----------------+---------+--------------+-------
|
||||
c2b47a90 | Test Sunucusu | running | cs2 | 27015
|
||||
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
## 1. Build Result
|
||||
|
||||
Final build succeeded using GCC 12 in `build/linux-gcc12`.
|
||||
|
||||
Final link output:
|
||||
|
||||
```text
|
||||
[27/27] Linking CXX shared library package/addons/conduit/bin/conduit.so
|
||||
```
|
||||
|
||||
Build/deploy issues encountered and fixed:
|
||||
- Ubuntu `clang++` was 14.0.0 and rejected `-std=c++23`.
|
||||
- `hl2sdk` headers needed POSIX aliases for `stricmp`/`strcmpi` during this Linux build.
|
||||
- `crash_handler_linux.cpp` needed `cstdarg` visible for `va_start`/`va_end`; handled via build flag.
|
||||
- `watchdog.cpp` had a Linux compile error because glibc `SIGRTMIN` is runtime, not constexpr. Local source change:
|
||||
|
||||
```diff
|
||||
-constexpr int kSampleSignal = SIGRTMIN + 4;
|
||||
+const int kSampleSignal = SIGRTMIN + 4;
|
||||
```
|
||||
|
||||
- First deployed build failed to load in Metamod with protobuf ABI mismatch:
|
||||
|
||||
```text
|
||||
[META] Failed to load plugin addons/conduit/bin/conduit: /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so: undefined symbol: _ZNK6google8protobuf7Message11GetTypeNameB5cxx11Ev
|
||||
```
|
||||
|
||||
Rebuilt with `_GLIBCXX_USE_CXX11_ABI=0`; Conduit then loaded successfully.
|
||||
|
||||
Final configure command used:
|
||||
|
||||
```sh
|
||||
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-gcc12 -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||
-DCMAKE_C_COMPILER=gcc-12 \
|
||||
-DCMAKE_CXX_COMPILER=g++-12 \
|
||||
"-DCMAKE_CXX_FLAGS=-include strings.h -include cstdarg -Dstricmp=strcasecmp -Dstrcmpi=strcasecmp -D_GLIBCXX_USE_CXX11_ABI=0"
|
||||
```
|
||||
|
||||
## 2. meta list
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- meta list ---
|
||||
Listing 1 plugin:
|
||||
[01] Conduit (0.1.0-dev) by Conduit Contributors
|
||||
```
|
||||
|
||||
## 3. conduit_status + conduit_prof
|
||||
|
||||
```text
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 3384.6 s
|
||||
game frames : 215969
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260613.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
--- conduit_prof ---
|
||||
owner callsite calls avg p50 p99 max total
|
||||
engine GameFrame 215971 2.36ms 4.19ms 4.19ms 34.46ms 510.40s
|
||||
stall_test_plugin conduit_stall_test 1 1.50s 2.15s 2.15s 1.50s 1.50s
|
||||
core SchemaFindField 2 98.4us 262.1us 262.1us 164.3us 196.7us
|
||||
```
|
||||
|
||||
After the deliberate crash test and restart, Conduit loaded again:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- meta list ---
|
||||
Listing 1 plugin:
|
||||
[01] Conduit (0.1.0-dev) by Conduit Contributors
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 130.2 s
|
||||
game frames : 7760
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260613.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
```
|
||||
|
||||
## 4. conduit_schema
|
||||
|
||||
```text
|
||||
--- conduit_schema CCSPlayerPawn m_iHealth ---
|
||||
CCSPlayerPawn::m_iHealth -> offset 0x5B0 (1456), type int32
|
||||
--- conduit_schema CBaseEntity m_iTeamNum ---
|
||||
CBaseEntity::m_iTeamNum -> offset 0x624 (1572), type uint8
|
||||
```
|
||||
|
||||
Comparison with Windows reference from the bring-up doc:
|
||||
- `CCSPlayerPawn::m_iHealth`: Linux `0x5B0`, Windows expected `0x2D0` -> mismatch.
|
||||
- `CBaseEntity::m_iTeamNum`: Linux `0x624`, Windows expected `0x344` -> mismatch.
|
||||
|
||||
## 5. conduit_stall_test 1500 Watchdog Block
|
||||
|
||||
RCON command:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_stall_test 1500 ---
|
||||
[Conduit] stalling the game thread for 1500 ms...
|
||||
```
|
||||
|
||||
Conduit log block:
|
||||
|
||||
```text
|
||||
2026-06-13 10:35:31 [WARN] watchdog: game thread has not advanced a frame for 591 ms (outside GameFrame)
|
||||
2026-06-13 10:35:31 [WARN] watchdog: context 1: owner=stall_test_plugin callsite=conduit_stall_test
|
||||
2026-06-13 10:35:31 [WARN] watchdog: >>> most likely culprit: 'stall_test_plugin' (in 'conduit_stall_test')
|
||||
2026-06-13 10:35:31 [WARN] watchdog: #00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xc95c9) [0x7fdf9e69e5c9]
|
||||
2026-06-13 10:35:31 [WARN] watchdog: #01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050]
|
||||
2026-06-13 10:35:31 [WARN] watchdog: #02 /lib/x86_64-linux-gnu/libc.so.6(clock_nanosleep+0x65) [0x7fdfefae4545]
|
||||
2026-06-13 10:35:31 [WARN] watchdog: #03 /lib/x86_64-linux-gnu/libc.so.6(nanosleep+0x13) [0x7fdfefae8e53]
|
||||
2026-06-13 10:35:31 [WARN] watchdog: #04 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf703) [0x7fdf9e694703]
|
||||
2026-06-13 10:35:31 [WARN] watchdog: #05 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902]
|
||||
2026-06-13 10:35:32 [INFO] watchdog: game thread recovered after a ~1586 ms stall
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Attribution worked.
|
||||
- Stack sample landed.
|
||||
- Symbol quality is partial: `libconduit.so(+0x...)` offsets are present, but not function names such as `conduit_stall_test`.
|
||||
|
||||
## 6. conduit_crash_test Report
|
||||
|
||||
RCON command result:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_crash_test ---
|
||||
(no response: connection closed)
|
||||
```
|
||||
|
||||
Generated file:
|
||||
|
||||
```text
|
||||
/var/lib/gamepanel/servers/c2b47a90/game/csgo/addons/conduit/crashes/conduit-crash-20260613-113458.txt
|
||||
```
|
||||
|
||||
Crash report content:
|
||||
|
||||
```text
|
||||
=== CONDUIT CRASH REPORT ==========================================
|
||||
Framework : Conduit 0.1.0-dev
|
||||
Time : 2026-06-13 11:34:58 (local)
|
||||
Process : pid=36
|
||||
Signal : 11 (Segmentation fault), fault address (nil)
|
||||
|
||||
--- ATTRIBUTION ---------------------------------------------------
|
||||
1. owner=crash_test_plugin callsite=conduit_crash_test
|
||||
>>> Most likely culprit: 'crash_test_plugin' (in 'conduit_crash_test')
|
||||
|
||||
--- STACK TRACE ---------------------------------------------------
|
||||
#00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbfb64) [0x7fdf9e694b64]
|
||||
#01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050]
|
||||
#02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf66c) [0x7fdf9e69466c]
|
||||
#03 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902]
|
||||
|
||||
--- RECENT EVENTS (oldest first) ----------------------------------
|
||||
[-1767.736s] [INFO] perf: heartbeat: 1921 frames, avg 2.22 ms, max 20.85 ms (last 30 s)
|
||||
[-1737.735s] [INFO] perf: heartbeat: 1920 frames, avg 2.16 ms, max 5.31 ms (last 30 s)
|
||||
[-1707.720s] [INFO] perf: heartbeat: 1921 frames, avg 2.12 ms, max 4.39 ms (last 30 s)
|
||||
[-1677.719s] [INFO] perf: heartbeat: 1920 frames, avg 2.20 ms, max 5.59 ms (last 30 s)
|
||||
[-1647.706s] [INFO] perf: heartbeat: 1921 frames, avg 2.39 ms, max 23.56 ms (last 30 s)
|
||||
[-1617.705s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 7.46 ms (last 30 s)
|
||||
[-1587.691s] [INFO] perf: heartbeat: 1921 frames, avg 2.37 ms, max 8.58 ms (last 30 s)
|
||||
[-1557.680s] [INFO] perf: heartbeat: 1921 frames, avg 2.33 ms, max 4.84 ms (last 30 s)
|
||||
[-1527.679s] [INFO] perf: heartbeat: 1920 frames, avg 2.25 ms, max 5.09 ms (last 30 s)
|
||||
[-1497.674s] [INFO] perf: heartbeat: 1920 frames, avg 2.18 ms, max 19.43 ms (last 30 s)
|
||||
[-1467.673s] [INFO] perf: heartbeat: 1920 frames, avg 2.30 ms, max 6.87 ms (last 30 s)
|
||||
[-1437.669s] [INFO] perf: heartbeat: 1920 frames, avg 2.23 ms, max 34.46 ms (last 30 s)
|
||||
[-1407.668s] [INFO] perf: heartbeat: 1920 frames, avg 2.45 ms, max 6.17 ms (last 30 s)
|
||||
[-1377.653s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.25 ms (last 30 s)
|
||||
[-1347.653s] [INFO] perf: heartbeat: 1920 frames, avg 2.53 ms, max 5.44 ms (last 30 s)
|
||||
[-1317.644s] [INFO] perf: heartbeat: 1921 frames, avg 2.51 ms, max 20.77 ms (last 30 s)
|
||||
[-1287.631s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.45 ms (last 30 s)
|
||||
[-1257.614s] [INFO] perf: heartbeat: 1921 frames, avg 2.57 ms, max 5.90 ms (last 30 s)
|
||||
[-1227.600s] [INFO] perf: heartbeat: 1921 frames, avg 2.54 ms, max 5.79 ms (last 30 s)
|
||||
[-1197.583s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 6.94 ms (last 30 s)
|
||||
[-1167.578s] [INFO] perf: heartbeat: 1920 frames, avg 2.46 ms, max 20.36 ms (last 30 s)
|
||||
[-1137.562s] [INFO] perf: heartbeat: 1921 frames, avg 2.47 ms, max 7.86 ms (last 30 s)
|
||||
[-1107.548s] [INFO] perf: heartbeat: 1921 frames, avg 2.53 ms, max 6.38 ms (last 30 s)
|
||||
[-1077.547s] [INFO] perf: heartbeat: 1920 frames, avg 2.54 ms, max 4.09 ms (last 30 s)
|
||||
[-1047.543s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 33.69 ms (last 30 s)
|
||||
[-1017.532s] [INFO] perf: heartbeat: 1921 frames, avg 2.44 ms, max 5.97 ms (last 30 s)
|
||||
[- 987.516s] [INFO] perf: heartbeat: 1921 frames, avg 2.59 ms, max 5.71 ms (last 30 s)
|
||||
[- 957.501s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 7.43 ms (last 30 s)
|
||||
[- 927.501s] [INFO] perf: heartbeat: 1920 frames, avg 2.60 ms, max 9.17 ms (last 30 s)
|
||||
[- 897.490s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 24.84 ms (last 30 s)
|
||||
[- 867.475s] [INFO] perf: heartbeat: 1921 frames, avg 2.58 ms, max 6.20 ms (last 30 s)
|
||||
[- 837.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 5.57 ms (last 30 s)
|
||||
[- 807.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 6.15 ms (last 30 s)
|
||||
[- 777.460s] [INFO] perf: heartbeat: 1921 frames, avg 2.70 ms, max 5.47 ms (last 30 s)
|
||||
[- 747.449s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 24.22 ms (last 30 s)
|
||||
[- 717.434s] [INFO] perf: heartbeat: 1921 frames, avg 2.80 ms, max 8.64 ms (last 30 s)
|
||||
[- 687.419s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.28 ms (last 30 s)
|
||||
[- 657.418s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 6.34 ms (last 30 s)
|
||||
[- 627.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 31.89 ms (last 30 s)
|
||||
[- 597.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 6.72 ms (last 30 s)
|
||||
[- 567.400s] [INFO] perf: heartbeat: 1921 frames, avg 2.67 ms, max 6.53 ms (last 30 s)
|
||||
[- 537.384s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.66 ms (last 30 s)
|
||||
[- 507.369s] [INFO] perf: heartbeat: 1921 frames, avg 2.55 ms, max 5.30 ms (last 30 s)
|
||||
[- 477.365s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 34.05 ms (last 30 s)
|
||||
[- 447.364s] [INFO] perf: heartbeat: 1920 frames, avg 2.59 ms, max 5.42 ms (last 30 s)
|
||||
[- 417.349s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.55 ms (last 30 s)
|
||||
[- 387.334s] [INFO] perf: heartbeat: 1921 frames, avg 2.34 ms, max 4.63 ms (last 30 s)
|
||||
[- 357.334s] [INFO] perf: heartbeat: 1920 frames, avg 2.77 ms, max 6.65 ms (last 30 s)
|
||||
[- 327.332s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 26.14 ms (last 30 s)
|
||||
[- 312.170s] detour 'detour_self_test' installed at 0x7fdf9e693cd0
|
||||
[- 312.170s] [INFO] hooks: detour 'detour_self_test' installed at 0x7fdf9e693cd0
|
||||
[- 312.169s] detour 'detour_self_test' removed
|
||||
[- 312.169s] [INFO] hooks: detour 'detour_self_test' removed
|
||||
[- 297.318s] [INFO] perf: heartbeat: 1921 frames, avg 2.73 ms, max 6.96 ms (last 30 s)
|
||||
[- 267.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.58 ms, max 6.68 ms (last 30 s)
|
||||
[- 237.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 7.08 ms (last 30 s)
|
||||
[- 207.296s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 25.68 ms (last 30 s)
|
||||
[- 177.293s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 5.12 ms (last 30 s)
|
||||
[- 147.280s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.80 ms (last 30 s)
|
||||
[- 117.280s] [INFO] perf: heartbeat: 1920 frames, avg 2.64 ms, max 6.24 ms (last 30 s)
|
||||
[- 87.265s] [INFO] perf: heartbeat: 1921 frames, avg 2.78 ms, max 5.95 ms (last 30 s)
|
||||
[- 57.263s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 31.12 ms (last 30 s)
|
||||
[- 27.248s] [INFO] perf: heartbeat: 1921 frames, avg 2.72 ms, max 7.34 ms (last 30 s)
|
||||
[- 0.001s] [WARN] core: conduit_crash_test invoked — crashing deliberately
|
||||
===================================================================
|
||||
```
|
||||
|
||||
## 7. Conduit Log File
|
||||
|
||||
Original log file:
|
||||
|
||||
```text
|
||||
/var/lib/gamepanel/servers/c2b47a90/game/csgo/addons/conduit/logs/conduit-20260613.log
|
||||
```
|
||||
|
||||
Copied artifact:
|
||||
|
||||
```text
|
||||
/root/codex/source-gamepanel/conduit-bringup-artifacts/conduit-20260613.log
|
||||
```
|
||||
|
||||
Relevant load lines:
|
||||
|
||||
```text
|
||||
2026-06-13 10:33:21 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid
|
||||
2026-06-13 10:33:21 [INFO] core: Conduit 0.1.0-dev loaded — crash reporter armed, profiler on
|
||||
2026-06-13 10:33:29 [INFO] core: first GameFrame observed — hook dispatch confirmed
|
||||
```
|
||||
|
||||
## 8. Console / Metamod / Signal Warnings
|
||||
|
||||
Important console findings:
|
||||
|
||||
1. Before the ABI rebuild, Metamod failed to load Conduit:
|
||||
|
||||
```text
|
||||
[META] Failed to load plugin addons/conduit/bin/conduit: /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so: undefined symbol: _ZNK6google8protobuf7Message11GetTypeNameB5cxx11Ev
|
||||
[META] Loaded 0 plugins.
|
||||
```
|
||||
|
||||
This was fixed by rebuilding with `_GLIBCXX_USE_CXX11_ABI=0`.
|
||||
|
||||
2. After the successful build, no current Metamod load failure was observed. `meta list` reports Conduit loaded.
|
||||
|
||||
3. Signal/watchdog behavior:
|
||||
- No signal clash symptoms observed.
|
||||
- No `(stack sample timed out)` observed.
|
||||
- Stack sample appeared, but symbol names are mostly raw offsets for `libconduit.so`.
|
||||
|
||||
4. Crash test console tail showed the expected deliberate crash:
|
||||
|
||||
```text
|
||||
post-hook: noop
|
||||
entry.sh: line 192: 36 Segmentation fault (core dumped) ./cs2 -dedicated -ip 0.0.0.0 -port 27015 -console -usercon -maxplayers 16 +game_type 0 +game_mode 1 +mapgroup mg_active +map de_dust2 +rcon_password changeme +sv_lan 0 +tv_port 27020 -insecure
|
||||
```
|
||||
|
||||
Other engine/game warnings seen, not clearly Conduit-specific:
|
||||
|
||||
```text
|
||||
Failed loading resource "maps/prefabs/misc/terrorist_team_intro_variant2/world_visibility.vvis_c" (ERROR_FILEOPEN: File not found)
|
||||
Failed to write backup_round15.txt!
|
||||
UNEXPECTED LONG FRAME DETECTED
|
||||
```
|
||||
|
||||
## Additional Findings
|
||||
|
||||
`conduit_dumpbytes` found `server!CreateInterface`:
|
||||
|
||||
```text
|
||||
[Conduit] server!CreateInterface @ 0x7fdfaa682220
|
||||
55 48 89 E5 41 55 49 89 F5 41 54 53 48 83 EC 08
|
||||
```
|
||||
|
||||
But scanning the same pattern failed:
|
||||
|
||||
```text
|
||||
[Conduit] no match in server (285312 bytes scanned)
|
||||
```
|
||||
|
||||
This looks like a Linux memscan bug or module range issue: export lookup can locate `CreateInterface`, but `ScanPattern` does not find the bytes at that address.
|
||||
|
||||
`conduit_detour_test` passed:
|
||||
|
||||
```text
|
||||
[Conduit] detour test: PASS (before=42 during=43 after=42; expected 42/43/42)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# Conduit Linux Console/ConVar Re-run - 2026-06-14
|
||||
|
||||
## Scope
|
||||
|
||||
- Pulled latest Conduit commits from `origin/main`.
|
||||
- Inspected the dynamic console command / convar diff and `docs/linux-bringup.md` test steps.
|
||||
- Rebuilt on Linux with GCC 12.
|
||||
- Deployed to CS2 server `Test Sunucusu`.
|
||||
- Verified dynamic command registration, dynamic command dispatch under profiler scope, plugin convar read/write, engine convar read/write, owner cleanup, and final server state.
|
||||
- Satisfactory server was not modified.
|
||||
|
||||
## Git
|
||||
|
||||
Latest commits:
|
||||
|
||||
```text
|
||||
bb58461 (HEAD -> main, origin/main, origin/HEAD) Phase 2: dynamic console commands + convar access
|
||||
a3bd08f Close out Linux verification of the Entity + Player slice
|
||||
de4bda2 Phase 2: schema-backed Entity + Player access, with event bridge
|
||||
4b79d33 Close out Linux verification of the Phase 2 GameEvents slice
|
||||
a5c1c13 docs: Linux re-test steps for the Phase 2 GameEvents slice
|
||||
ffb57f3 Phase 2 start: dispatch core + GameEvents, with an RTTI vtable finder
|
||||
```
|
||||
|
||||
Pulled range:
|
||||
|
||||
```text
|
||||
Updating de4bda2..bb58461
|
||||
Fast-forward
|
||||
PLAN.md | 4 +-
|
||||
core/CMakeLists.txt | 1 +
|
||||
core/src/conduit/commands.cpp | 52 ++++++++
|
||||
core/src/conduit/console.cpp | 302 ++++++++++++++++++++++++++++++++++++++++++
|
||||
core/src/conduit/console.h | 78 +++++++++++
|
||||
core/src/plugin.cpp | 5 +
|
||||
docs/linux-bringup.md | 58 ++++++--
|
||||
7 files changed, 491 insertions(+), 9 deletions(-)
|
||||
create mode 100644 core/src/conduit/console.cpp
|
||||
create mode 100644 core/src/conduit/console.h
|
||||
```
|
||||
|
||||
Working tree after the run:
|
||||
|
||||
```text
|
||||
## main...origin/main
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Command:
|
||||
|
||||
```sh
|
||||
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||
-DCMAKE_C_COMPILER=gcc-12 \
|
||||
-DCMAKE_CXX_COMPILER=g++-12
|
||||
cmake --build /root/codex/Conduit/build/linux-rerun
|
||||
```
|
||||
|
||||
Result: build and link succeeded.
|
||||
|
||||
```text
|
||||
-- Configuring done
|
||||
-- Generating done
|
||||
-- Build files have been written to: /root/codex/Conduit/build/linux-rerun
|
||||
[1/4] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o
|
||||
[2/4] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/console.cpp.o
|
||||
[3/4] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o
|
||||
[4/4] Linking CXX shared library package/addons/conduit/bin/conduit.so
|
||||
```
|
||||
|
||||
Only warning observed: the existing volatile compound-assignment warning in `commands.cpp`.
|
||||
|
||||
No failed CMake/Ninja output exists because the build passed. The Linux link path for `CConVar` / `ConVarRefAbstract` symbols passed.
|
||||
|
||||
## Deploy
|
||||
|
||||
Package source:
|
||||
|
||||
```text
|
||||
/root/codex/Conduit/build/linux-rerun/package/.
|
||||
```
|
||||
|
||||
Destination:
|
||||
|
||||
```text
|
||||
/var/lib/gamepanel/servers/c2b47a90/game/csgo/
|
||||
```
|
||||
|
||||
CS2 container restarted:
|
||||
|
||||
```text
|
||||
gp_c2b47a90
|
||||
```
|
||||
|
||||
Satisfactory was not touched:
|
||||
|
||||
```text
|
||||
/gp_d8f13411 exited running=false exit=130 oom=false
|
||||
```
|
||||
|
||||
## Load Check
|
||||
|
||||
RCON:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- meta list ---
|
||||
Listing 1 plugin:
|
||||
[01] Conduit (0.1.0-dev) by Conduit Contributors
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 47.4 s
|
||||
game frames : 2251
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
```
|
||||
|
||||
Startup log:
|
||||
|
||||
```text
|
||||
2026-06-14 14:19:24 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid
|
||||
2026-06-14 14:19:25 [INFO] entity: armed via CGameEntitySystem vtable 0x7f4c8640ffd8 - entity system not created yet, will resolve on first use after map load
|
||||
2026-06-14 14:19:25 [INFO] gameevents: armed via CGameEventManager vtable 0x7f4c86410f50 - instance captured on next LoadEventsFromFile
|
||||
2026-06-14 14:19:25 [INFO] core: Conduit 0.1.0-dev loaded - crash reporter armed, profiler on
|
||||
2026-06-14 14:19:25 [INFO] gameevents: game event manager connected (0x7f4c86790460) - event listening live
|
||||
2026-06-14 14:19:36 [INFO] core: first GameFrame observed - hook dispatch confirmed
|
||||
```
|
||||
|
||||
Result: plugin loaded, frames advanced, no Conduit/Metamod load failure.
|
||||
|
||||
## Dynamic Command and Profiler
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
conduit_console
|
||||
conduit_demo alpha bravo charlie
|
||||
conduit_prof
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_console ---
|
||||
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
|
||||
[Conduit] [INFO] console: convar 'conduit_demo_value' created (owner 'console_demo')
|
||||
console: ready, 1 command(s), 1 plugin convar(s)
|
||||
cmd conduit_demo owner=console_demo
|
||||
cvar conduit_demo_value owner=console_demo value=7
|
||||
--- conduit_demo alpha bravo charlie ---
|
||||
[Conduit] [INFO] console_demo: conduit_demo invoked: argc=4 args='alpha bravo charlie' issuer=server(-1)
|
||||
--- conduit_prof ---
|
||||
owner callsite calls avg p50 p99 max total
|
||||
engine GameFrame 3409 2.53ms 4.19ms 8.39ms 47.48ms 8.62s
|
||||
console_demo conduit_demo 1 63.1us 65.5us 65.5us 63.1us 63.1us
|
||||
```
|
||||
|
||||
Result: dynamic command dispatch works and is profiled under `console_demo / conduit_demo`.
|
||||
|
||||
## ConVar Tests
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
conduit_cvar conduit_demo_value
|
||||
conduit_cvar conduit_demo_value 42
|
||||
conduit_cvar conduit_demo_value
|
||||
conduit_cvar sv_cheats
|
||||
conduit_cvar sv_gravity
|
||||
conduit_cvar sv_gravity 700
|
||||
conduit_cvar sv_gravity
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_cvar conduit_demo_value ---
|
||||
[Conduit] conduit_demo_value = 7 (int=7 float=7.000 bool=1)
|
||||
--- conduit_cvar conduit_demo_value 42 ---
|
||||
[Conduit] set 'conduit_demo_value' = '42'
|
||||
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
|
||||
--- conduit_cvar conduit_demo_value ---
|
||||
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
|
||||
--- conduit_cvar sv_cheats ---
|
||||
[Conduit] sv_cheats = false (int=0 float=0.000 bool=0)
|
||||
--- conduit_cvar sv_gravity ---
|
||||
[Conduit] sv_gravity = 800.000000 (int=800 float=800.000 bool=1)
|
||||
--- conduit_cvar sv_gravity 700 ---
|
||||
L 06/14/2026 - 14:20:43: server_cvar: "sv_gravity" "700.000000"
|
||||
[Conduit] set 'sv_gravity' = '700'
|
||||
[Conduit] sv_gravity = 700.000000 (int=700 float=700.000 bool=1)
|
||||
--- conduit_cvar sv_gravity ---
|
||||
[Conduit] sv_gravity = 700.000000 (int=700 float=700.000 bool=1)
|
||||
```
|
||||
|
||||
Result: plugin-owned convar read/write passed. Engine convar read/write passed.
|
||||
|
||||
`sv_gravity` was restored:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_cvar sv_gravity 800 ---
|
||||
L 06/14/2026 - 14:20:56: server_cvar: "sv_gravity" "800.000000"
|
||||
[Conduit] set 'sv_gravity' = '800'
|
||||
[Conduit] sv_gravity = 800.000000 (int=800 float=800.000 bool=1)
|
||||
--- conduit_cvar sv_gravity ---
|
||||
[Conduit] sv_gravity = 800.000000 (int=800 float=800.000 bool=1)
|
||||
```
|
||||
|
||||
## Owner Cleanup
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
conduit_concmd_stop
|
||||
conduit_demo x
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
--- conduit_concmd_stop ---
|
||||
[Conduit] removed 2 demo registration(s)
|
||||
--- conduit_demo x ---
|
||||
|
||||
```
|
||||
|
||||
Result: `conduit_demo x` produced no handler line after cleanup, so the command was removed from the engine.
|
||||
|
||||
I then called `conduit_console` again to probe the persistence/adopt path:
|
||||
|
||||
```text
|
||||
--- conduit_console ---
|
||||
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
|
||||
[Conduit] [INFO] console: convar 'conduit_demo_value' already registered - adopting (owner 'console_demo')
|
||||
console: ready, 1 command(s), 0 plugin convar(s)
|
||||
cmd conduit_demo owner=console_demo
|
||||
```
|
||||
|
||||
The persisted convar value remained available:
|
||||
|
||||
```text
|
||||
--- conduit_cvar conduit_demo_value ---
|
||||
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
|
||||
```
|
||||
|
||||
Final cleanup after the re-register:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_cvar conduit_demo_value ---
|
||||
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
|
||||
--- conduit_concmd_stop ---
|
||||
[Conduit] removed 1 demo registration(s)
|
||||
--- conduit_demo x ---
|
||||
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 115.6 s
|
||||
game frames : 6615
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
--- conduit_prof ---
|
||||
owner callsite calls avg p50 p99 max total
|
||||
engine GameFrame 6617 2.34ms 4.19ms 8.39ms 47.48ms 15.50s
|
||||
console_demo conduit_demo 1 63.1us 65.5us 65.5us 63.1us 63.1us
|
||||
```
|
||||
|
||||
## Finding
|
||||
|
||||
The adopt path works for name-based access: `conduit_demo_value` stayed at `42` and could be read after re-registration.
|
||||
|
||||
Potential mismatch: after adoption, `conduit_console` reported `0 plugin convar(s)` and did not list `conduit_demo_value`, despite logging `already registered - adopting`. That means the existing convar is not represented in `g_convars`, so owner cleanup removed only the command on the second cleanup (`removed 1 demo registration(s)`). If the intended behavior is "adopted convars are tracked and listed as plugin-owned", this needs a small fix. If "adopt" only means "treat existing engine convar as accessible by name", the runtime behavior is consistent.
|
||||
|
||||
## Final State
|
||||
|
||||
Docker:
|
||||
|
||||
```text
|
||||
/gp_c2b47a90 running running=true exit=0 oom=false
|
||||
/gp_d8f13411 exited running=false exit=130 oom=false
|
||||
```
|
||||
|
||||
Panel DB:
|
||||
|
||||
```text
|
||||
uuid | name | status | slug | port
|
||||
----------+----------------+---------+--------------+-------
|
||||
c2b47a90 | Test Sunucusu | running | cs2 | 27015
|
||||
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
No Conduit/Metamod/signal-specific load failure or crash was observed. Server console had ordinary CS2/Steam/map warnings only.
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# Conduit Linux Entity Re-run - 2026-06-14
|
||||
|
||||
## Scope
|
||||
|
||||
- Pulled latest Conduit commits from `origin/main`.
|
||||
- Inspected the Phase 2 entity/event bridge diff and updated `docs/linux-bringup.md` steps.
|
||||
- Rebuilt on Linux with GCC 12.
|
||||
- Deployed to CS2 server `Test Sunucusu`.
|
||||
- Verified normal startup arming, lazy entity-system resolution, Linux RTTI vtable lookup, event-to-entity bridge, schema-backed health read/write, cleanup, and final server state.
|
||||
- Satisfactory server was not modified.
|
||||
|
||||
## Git
|
||||
|
||||
Latest commits:
|
||||
|
||||
```text
|
||||
de4bda2 (HEAD -> main, origin/main, origin/HEAD) Phase 2: schema-backed Entity + Player access, with event bridge
|
||||
4b79d33 Close out Linux verification of the Phase 2 GameEvents slice
|
||||
a5c1c13 docs: Linux re-test steps for the Phase 2 GameEvents slice
|
||||
ffb57f3 Phase 2 start: dispatch core + GameEvents, with an RTTI vtable finder
|
||||
4078ab7 PLAN: close the Linux memscan question — M1 fully done on both platforms
|
||||
```
|
||||
|
||||
Pulled range:
|
||||
|
||||
```text
|
||||
Updating a5c1c13..de4bda2
|
||||
Fast-forward
|
||||
PLAN.md | 4 +-
|
||||
core/CMakeLists.txt | 1 +
|
||||
core/src/conduit/commands.cpp | 61 ++++++-
|
||||
core/src/conduit/entity.cpp | 380 ++++++++++++++++++++++++++++++++++++++++
|
||||
core/src/conduit/entity.h | 89 ++++++++++
|
||||
core/src/conduit/gameevents.cpp | 9 +
|
||||
core/src/conduit/gameevents.h | 9 +
|
||||
core/src/conduit/memscan.cpp | 52 ++++++
|
||||
core/src/conduit/memscan.h | 6 +
|
||||
core/src/plugin.cpp | 5 +
|
||||
docs/linux-bringup.md | 53 +++++-
|
||||
```
|
||||
|
||||
Working tree after run:
|
||||
|
||||
```text
|
||||
## main...origin/main
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Command:
|
||||
|
||||
```sh
|
||||
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||
-DCMAKE_C_COMPILER=gcc-12 \
|
||||
-DCMAKE_CXX_COMPILER=g++-12
|
||||
cmake --build /root/codex/Conduit/build/linux-rerun
|
||||
```
|
||||
|
||||
Result: build succeeded.
|
||||
|
||||
```text
|
||||
-- Configuring done
|
||||
-- Generating done
|
||||
-- Build files have been written to: /root/codex/Conduit/build/linux-rerun
|
||||
[1/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/memscan.cpp.o
|
||||
[2/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o
|
||||
/root/codex/Conduit/core/src/conduit/commands.cpp: In function ‘int {anonymous}::DetourTestTarget(int)’:
|
||||
/root/codex/Conduit/core/src/conduit/commands.cpp:338:13: warning: compound assignment with ‘volatile’-qualified left operand is deprecated [-Wvolatile]
|
||||
338 | acc += x;
|
||||
| ~~~~^~~~
|
||||
[3/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/gamedata.cpp.o
|
||||
[4/7] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o
|
||||
[5/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/entity.cpp.o
|
||||
[6/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/gameevents.cpp.o
|
||||
[7/7] Linking CXX shared library package/addons/conduit/bin/conduit.so
|
||||
```
|
||||
|
||||
No failed CMake/Ninja output exists because the build passed.
|
||||
|
||||
## Deploy
|
||||
|
||||
Package source:
|
||||
|
||||
```text
|
||||
/root/codex/Conduit/build/linux-rerun/package/.
|
||||
```
|
||||
|
||||
Destination:
|
||||
|
||||
```text
|
||||
/var/lib/gamepanel/servers/c2b47a90/game/csgo/
|
||||
```
|
||||
|
||||
CS2 container restarted:
|
||||
|
||||
```text
|
||||
gp_c2b47a90
|
||||
```
|
||||
|
||||
Satisfactory container state during deploy:
|
||||
|
||||
```text
|
||||
/gp_d8f13411 exited running=false exit=130 oom=false
|
||||
```
|
||||
|
||||
## Load Check
|
||||
|
||||
RCON:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- meta list ---
|
||||
Listing 1 plugin:
|
||||
[01] Conduit (0.1.0-dev) by Conduit Contributors
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 45.8 s
|
||||
game frames : 2220
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
```
|
||||
|
||||
Startup log:
|
||||
|
||||
```text
|
||||
2026-06-14 13:48:50 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid
|
||||
2026-06-14 13:48:50 [INFO] entity: armed via CGameEntitySystem vtable 0x7f2eed759fd8 — entity system not created yet, will resolve on first use after map load
|
||||
2026-06-14 13:48:51 [INFO] gameevents: armed via CGameEventManager vtable 0x7f2eed75af50 — instance captured on next LoadEventsFromFile
|
||||
2026-06-14 13:48:51 [INFO] core: Conduit 0.1.0-dev loaded — crash reporter armed, profiler on
|
||||
2026-06-14 13:48:51 [INFO] gameevents: game event manager connected (0x7f2eedada460) — event listening live
|
||||
2026-06-14 13:49:01 [INFO] core: first GameFrame observed — hook dispatch confirmed
|
||||
```
|
||||
|
||||
Result: normal startup did not crash. Entity acquisition armed at load and deferred resolution until map/entity system was available.
|
||||
|
||||
## Entity RTTI and Lazy Resolve
|
||||
|
||||
RCON:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_vtable server CGameEntitySystem ---
|
||||
[Conduit] CGameEntitySystem vtable @ 0x7f2eed759fd8 first method -> 0x7f2eec92aec0
|
||||
--- conduit_entity ---
|
||||
[Conduit] [INFO] entity: entity system connected (0x7f2ee9124000) via GameResourceServiceServerV001+0x50 — vtable matches CGameEntitySystem
|
||||
entity system: connected
|
||||
slot 0 cs_player_controller team=2 pawn=player health=100
|
||||
slot 1 cs_player_controller team=3 pawn=player health=100
|
||||
```
|
||||
|
||||
Result: Linux Itanium RTTI path found `CGameEntitySystem`. Lazy resource-service scan found the entity system at `GameResourceServiceServerV001+0x50`. This differs from Windows `+0x58`, as expected, and was found by vtable matching.
|
||||
|
||||
## Event to Entity Bridge
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
conduit_event_stop
|
||||
conduit_event_listen player_spawn
|
||||
mp_warmup_end
|
||||
mp_restartgame 1
|
||||
```
|
||||
|
||||
RCON / console-reported Conduit lines:
|
||||
|
||||
```text
|
||||
[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre)
|
||||
[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post)
|
||||
[Conduit] listening to 'player_spawn' (pre+post) — watch the log
|
||||
[Conduit] [INFO] event_test: [pre] player_spawn userid=0 pawn=player idx=256 health=100 team=2
|
||||
[Conduit] [INFO] event_test: [post] player_spawn userid=0 pawn=player idx=256 health=100 team=2
|
||||
[Conduit] [INFO] event_test: [pre] player_spawn userid=1 pawn=player idx=260 health=100 team=3
|
||||
[Conduit] [INFO] event_test: [post] player_spawn userid=1 pawn=player idx=260 health=100 team=3
|
||||
```
|
||||
|
||||
Conduit log:
|
||||
|
||||
```text
|
||||
2026-06-14 13:50:11 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre)
|
||||
2026-06-14 13:50:11 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post)
|
||||
2026-06-14 13:50:11 [INFO] event_test: [pre] player_spawn userid=0 pawn=player idx=256 health=100 team=2
|
||||
2026-06-14 13:50:11 [INFO] event_test: [post] player_spawn userid=0 pawn=player idx=256 health=100 team=2
|
||||
2026-06-14 13:50:11 [INFO] event_test: [pre] player_spawn userid=1 pawn=player idx=260 health=100 team=3
|
||||
2026-06-14 13:50:11 [INFO] event_test: [post] player_spawn userid=1 pawn=player idx=260 health=100 team=3
|
||||
2026-06-14 13:50:12 [INFO] event_test: [pre] player_spawn userid=0 pawn=player idx=256 health=100 team=2
|
||||
2026-06-14 13:50:12 [INFO] event_test: [post] player_spawn userid=0 pawn=player idx=256 health=100 team=2
|
||||
2026-06-14 13:50:12 [INFO] event_test: [pre] player_spawn userid=1 pawn=player idx=260 health=100 team=3
|
||||
2026-06-14 13:50:12 [INFO] event_test: [post] player_spawn userid=1 pawn=player idx=260 health=100 team=3
|
||||
```
|
||||
|
||||
Result: event carried player reference resolved to live pawn entities in both pre and post phases. Schema-backed reads returned health/team.
|
||||
|
||||
## Health Read / Write
|
||||
|
||||
RCON:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_ent_health 0 ---
|
||||
[Conduit] slot 0 pawn=player health=100 team=2
|
||||
--- conduit_ent_health 0 45 ---
|
||||
[Conduit] slot 0 health 100 -> 45 (read back 45; networked write)
|
||||
--- conduit_ent_health 0 ---
|
||||
[Conduit] slot 0 pawn=player health=45 team=2
|
||||
```
|
||||
|
||||
Cleanup / restore:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_ent_health 0 100 ---
|
||||
[Conduit] slot 0 health 45 -> 100 (read back 100; networked write)
|
||||
--- conduit_ent_health 0 ---
|
||||
[Conduit] slot 0 pawn=player health=100 team=2
|
||||
--- conduit_event_stop ---
|
||||
[Conduit] removed 2 test subscription(s)
|
||||
--- conduit_events ---
|
||||
game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off
|
||||
--- conduit_prof ---
|
||||
owner callsite calls avg p50 p99 max total
|
||||
engine GameFrame 6851 2.44ms 4.19ms 8.39ms 33.34ms 16.74s
|
||||
event_test player_spawn 8 42.6us 65.5us 131.1us 77.7us 341.2us
|
||||
core SchemaFindField 3 12.7us 8.2us 32.8us 29.3us 38.0us
|
||||
```
|
||||
|
||||
Result: schema-backed setter wrote `m_iHealth`, readback returned the written value, then health was restored to 100.
|
||||
|
||||
## Final State
|
||||
|
||||
RCON:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 162.5 s
|
||||
game frames : 9648
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
--- conduit_events ---
|
||||
game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off
|
||||
```
|
||||
|
||||
Docker:
|
||||
|
||||
```text
|
||||
/gp_c2b47a90 running running=true exit=0 oom=false
|
||||
/gp_d8f13411 exited running=false exit=130 oom=false
|
||||
```
|
||||
|
||||
Panel DB:
|
||||
|
||||
```text
|
||||
uuid | name | status | slug | port
|
||||
----------+----------------+---------+--------------+-------
|
||||
c2b47a90 | Test Sunucusu | running | cs2 | 27015
|
||||
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
## Odd Warnings / Notes
|
||||
|
||||
No Conduit/Metamod/signal load failure or crash was observed.
|
||||
|
||||
The `conduit_vtable` / `conduit_entity` window produced one watchdog sample:
|
||||
|
||||
```text
|
||||
2026-06-14 13:49:57 [WARN] watchdog: game thread has not advanced a frame for 521 ms (outside GameFrame)
|
||||
2026-06-14 13:49:57 [WARN] watchdog: no Conduit context on the game thread — the stall is in engine/game code or an untracked path
|
||||
2026-06-14 13:49:57 [WARN] watchdog: #00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcff99) [0x7f2ee4b0ef99]
|
||||
2026-06-14 13:49:57 [WARN] watchdog: #01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7f2f31cc2050]
|
||||
2026-06-14 13:49:57 [WARN] watchdog: #02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd020) [0x7f2ee4b0c020]
|
||||
2026-06-14 13:49:57 [WARN] watchdog: #03 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd160) [0x7f2ee4b0c160]
|
||||
2026-06-14 13:49:57 [WARN] watchdog: #04 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd523) [0x7f2ee4b0c523]
|
||||
2026-06-14 13:49:57 [WARN] watchdog: #05 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd60f) [0x7f2ee4b0c60f]
|
||||
2026-06-14 13:49:57 [WARN] watchdog: #06 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcdecd) [0x7f2ee4b0cecd]
|
||||
2026-06-14 13:49:57 [WARN] watchdog: #07 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xc10d5) [0x7f2ee4b000d5]
|
||||
2026-06-14 13:49:57 [WARN] watchdog: #08 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7f2f3173b902]
|
||||
2026-06-14 13:49:57 [INFO] entity: entity system connected (0x7f2ee9124000) via GameResourceServiceServerV001+0x50 — vtable matches CGameEntitySystem
|
||||
2026-06-14 13:49:57 [INFO] watchdog: game thread recovered after a ~816 ms stall
|
||||
```
|
||||
|
||||
This looks tied to the diagnostic RTTI/entity scan command window. The server recovered and continued through later event and health tests.
|
||||
|
||||
Server console also printed ordinary CS2 map/resource/round warnings such as missing lightmap resources and `Failed to write backup_round00.txt!`; I did not see those tied to Conduit/Metamod/signal failure.
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
# Conduit Linux GameEvents Re-run - 2026-06-14
|
||||
|
||||
## Scope
|
||||
|
||||
- Pulled latest Conduit commits from `origin/main`.
|
||||
- Inspected the new Phase 2 dispatch/GameEvents diff.
|
||||
- Rebuilt clean on Linux with GCC 12.
|
||||
- Deployed the resulting package to CS2 server `Test Sunucusu`.
|
||||
- Verified Linux RTTI vtable lookup, GameEvent manager connection, pre/post dispatch, cancellation, profiler accounting, and cleanup.
|
||||
- Did not touch the Satisfactory server files/container.
|
||||
|
||||
## Git
|
||||
|
||||
Latest commits after pull:
|
||||
|
||||
```text
|
||||
a5c1c13 (HEAD -> main, origin/main, origin/HEAD) docs: Linux re-test steps for the Phase 2 GameEvents slice
|
||||
ffb57f3 Phase 2 start: dispatch core + GameEvents, with an RTTI vtable finder
|
||||
4078ab7 PLAN: close the Linux memscan question - M1 fully done on both platforms
|
||||
```
|
||||
|
||||
Diff summary inspected:
|
||||
|
||||
```text
|
||||
PLAN.md | 4 +-
|
||||
core/CMakeLists.txt | 1 +
|
||||
core/src/conduit/commands.cpp | 85 ++++++++++++
|
||||
core/src/conduit/gameevents.cpp | 294 ++++++++++++++++++++++++++++++++++++++++
|
||||
core/src/conduit/gameevents.h | 73 ++++++++++
|
||||
core/src/conduit/memscan.cpp | 237 ++++++++++++++++++++++++++++++--
|
||||
core/src/conduit/memscan.h | 7 +
|
||||
core/src/plugin.cpp | 5 +
|
||||
docs/linux-bringup.md | 24 ++++
|
||||
```
|
||||
|
||||
Working tree after the run:
|
||||
|
||||
```text
|
||||
## main...origin/main
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Build command used:
|
||||
|
||||
```sh
|
||||
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||
-DCMAKE_C_COMPILER=gcc-12 \
|
||||
-DCMAKE_CXX_COMPILER=g++-12
|
||||
cmake --build /root/codex/Conduit/build/linux-rerun
|
||||
```
|
||||
|
||||
Result: build succeeded.
|
||||
|
||||
Final build line:
|
||||
|
||||
```text
|
||||
[30/30] Linking CXX shared library package/addons/conduit/bin/conduit.so
|
||||
```
|
||||
|
||||
Warnings only:
|
||||
|
||||
```text
|
||||
- protobuf syntax warnings
|
||||
- paths.cpp snprintf truncation warnings
|
||||
- commands.cpp volatile compound assignment deprecated
|
||||
- log.cpp snprintf truncation warning
|
||||
- C-only warning for C++ flags on Zydis.c
|
||||
```
|
||||
|
||||
No full cmake/ninja failure output exists because the build did not fail.
|
||||
|
||||
## Deploy
|
||||
|
||||
Deployed package:
|
||||
|
||||
```text
|
||||
/root/codex/Conduit/build/linux-rerun/package/.
|
||||
```
|
||||
|
||||
Destination:
|
||||
|
||||
```text
|
||||
/var/lib/gamepanel/servers/c2b47a90/game/csgo/
|
||||
```
|
||||
|
||||
Server restarted:
|
||||
|
||||
```text
|
||||
gp_c2b47a90
|
||||
```
|
||||
|
||||
Satisfactory server was not modified.
|
||||
|
||||
## Initial Load
|
||||
|
||||
`meta list` and `conduit_status` after deploy:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- meta list ---
|
||||
Listing 1 plugin:
|
||||
[01] Conduit (0.1.0-dev) by Conduit Contributors
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 88.9 s
|
||||
game frames : 5066
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
```
|
||||
|
||||
Startup log lines:
|
||||
|
||||
```text
|
||||
2026-06-14 11:34:50 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid
|
||||
2026-06-14 11:34:50 [INFO] gameevents: armed via CGameEventManager vtable 0x7f47c7ae2f50 — instance captured on next LoadEventsFromFile
|
||||
2026-06-14 11:34:50 [INFO] core: Conduit 0.1.0-dev loaded — crash reporter armed, profiler on
|
||||
2026-06-14 11:34:50 [INFO] gameevents: game event manager connected (0x7f47c7e62460) — event listening live
|
||||
2026-06-14 11:35:00 [INFO] core: first GameFrame observed — hook dispatch confirmed
|
||||
```
|
||||
|
||||
## RTTI Vtable Checks
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
conduit_vtable server CGameEventManager
|
||||
conduit_vtable server CCSPlayerController
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_vtable server CGameEventManager ---
|
||||
[Conduit] CGameEventManager vtable @ 0x7f47c7ae2f50 first method -> 0x7f47c6d0aa80
|
||||
--- conduit_vtable server CCSPlayerController ---
|
||||
[Conduit] CCSPlayerController vtable @ 0x7f47c7ace5a8 first method -> 0x7f47c6b169a0
|
||||
```
|
||||
|
||||
Result: Linux Itanium RTTI lookup passed for both classes.
|
||||
|
||||
## Listen Test
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
conduit_event_stop
|
||||
conduit_event_listen player_spawn
|
||||
mp_restartgame 1
|
||||
```
|
||||
|
||||
RCON output:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_event_stop ---
|
||||
[Conduit] removed 0 test subscription(s)
|
||||
--- conduit_event_listen player_spawn ---
|
||||
[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre)
|
||||
[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post)
|
||||
[Conduit] listening to 'player_spawn' (pre+post) — watch the log
|
||||
--- mp_restartgame 1 ---
|
||||
```
|
||||
|
||||
Log evidence:
|
||||
|
||||
```text
|
||||
2026-06-14 11:55:18 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre)
|
||||
2026-06-14 11:55:18 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post)
|
||||
2026-06-14 11:55:20 [INFO] event_test: [pre] player_spawn userid=0 attacker=-1
|
||||
2026-06-14 11:55:20 [INFO] event_test: [post] player_spawn userid=0 attacker=-1
|
||||
2026-06-14 11:55:20 [INFO] event_test: [pre] player_spawn userid=1 attacker=-1
|
||||
2026-06-14 11:55:20 [INFO] event_test: [post] player_spawn userid=1 attacker=-1
|
||||
```
|
||||
|
||||
`conduit_events` and `conduit_prof` after listen:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_events ---
|
||||
game events: manager connected, 2 subscription(s), 1 event(s) registered, FireEvent detour on
|
||||
event_test player_spawn pre hits=2
|
||||
event_test player_spawn post hits=2
|
||||
--- conduit_prof ---
|
||||
owner callsite calls avg p50 p99 max total
|
||||
engine GameFrame 80323 2.28ms 4.19ms 4.19ms 27.49ms 183.11s
|
||||
event_test player_spawn 4 38.1us 65.5us 65.5us 54.9us 152.6us
|
||||
```
|
||||
|
||||
Result: pre and post dispatch both fired.
|
||||
|
||||
## Cancel Test
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
conduit_event_cancel player_spawn
|
||||
mp_restartgame 1
|
||||
```
|
||||
|
||||
RCON output:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_event_cancel player_spawn ---
|
||||
[Conduit] [INFO] gameevents: 'event_cancel_test' subscribed to 'player_spawn' (pre)
|
||||
[Conduit] will cancel every 'player_spawn' until conduit_event_stop
|
||||
--- mp_restartgame 1 ---
|
||||
```
|
||||
|
||||
Log evidence:
|
||||
|
||||
```text
|
||||
2026-06-14 12:02:21 [INFO] gameevents: 'event_cancel_test' subscribed to 'player_spawn' (pre)
|
||||
2026-06-14 12:02:22 [INFO] event_test: [pre] player_spawn userid=0 attacker=-1
|
||||
2026-06-14 12:02:22 [WARN] event_test: cancelling 'player_spawn' (pre)
|
||||
2026-06-14 12:02:22 [INFO] event_test: [pre] player_spawn userid=1 attacker=-1
|
||||
2026-06-14 12:02:22 [WARN] event_test: cancelling 'player_spawn' (pre)
|
||||
```
|
||||
|
||||
There were no new `[post] player_spawn` lines after cancellation.
|
||||
|
||||
`conduit_events` and `conduit_prof` after cancel:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_events ---
|
||||
game events: manager connected, 3 subscription(s), 1 event(s) registered, FireEvent detour on
|
||||
event_test player_spawn pre hits=4
|
||||
event_test player_spawn post hits=2
|
||||
event_cancel_test player_spawn pre hits=2
|
||||
--- conduit_prof ---
|
||||
owner callsite calls avg p50 p99 max total
|
||||
engine GameFrame 107531 2.25ms 4.19ms 4.19ms 32.27ms 242.28s
|
||||
event_test player_spawn 6 47.1us 65.5us 131.1us 76.9us 282.5us
|
||||
event_cancel_test player_spawn 2 14.5us 16.4us 16.4us 14.9us 28.9us
|
||||
```
|
||||
|
||||
Result: cancel path passed; post stayed at `hits=2` while pre advanced.
|
||||
|
||||
## Cleanup
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
conduit_event_stop
|
||||
conduit_events
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_event_stop ---
|
||||
[Conduit] removed 3 test subscription(s)
|
||||
--- conduit_events ---
|
||||
game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off
|
||||
```
|
||||
|
||||
Final status:
|
||||
|
||||
```text
|
||||
rcon: authenticated
|
||||
--- conduit_status ---
|
||||
Conduit 0.1.0-dev
|
||||
uptime : 2228.6 s
|
||||
game frames : 141868
|
||||
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
|
||||
crash report: armed (use conduit_crash_test to verify)
|
||||
--- conduit_events ---
|
||||
game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off
|
||||
```
|
||||
|
||||
## Container and Panel State
|
||||
|
||||
Docker:
|
||||
|
||||
```text
|
||||
/gp_c2b47a90 running running=true exit=0 oom=false
|
||||
/gp_d8f13411 exited running=false exit=130 oom=false
|
||||
```
|
||||
|
||||
Panel DB:
|
||||
|
||||
```text
|
||||
uuid | name | status | slug | port
|
||||
----------+----------------+---------+--------------+-------
|
||||
c2b47a90 | Test Sunucusu | running | cs2 | 27015
|
||||
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
## Odd Warnings
|
||||
|
||||
During the RTTI/vtable/memscan command window, the log briefly reported watchdog stalls outside Conduit context:
|
||||
|
||||
```text
|
||||
2026-06-14 11:50:54 [WARN] watchdog: game thread has not advanced a frame for 509 ms (outside GameFrame)
|
||||
2026-06-14 11:50:54 [WARN] watchdog: no Conduit context on the game thread — the stall is in engine/game code or an untracked path
|
||||
2026-06-14 11:51:04 [WARN] watchdog: game thread has not advanced a frame for 585 ms (outside GameFrame)
|
||||
2026-06-14 11:51:04 [WARN] watchdog: no Conduit context on the game thread — the stall is in engine/game code or an untracked path
|
||||
```
|
||||
|
||||
The server recovered and continued through 141k+ frames. I did not see Conduit/Metamod/signal load failures or crashes in this run.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Daemon configuration — mounted into the daemon container
|
||||
# Adjust api_url and node_token for your deployment
|
||||
|
||||
api_url: "http://api:3000"
|
||||
node_token: "CHANGE_ME_GENERATE_A_SECURE_TOKEN"
|
||||
grpc_port: 50051
|
||||
data_path: "/var/lib/gamepanel/servers"
|
||||
backup_path: "/var/lib/gamepanel/backups"
|
||||
|
||||
docker:
|
||||
socket: "/var/run/docker.sock"
|
||||
network: "gamepanel_nw"
|
||||
network_subnet: "172.18.0.0/16"
|
||||
|
||||
# Optional node-local MySQL/MariaDB management for server databases.
|
||||
# `connection_host` should be reachable by the game containers on this node.
|
||||
managed_mysql:
|
||||
url: "mysql://root:change-me@127.0.0.1:3306/mysql"
|
||||
connection_host: "CHANGE_ME_REACHABLE_FROM_GAME_CONTAINERS"
|
||||
connection_port: 3306
|
||||
phpmyadmin_url: "http://127.0.0.1:8080/"
|
||||
# Optional: overrides the client binary. Defaults to trying "mariadb" then "mysql".
|
||||
# bin: "mariadb"
|
||||
@@ -0,0 +1,28 @@
|
||||
version: "3.9"
|
||||
|
||||
# Development-only services (DB + Redis)
|
||||
# Usage: docker compose -f docker-compose.dev.yml up -d
|
||||
# Then run: pnpm dev
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-gamepanel}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-gamepanel}
|
||||
POSTGRES_DB: ${DB_NAME:-gamepanel}
|
||||
volumes:
|
||||
- pgdata_dev:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass gamepanel
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
volumes:
|
||||
pgdata_dev:
|
||||
+77
-5
@@ -1,29 +1,101 @@
|
||||
services:
|
||||
# --- PostgreSQL ---
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: gamepanel-postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-gamepanel}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-gamepanel}
|
||||
POSTGRES_DB: ${DB_NAME:-gamepanel}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
|
||||
interval: 5s
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# --- Redis (rate limiting, session cache) ---
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: gamepanel-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-gamepanel}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-gamepanel}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# --- API ---
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
container_name: gamepanel-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD:-gamepanel}@postgres:5432/${DB_NAME:-gamepanel}
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-gamepanel}@redis:6379
|
||||
PORT: 3000
|
||||
HOST: 0.0.0.0
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost}
|
||||
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-100}
|
||||
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-60000}
|
||||
ports:
|
||||
- "${API_PORT:-3000}:3000"
|
||||
|
||||
# --- Web (nginx + SPA) ---
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/web/Dockerfile
|
||||
args:
|
||||
VITE_API_URL: /api
|
||||
container_name: gamepanel-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "${WEB_PORT:-80}:80"
|
||||
|
||||
# --- Daemon (runs on game server nodes) ---
|
||||
daemon:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/daemon/Dockerfile
|
||||
container_name: gamepanel-daemon
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
privileged: true
|
||||
environment:
|
||||
DAEMON_CONFIG: /etc/gamepanel/config.yml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- daemon_data:/var/lib/gamepanel/servers
|
||||
- daemon_backups:/var/lib/gamepanel/backups
|
||||
- ./daemon-config.yml:/etc/gamepanel/config.yml:ro
|
||||
ports:
|
||||
- "${DAEMON_GRPC_PORT:-50051}:50051"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
daemon_data:
|
||||
daemon_backups:
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
ALTER TABLE "games"
|
||||
ADD COLUMN IF NOT EXISTS "automation_rules" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||
|
||||
UPDATE "games"
|
||||
SET
|
||||
"automation_rules" = '[
|
||||
{
|
||||
"id": "cs2-install-latest-counterstrikesharp-runtime",
|
||||
"event": "server.install.completed",
|
||||
"enabled": true,
|
||||
"runOncePerServer": true,
|
||||
"continueOnError": false,
|
||||
"actions": [
|
||||
{
|
||||
"id": "install-cs2-runtime",
|
||||
"type": "github_release_extract",
|
||||
"owner": "roflmuffin",
|
||||
"repo": "CounterStrikeSharp",
|
||||
"assetNamePatterns": [
|
||||
"^counterstrikesharp-with-runtime-.*linux.*\\\\.zip$",
|
||||
"^counterstrikesharp-with-runtime.*\\\\.zip$"
|
||||
],
|
||||
"destination": "/game/csgo",
|
||||
"stripComponents": 0,
|
||||
"maxBytes": 268435456
|
||||
}
|
||||
]
|
||||
}
|
||||
]'::jsonb,
|
||||
"updated_at" = now()
|
||||
WHERE
|
||||
"slug" = 'cs2'
|
||||
AND (
|
||||
"automation_rules" IS NULL
|
||||
OR "automation_rules" = '[]'::jsonb
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
WITH metamod_rule AS (
|
||||
SELECT '[
|
||||
{
|
||||
"id": "cs2-install-latest-metamod",
|
||||
"event": "server.install.completed",
|
||||
"enabled": true,
|
||||
"runOncePerServer": true,
|
||||
"continueOnError": false,
|
||||
"actions": [
|
||||
{
|
||||
"id": "install-cs2-metamod",
|
||||
"type": "http_directory_extract",
|
||||
"indexUrl": "https://mms.alliedmods.net/mmsdrop/2.0/",
|
||||
"assetNamePattern": "^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$",
|
||||
"destination": "/game/csgo",
|
||||
"stripComponents": 0,
|
||||
"maxBytes": 268435456
|
||||
},
|
||||
{
|
||||
"id": "ensure-cs2-metamod-gameinfo-entry",
|
||||
"type": "insert_before_line",
|
||||
"path": "/game/csgo/gameinfo.gi",
|
||||
"line": "\\t\\t\\tGame csgo/addons/metamod",
|
||||
"beforePattern": "^\\\\s*Game\\\\s+csgo\\\\s*$",
|
||||
"existsPattern": "^\\\\s*Game\\\\s+csgo/addons/metamod\\\\s*$",
|
||||
"skipIfExists": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]'::jsonb AS rule
|
||||
)
|
||||
UPDATE "games" g
|
||||
SET
|
||||
"automation_rules" = CASE
|
||||
WHEN g."automation_rules" IS NULL OR jsonb_typeof(g."automation_rules") <> 'array'
|
||||
THEN (SELECT rule FROM metamod_rule)
|
||||
ELSE g."automation_rules" || (SELECT rule FROM metamod_rule)
|
||||
END,
|
||||
"updated_at" = now()
|
||||
WHERE
|
||||
g."slug" = 'cs2'
|
||||
AND NOT (
|
||||
COALESCE(g."automation_rules", '[]'::jsonb) @> '[{"id":"cs2-install-latest-metamod"}]'::jsonb
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
INSERT INTO "games" (
|
||||
"slug",
|
||||
"name",
|
||||
"docker_image",
|
||||
"default_port",
|
||||
"config_files",
|
||||
"automation_rules",
|
||||
"startup_command",
|
||||
"stop_command",
|
||||
"environment_vars",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
VALUES (
|
||||
'satisfactory',
|
||||
'Satisfactory',
|
||||
'wolveix/satisfactory-server:latest',
|
||||
7777,
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'',
|
||||
'quit',
|
||||
'[
|
||||
{
|
||||
"key": "MAXPLAYERS",
|
||||
"default": "4",
|
||||
"description": "Maximum player count",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "STEAMBETA",
|
||||
"label": "Branch",
|
||||
"default": "false",
|
||||
"description": "Use the experimental branch instead of stable",
|
||||
"required": false,
|
||||
"inputType": "boolean",
|
||||
"enabledLabel": "Experimental",
|
||||
"disabledLabel": "Stable"
|
||||
},
|
||||
{
|
||||
"key": "AUTOSAVENUM",
|
||||
"default": "5",
|
||||
"description": "Number of rotating autosaves",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "MAXTICKRATE",
|
||||
"default": "30",
|
||||
"description": "Maximum simulation tick rate",
|
||||
"required": false
|
||||
}
|
||||
]'::jsonb,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT ("slug") DO UPDATE
|
||||
SET
|
||||
"name" = EXCLUDED."name",
|
||||
"docker_image" = EXCLUDED."docker_image",
|
||||
"default_port" = EXCLUDED."default_port",
|
||||
"config_files" = EXCLUDED."config_files",
|
||||
"automation_rules" = EXCLUDED."automation_rules",
|
||||
"startup_command" = EXCLUDED."startup_command",
|
||||
"stop_command" = EXCLUDED."stop_command",
|
||||
"environment_vars" = EXCLUDED."environment_vars",
|
||||
"updated_at" = now();
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1771748754705,
|
||||
"tag": "0000_red_sunset_bain",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1772200000000,
|
||||
"tag": "0001_game_automation_rules",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1772300000000,
|
||||
"tag": "0002_cs2_add_metamod_workflow",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1772400000000,
|
||||
"tag": "0003_global_plugin_registry",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1772600000000,
|
||||
"tag": "0004_cs2_startup_parameters",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1772800000000,
|
||||
"tag": "0005_cs2_servername_default",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1772900000000,
|
||||
"tag": "0006_cs2_servername_branding",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1773000000000,
|
||||
"tag": "0007_satisfactory_game",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const games = pgTable('games', {
|
||||
dockerImage: text('docker_image').notNull(),
|
||||
defaultPort: integer('default_port').notNull(),
|
||||
configFiles: jsonb('config_files').default([]).notNull(),
|
||||
automationRules: jsonb('automation_rules').default([]).notNull(),
|
||||
startupCommand: text('startup_command').notNull(),
|
||||
stopCommand: text('stop_command'),
|
||||
environmentVars: jsonb('environment_vars').default([]).notNull(),
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './backups';
|
||||
export * from './plugins';
|
||||
export * from './schedules';
|
||||
export * from './audit-logs';
|
||||
export * from './server-databases';
|
||||
|
||||
@@ -6,11 +6,17 @@ import {
|
||||
boolean,
|
||||
timestamp,
|
||||
pgEnum,
|
||||
jsonb,
|
||||
bigint,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { games } from './games';
|
||||
import { servers } from './servers';
|
||||
import { users } from './users';
|
||||
|
||||
export const pluginSourceEnum = pgEnum('plugin_source', ['spiget', 'manual']);
|
||||
export const pluginReleaseChannelEnum = pgEnum('plugin_release_channel', ['stable', 'beta', 'alpha']);
|
||||
export const pluginReleaseArtifactTypeEnum = pgEnum('plugin_release_artifact_type', ['file', 'zip']);
|
||||
export const pluginInstallStatusEnum = pgEnum('plugin_install_status', ['installed', 'updating', 'failed']);
|
||||
|
||||
export const plugins = pgTable('plugins', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
@@ -24,6 +30,29 @@ export const plugins = pgTable('plugins', {
|
||||
externalId: varchar('external_id', { length: 255 }),
|
||||
downloadUrl: text('download_url'),
|
||||
version: varchar('version', { length: 100 }),
|
||||
isGlobal: boolean('is_global').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const pluginReleases = pgTable('plugin_releases', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
pluginId: uuid('plugin_id')
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: 'cascade' }),
|
||||
version: varchar('version', { length: 100 }).notNull(),
|
||||
channel: pluginReleaseChannelEnum('channel').default('stable').notNull(),
|
||||
artifactType: pluginReleaseArtifactTypeEnum('artifact_type').default('file').notNull(),
|
||||
artifactUrl: text('artifact_url').notNull(),
|
||||
destination: text('destination'),
|
||||
fileName: varchar('file_name', { length: 255 }),
|
||||
checksumSha256: varchar('checksum_sha256', { length: 128 }),
|
||||
sizeBytes: bigint('size_bytes', { mode: 'number' }),
|
||||
changelog: text('changelog'),
|
||||
installSchema: jsonb('install_schema').default([]).notNull(),
|
||||
configTemplates: jsonb('config_templates').default([]).notNull(),
|
||||
isPublished: boolean('is_published').default(true).notNull(),
|
||||
createdByUserId: uuid('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
@@ -36,7 +65,24 @@ export const serverPlugins = pgTable('server_plugins', {
|
||||
pluginId: uuid('plugin_id')
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: 'cascade' }),
|
||||
releaseId: uuid('release_id').references(() => pluginReleases.id, { onDelete: 'set null' }),
|
||||
installedVersion: varchar('installed_version', { length: 100 }),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
installOptions: jsonb('install_options').default({}).notNull(),
|
||||
autoUpdateChannel: pluginReleaseChannelEnum('auto_update_channel').default('stable').notNull(),
|
||||
isPinned: boolean('is_pinned').default(false).notNull(),
|
||||
status: pluginInstallStatusEnum('status').default('installed').notNull(),
|
||||
lastError: text('last_error'),
|
||||
installedAt: timestamp('installed_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const serverPluginFiles = pgTable('server_plugin_files', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
serverPluginId: uuid('server_plugin_id')
|
||||
.notNull()
|
||||
.references(() => serverPlugins.id, { onDelete: 'cascade' }),
|
||||
path: text('path').notNull(),
|
||||
kind: varchar('kind', { length: 32 }).default('artifact').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { servers } from './servers';
|
||||
|
||||
export const serverDatabases = pgTable('server_databases', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
serverId: uuid('server_id')
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: 'cascade' }),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
databaseName: varchar('database_name', { length: 255 }).notNull().unique(),
|
||||
username: varchar('username', { length: 64 }).notNull().unique(),
|
||||
password: text('password').notNull(),
|
||||
host: varchar('host', { length: 255 }).notNull(),
|
||||
port: integer('port').notNull(),
|
||||
phpMyAdminUrl: text('phpmyadmin_url'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
+414
-12
@@ -1,7 +1,59 @@
|
||||
import { createDb } from './client';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { games } from './schema/games';
|
||||
import { users } from './schema/users';
|
||||
|
||||
const DEFAULT_CS2_SERVER_CFG = `// ============================================
|
||||
// CS2 Server Config
|
||||
// ============================================
|
||||
|
||||
// ---- Sunucu Bilgileri ----
|
||||
hostname "SourceGamePanel CS2 Server"
|
||||
sv_password ""
|
||||
rcon_password "changeme"
|
||||
sv_cheats 0
|
||||
|
||||
// ---- Topluluk Sunucu Gorunurlugu ----
|
||||
sv_region 3
|
||||
sv_tags "competitive,community"
|
||||
sv_lan 0
|
||||
sv_steamgroup ""
|
||||
sv_steamgroup_exclusive 0
|
||||
|
||||
// ---- Performans ----
|
||||
sv_maxrate 0
|
||||
sv_minrate 64000
|
||||
sv_max_queries_sec 5
|
||||
sv_max_queries_window 30
|
||||
sv_parallel_sendsnapshot 1
|
||||
net_maxroutable 1200
|
||||
|
||||
// ---- Baglanti ----
|
||||
sv_maxclients 16
|
||||
sv_timeout 60
|
||||
|
||||
// ---- GOTV (Tamamen Kapali) ----
|
||||
tv_enable 0
|
||||
tv_autorecord 0
|
||||
tv_delay 0
|
||||
tv_maxclients 0
|
||||
tv_port 0
|
||||
|
||||
// ---- Loglama ----
|
||||
log on
|
||||
mp_logmoney 0
|
||||
mp_logdetail 0
|
||||
mp_logdetail_items 0
|
||||
sv_logfile 1
|
||||
|
||||
// ---- Genel Oyun Ayarlari ----
|
||||
mp_autokick 0
|
||||
sv_allow_votes 0
|
||||
sv_alltalk 0
|
||||
sv_deadtalk 1
|
||||
sv_voiceenable 1
|
||||
`;
|
||||
|
||||
async function seed() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
@@ -16,7 +68,7 @@ async function seed() {
|
||||
// Password: admin123 (argon2id hash)
|
||||
// In production, change this immediately after first login
|
||||
const ADMIN_PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+daw';
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$3968YbMY1wOYMK5NTLa2dQ$j8BkXfK7znAAiuYiC9zWgOaBK11VeimROd28QOMMgd0';
|
||||
|
||||
await db
|
||||
.insert(users)
|
||||
@@ -26,7 +78,15 @@ async function seed() {
|
||||
passwordHash: ADMIN_PASSWORD_HASH,
|
||||
isSuperAdmin: true,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
.onConflictDoUpdate({
|
||||
target: users.email,
|
||||
set: {
|
||||
username: 'admin',
|
||||
passwordHash: ADMIN_PASSWORD_HASH,
|
||||
isSuperAdmin: true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Seed games
|
||||
console.log('Seeding games...');
|
||||
@@ -83,46 +143,388 @@ async function seed() {
|
||||
{
|
||||
slug: 'cs2',
|
||||
name: 'Counter-Strike 2',
|
||||
dockerImage: 'cm2network/csgo:latest',
|
||||
dockerImage: 'cm2network/cs2:latest',
|
||||
defaultPort: 27015,
|
||||
startupCommand:
|
||||
'./srcds_run -game csgo -console -usercon +game_type 0 +game_mode 0 +mapgroup mg_active +map de_dust2',
|
||||
startupCommand: '',
|
||||
stopCommand: 'quit',
|
||||
configFiles: [
|
||||
{
|
||||
path: 'csgo/cfg/server.cfg',
|
||||
path: 'game/csgo/cfg/server.cfg',
|
||||
parser: 'keyvalue',
|
||||
editableKeys: [
|
||||
'hostname',
|
||||
'sv_tags',
|
||||
'sv_password',
|
||||
'rcon_password',
|
||||
'sv_cheats',
|
||||
'sv_region',
|
||||
'sv_lan',
|
||||
'sv_steamgroup',
|
||||
'sv_steamgroup_exclusive',
|
||||
'sv_maxrate',
|
||||
'sv_minrate',
|
||||
'sv_max_queries_sec',
|
||||
'sv_max_queries_window',
|
||||
'sv_parallel_sendsnapshot',
|
||||
'net_maxroutable',
|
||||
'sv_maxclients',
|
||||
'sv_timeout',
|
||||
'tv_enable',
|
||||
'tv_autorecord',
|
||||
'tv_delay',
|
||||
'tv_maxclients',
|
||||
'tv_port',
|
||||
'sv_logfile',
|
||||
'mp_autokick',
|
||||
'sv_allow_votes',
|
||||
'sv_alltalk',
|
||||
'sv_deadtalk',
|
||||
'sv_voiceenable',
|
||||
'mp_autoteambalance',
|
||||
'mp_limitteams',
|
||||
],
|
||||
},
|
||||
{ path: 'csgo/cfg/autoexec.cfg', parser: 'keyvalue' },
|
||||
{ path: 'game/csgo/cfg/autoexec.cfg', parser: 'keyvalue' },
|
||||
],
|
||||
automationRules: [
|
||||
{
|
||||
id: 'cs2-write-default-server-config',
|
||||
event: 'server.install.completed',
|
||||
enabled: true,
|
||||
runOncePerServer: true,
|
||||
continueOnError: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'write-cs2-default-server-config',
|
||||
type: 'write_file',
|
||||
path: '/game/csgo/cfg/server.cfg',
|
||||
data: DEFAULT_CS2_SERVER_CFG,
|
||||
},
|
||||
{
|
||||
id: 'write-cs2-persisted-server-config',
|
||||
type: 'write_file',
|
||||
path: '/game/csgo/cfg/.sourcegamepanel-server.cfg',
|
||||
data: DEFAULT_CS2_SERVER_CFG,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cs2-install-latest-metamod',
|
||||
event: 'server.install.completed',
|
||||
enabled: true,
|
||||
runOncePerServer: true,
|
||||
continueOnError: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'install-cs2-metamod',
|
||||
type: 'http_directory_extract',
|
||||
indexUrl: 'https://mms.alliedmods.net/mmsdrop/2.0/',
|
||||
assetNamePattern: '^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$',
|
||||
destination: '/game/csgo',
|
||||
stripComponents: 0,
|
||||
maxBytes: 256 * 1024 * 1024,
|
||||
},
|
||||
{
|
||||
id: 'ensure-cs2-metamod-gameinfo-entry',
|
||||
type: 'insert_before_line',
|
||||
path: '/game/csgo/gameinfo.gi',
|
||||
line: '\t\t\tGame csgo/addons/metamod',
|
||||
beforePattern: '^\\s*Game\\s+csgo\\s*$',
|
||||
existsPattern: '^\\s*Game\\s+csgo/addons/metamod\\s*$',
|
||||
skipIfExists: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cs2-install-latest-counterstrikesharp-runtime',
|
||||
event: 'server.install.completed',
|
||||
enabled: true,
|
||||
runOncePerServer: true,
|
||||
continueOnError: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'install-cs2-runtime',
|
||||
type: 'github_release_extract',
|
||||
owner: 'roflmuffin',
|
||||
repo: 'CounterStrikeSharp',
|
||||
assetNamePatterns: [
|
||||
'^counterstrikesharp-with-runtime-.*linux.*\\.zip$',
|
||||
'^counterstrikesharp-with-runtime.*\\.zip$',
|
||||
],
|
||||
destination: '/game/csgo',
|
||||
stripComponents: 0,
|
||||
maxBytes: 256 * 1024 * 1024,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
environmentVars: [
|
||||
{
|
||||
key: 'SRCDS_TOKEN',
|
||||
default: '',
|
||||
description: 'Steam Game Server Login Token',
|
||||
description: 'Steam Game Server Login Token (optional for local testing)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'CS2_SERVERNAME',
|
||||
default: 'SourceGamePanel CS2 Server',
|
||||
description: 'Server name',
|
||||
required: false,
|
||||
},
|
||||
{ key: 'CS2_PORT', default: '27015', description: 'Game port', required: false },
|
||||
{ key: 'CS2_STARTMAP', default: 'de_dust2', description: 'Initial map', required: false },
|
||||
{ key: 'CS2_MAXPLAYERS', default: '16', description: 'Max players', required: false },
|
||||
{ key: 'CS2_RCONPW', default: '', description: 'RCON password', required: false },
|
||||
{
|
||||
key: 'CS2_IP',
|
||||
default: '0.0.0.0',
|
||||
description: 'Bind address',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'CS2_HOST_WORKSHOP_COLLECTION',
|
||||
default: '',
|
||||
description: 'Steam Workshop collection id to load',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'CS2_HOST_WORKSHOP_MAP',
|
||||
default: '',
|
||||
description: 'Steam Workshop map id to launch',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'CS2_GAMETYPE',
|
||||
default: '0',
|
||||
description: 'Game type numeric value',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'CS2_GAMEMODE',
|
||||
default: '1',
|
||||
description: 'Game mode numeric value',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'CS2_ADDITIONAL_ARGS',
|
||||
default: '',
|
||||
description: 'Extra startup arguments appended to the server launch command',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'CS2_INSECURE',
|
||||
label: 'Insecure Mode',
|
||||
default: '',
|
||||
description: 'Toggles the -insecure launch flag inside CS2_ADDITIONAL_ARGS',
|
||||
required: false,
|
||||
inputType: 'boolean',
|
||||
composeInto: 'CS2_ADDITIONAL_ARGS',
|
||||
flagValue: '-insecure',
|
||||
enabledLabel: 'Aktif',
|
||||
disabledLabel: 'Pasif',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'minecraft-bedrock',
|
||||
name: 'Minecraft: Bedrock Edition',
|
||||
dockerImage: 'itzg/minecraft-bedrock-server:latest',
|
||||
defaultPort: 19132,
|
||||
startupCommand: '',
|
||||
stopCommand: 'stop',
|
||||
configFiles: [
|
||||
{
|
||||
path: 'server.properties',
|
||||
parser: 'properties',
|
||||
editableKeys: [
|
||||
'server-name',
|
||||
'server-port',
|
||||
'max-players',
|
||||
'gamemode',
|
||||
'difficulty',
|
||||
'level-seed',
|
||||
'online-mode',
|
||||
'allow-cheats',
|
||||
'view-distance',
|
||||
],
|
||||
},
|
||||
],
|
||||
environmentVars: [
|
||||
{ key: 'EULA', default: 'TRUE', description: 'Accept Minecraft EULA', required: true },
|
||||
{
|
||||
key: 'VERSION',
|
||||
default: 'LATEST',
|
||||
description: 'Bedrock server version',
|
||||
required: true,
|
||||
},
|
||||
{ key: 'SRCDS_RCONPW', default: '', description: 'RCON password', required: false },
|
||||
{ key: 'SRCDS_PW', default: '', description: 'Server password', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'SRCDS_MAXPLAYERS',
|
||||
default: '16',
|
||||
slug: 'terraria',
|
||||
name: 'Terraria',
|
||||
dockerImage: 'ryshe/terraria:latest',
|
||||
defaultPort: 7777,
|
||||
startupCommand: '',
|
||||
stopCommand: 'exit',
|
||||
configFiles: [
|
||||
{
|
||||
path: 'serverconfig.txt',
|
||||
parser: 'keyvalue',
|
||||
editableKeys: [
|
||||
'worldname',
|
||||
'maxplayers',
|
||||
'password',
|
||||
'motd',
|
||||
'difficulty',
|
||||
'worldsize',
|
||||
],
|
||||
},
|
||||
],
|
||||
environmentVars: [
|
||||
{ key: 'WORLD_NAME', default: 'world', description: 'World file name', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'rust',
|
||||
name: 'Rust',
|
||||
dockerImage: 'didstopia/rust-server:latest',
|
||||
defaultPort: 28015,
|
||||
startupCommand: '',
|
||||
stopCommand: 'quit',
|
||||
configFiles: [],
|
||||
environmentVars: [
|
||||
{
|
||||
key: 'RUST_SERVER_NAME',
|
||||
default: 'My Rust Server',
|
||||
description: 'Server name',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'RUST_SERVER_MAXPLAYERS',
|
||||
default: '50',
|
||||
description: 'Max players',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'RUST_SERVER_IDENTITY',
|
||||
default: 'default',
|
||||
description: 'Server identity',
|
||||
required: false,
|
||||
},
|
||||
{ key: 'RUST_RCON_PASSWORD', default: '', description: 'RCON password', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'satisfactory',
|
||||
name: 'Satisfactory',
|
||||
dockerImage: 'wolveix/satisfactory-server:latest',
|
||||
defaultPort: 7777,
|
||||
startupCommand: '',
|
||||
stopCommand: 'quit',
|
||||
configFiles: [],
|
||||
automationRules: [],
|
||||
environmentVars: [
|
||||
{
|
||||
key: 'MAXPLAYERS',
|
||||
default: '4',
|
||||
description: 'Maximum player count',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'STEAMBETA',
|
||||
label: 'Branch',
|
||||
default: 'false',
|
||||
description: 'Use the experimental branch instead of stable',
|
||||
required: false,
|
||||
inputType: 'boolean',
|
||||
enabledLabel: 'Experimental',
|
||||
disabledLabel: 'Stable',
|
||||
},
|
||||
{
|
||||
key: 'AUTOSAVENUM',
|
||||
default: '5',
|
||||
description: 'Number of rotating autosaves',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'MAXTICKRATE',
|
||||
default: '30',
|
||||
description: 'Maximum simulation tick rate',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'fivem',
|
||||
name: 'FiveM',
|
||||
dockerImage: 'spritsail/fivem:latest',
|
||||
defaultPort: 30120,
|
||||
startupCommand: '',
|
||||
stopCommand: 'quit',
|
||||
configFiles: [
|
||||
{
|
||||
path: 'server.cfg',
|
||||
parser: 'keyvalue',
|
||||
editableKeys: [
|
||||
'endpoint_add_tcp',
|
||||
'endpoint_add_udp',
|
||||
'sv_hostname',
|
||||
'sv_scriptHookAllowed',
|
||||
'rcon_password',
|
||||
'sv_endpointprivacy',
|
||||
'sv_maxclients',
|
||||
],
|
||||
},
|
||||
],
|
||||
automationRules: [],
|
||||
environmentVars: [
|
||||
{
|
||||
key: 'LICENSE_KEY',
|
||||
default: '',
|
||||
description: 'Cfx.re server license key required to start FXServer',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
|
||||
await db
|
||||
.update(games)
|
||||
.set({
|
||||
name: 'FiveM',
|
||||
dockerImage: 'spritsail/fivem:latest',
|
||||
defaultPort: 30120,
|
||||
startupCommand: '',
|
||||
stopCommand: 'quit',
|
||||
configFiles: [
|
||||
{
|
||||
path: 'server.cfg',
|
||||
parser: 'keyvalue',
|
||||
editableKeys: [
|
||||
'endpoint_add_tcp',
|
||||
'endpoint_add_udp',
|
||||
'sv_hostname',
|
||||
'sv_scriptHookAllowed',
|
||||
'rcon_password',
|
||||
'sv_endpointprivacy',
|
||||
'sv_maxclients',
|
||||
],
|
||||
},
|
||||
],
|
||||
automationRules: [],
|
||||
environmentVars: [
|
||||
{
|
||||
key: 'LICENSE_KEY',
|
||||
default: '',
|
||||
description: 'Cfx.re server license key required to start FXServer',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(games.slug, 'fivem'));
|
||||
|
||||
console.log('Seed completed successfully!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -46,11 +46,54 @@ message CreateServerRequest {
|
||||
repeated string install_plugin_urls = 9;
|
||||
}
|
||||
|
||||
message UpdateServerRequest {
|
||||
string uuid = 1;
|
||||
string docker_image = 2;
|
||||
int64 memory_limit = 3;
|
||||
int64 disk_limit = 4;
|
||||
int32 cpu_limit = 5;
|
||||
string startup_command = 6;
|
||||
map<string, string> environment = 7;
|
||||
repeated PortMapping ports = 8;
|
||||
}
|
||||
|
||||
message ServerResponse {
|
||||
string uuid = 1;
|
||||
string status = 2;
|
||||
}
|
||||
|
||||
// === Managed Databases ===
|
||||
|
||||
message CreateDatabaseRequest {
|
||||
string server_uuid = 1;
|
||||
string name = 2;
|
||||
string password = 3;
|
||||
}
|
||||
|
||||
message ImportDatabaseSqlRequest {
|
||||
string database_name = 1;
|
||||
string sql = 2;
|
||||
}
|
||||
|
||||
message UpdateDatabasePasswordRequest {
|
||||
string username = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
message DeleteDatabaseRequest {
|
||||
string database_name = 1;
|
||||
string username = 2;
|
||||
}
|
||||
|
||||
message ManagedDatabaseCredentials {
|
||||
string database_name = 1;
|
||||
string username = 2;
|
||||
string password = 3;
|
||||
string host = 4;
|
||||
int32 port = 5;
|
||||
string phpmyadmin_url = 6;
|
||||
}
|
||||
|
||||
// === Power ===
|
||||
|
||||
enum PowerAction {
|
||||
@@ -210,8 +253,13 @@ service DaemonService {
|
||||
|
||||
// Server lifecycle
|
||||
rpc CreateServer(CreateServerRequest) returns (ServerResponse);
|
||||
rpc UpdateServer(UpdateServerRequest) returns (ServerResponse);
|
||||
rpc DeleteServer(ServerIdentifier) returns (Empty);
|
||||
rpc ReinstallServer(ServerIdentifier) returns (Empty);
|
||||
rpc CreateDatabase(CreateDatabaseRequest) returns (ManagedDatabaseCredentials);
|
||||
rpc ImportDatabaseSql(ImportDatabaseSqlRequest) returns (Empty);
|
||||
rpc UpdateDatabasePassword(UpdateDatabasePasswordRequest) returns (Empty);
|
||||
rpc DeleteDatabase(DeleteDatabaseRequest) returns (Empty);
|
||||
|
||||
// Power
|
||||
rpc SetPowerState(PowerRequest) returns (Empty);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
// Proto generated types will be exported here after running `pnpm generate`
|
||||
// For now, this is a placeholder
|
||||
|
||||
export const PROTO_PATH = new URL('../daemon.proto', import.meta.url).pathname;
|
||||
const moduleUrl = (import.meta as ImportMeta & { url: string }).url;
|
||||
|
||||
export const PROTO_PATH = decodeURIComponent(
|
||||
moduleUrl
|
||||
.replace(/^file:\/\//, '')
|
||||
.replace(/\/src\/index\.(ts|js)$/, '/daemon.proto'),
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user