10 Commits

Author SHA1 Message Date
hibna 5215560ede her sey 2026-07-21 22:06:10 +00:00
hibna afc64b83c1 Add panel feature updates across API, daemon, and web 2026-03-02 21:53:54 +00:00
hibna 6b463c2b1a fix(daemon): preserve file ownership on writes for cs2 addons 2026-02-26 22:36:57 +00:00
hibna c7d1627e18 feat: patch cs2 gameinfo after metamod install 2026-02-26 21:21:27 +00:00
hibna 2a3ad5e78f feat: overhaul server automation, files editor, and CS2 setup workflows 2026-02-26 21:01:00 +00:00
hibna 44c439e2f9 feat: wire daemon console/files/config/players and improve runtime fallbacks 2026-02-22 12:09:07 +00:00
hibna 614d25c189 Add internal daemon routes and service management scripts 2026-02-22 10:16:42 +00:00
hibna c9fe2bd9fe fix: resolve frontend routing, API mismatches, and missing UI components
- Add servers list page and missing routes (servers, settings redirect, account security)
- Fix members page .map error (API returns { data } wrapper, not flat array)
- Fix auth store fetchUser expecting flat User but API returns { user } wrapper
- Add node token display dialog after creation
- Add allocation management UI to node detail page
- Add account security page with password change
- Add change-password API endpoint
- Add node servers and stats API endpoints
- Fix config save using PATCH instead of PUT, add api.put method
- Fix audit logs field name mismatch (userName vs username)
- Replace admin nodes page to avoid orgId dependency
- Remove duplicate sidebar nav items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:07:00 +03:00
hibna d7d8fd5339 Fix auth flows and add daemon heartbeat endpoint 2026-02-22 09:41:17 +00:00
hibna c926613ee0 chore: initial commit for main 2026-02-22 09:52:38 +03:00
106 changed files with 20030 additions and 687 deletions
View File
+12
View File
@@ -0,0 +1,12 @@
node_modules
**/node_modules
**/dist
**/target
**/.turbo
.git
.env
.env.*
!.env.example
*.md
.vscode
.idea
+36 -6
View File
@@ -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=
+97
View File
@@ -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 .
+4 -2
View File
@@ -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/
@@ -36,4 +38,4 @@ build/
# Claude
.claude/
plans.md
plans.md
+630
View File
@@ -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 |
+300
View File
@@ -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.
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+201
View File
@@ -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',
);
}
}
+2 -2
View File
@@ -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;
}
+178
View File
@@ -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);
}
+923
View File
@@ -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();
}
}
+740
View File
@@ -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
View File
@@ -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;
}
+943
View File
@@ -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;
}
+2 -3
View File
@@ -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
+22
View File
@@ -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');
});
+346
View File
@@ -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,
},
};
}
+801 -3
View File
@@ -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
+131
View File
@@ -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()),
}),
};
+33
View File
@@ -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;
+16
View File
@@ -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 };
});
}
+184
View File
@@ -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 };
},
);
}
+68
View File
@@ -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,
};
});
}
+105 -2
View File
@@ -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
+97 -23
View File
@@ -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,
},
};
}
+92 -18
View File
@@ -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')
);
}
+343
View File
@@ -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();
});
}
+233
View File
@@ -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
+63
View File
@@ -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
+48 -3
View File
@@ -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,
},
};
}
+1
View File
@@ -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()),
}),
+1
View File
@@ -484,6 +484,7 @@ dependencies = [
"bollard",
"flate2",
"futures",
"libc",
"prost",
"prost-types",
"reqwest",
+1
View File
@@ -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"] }
+29
View File
@@ -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"]
+152
View File
@@ -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(())
}
+15
View File
@@ -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
}
+403 -25
View File
@@ -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"))
}
}
+40
View File
@@ -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
+106 -2
View File
@@ -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(&current, 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(&current, 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,
+91 -22
View File
@@ -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() {
in_player_section = false;
continue;
}
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");
}
}
+157
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+463
View File
@@ -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()),
}
}
+5 -3
View File
@@ -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?;
}
+237 -43
View File
@@ -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,28 +290,45 @@ impl ServerManager {
/// Start a server.
pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> {
let mut servers = self.servers.write().await;
let spec = servers
.get_mut(uuid)
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
if !spec.can_transition_to(&ServerState::Starting) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "starting".to_string(),
});
let mut managed = false;
let mut previous_state: Option<ServerState> = None;
{
let mut servers = self.servers.write().await;
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;
managed = true;
}
}
spec.state = ServerState::Starting;
drop(servers);
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)));
}
self.docker.start_container(uuid).await.map_err(|e| {
DaemonError::Internal(format!("Failed to start container: {}", e))
})?;
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Running;
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(())
@@ -159,28 +336,45 @@ impl ServerManager {
/// Stop a server.
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> {
let mut servers = self.servers.write().await;
let spec = servers
.get_mut(uuid)
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
if !spec.can_transition_to(&ServerState::Stopping) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "stopping".to_string(),
});
let mut managed = false;
let mut previous_state: Option<ServerState> = None;
{
let mut servers = self.servers.write().await;
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;
managed = true;
}
}
spec.state = ServerState::Stopping;
drop(servers);
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)));
}
self.docker.stop_container(uuid, 30).await.map_err(|e| {
DaemonError::Internal(format!("Failed to stop container: {}", e))
})?;
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Stopped;
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(())
+37
View File
@@ -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;"]
+54
View File
@@ -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
View File
@@ -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();
+3 -2
View File
@@ -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] });
},
});
+16
View File
@@ -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
View File
@@ -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) =>
+131
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
)}
+159 -6
View File
@@ -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>
);
}
+70
View File
@@ -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>
);
}
+820
View File
@@ -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&apos;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>
);
}
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+155 -4
View File
@@ -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">
+62 -3
View File
@@ -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}`}>
+28 -16
View File
@@ -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}
+79 -7
View File
@@ -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('');
+320
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+561 -8
View File
@@ -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>
+241 -10
View File
@@ -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>
+90
View File
@@ -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} &middot; {server.nodeName} &middot; :{server.port}
</p>
</div>
</div>
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+73 -5
View File
@@ -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"
+2 -2
View File
@@ -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.
+23
View File
@@ -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"
+28
View File
@@ -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
View File
@@ -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
}
]
}
+1
View File
@@ -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(),
+1
View File
@@ -8,3 +8,4 @@ export * from './backups';
export * from './plugins';
export * from './schedules';
export * from './audit-logs';
export * from './server-databases';
+46
View File
@@ -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
View File
@@ -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 },
],
},
{
slug: 'terraria',
name: 'Terraria',
dockerImage: 'ryshe/terraria:latest',
defaultPort: 7777,
startupCommand: '',
stopCommand: 'exit',
configFiles: [
{
key: 'SRCDS_MAXPLAYERS',
default: '16',
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);
}
+48
View File
@@ -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);
+7 -1
View File
@@ -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