Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ca55bc94d | |||
| 11924416a9 | |||
| 5215560ede | |||
| afc64b83c1 | |||
| 6b463c2b1a | |||
| c7d1627e18 | |||
| 2a3ad5e78f | |||
| 44c439e2f9 | |||
| 614d25c189 | |||
| c9fe2bd9fe | |||
| d7d8fd5339 | |||
| c926613ee0 | |||
| 124e4f8921 | |||
| 5709d8bc10 | |||
| 0941a9ba46 |
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/target
|
||||
**/.turbo
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
+47
-6
@@ -1,17 +1,58 @@
|
||||
# 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
|
||||
DAEMON_TOKEN=CHANGE_ME_GENERATE_A_SECURE_TOKEN
|
||||
# Host directories for game server files and backups. The daemon hands these
|
||||
# exact paths to the host Docker engine when creating game containers, so they
|
||||
# must exist on the host — not inside the daemon container.
|
||||
DAEMON_DATA_PATH=/var/lib/gamepanel/servers
|
||||
DAEMON_BACKUP_PATH=/var/lib/gamepanel/backups
|
||||
|
||||
# --- Managed config persistence ---
|
||||
# How long the panel keeps restoring panel-managed config files after a start.
|
||||
# Steam images can re-validate for a long time before overwriting them.
|
||||
MANAGED_CONFIG_SUSTAIN_MS=1800000
|
||||
|
||||
# --- CDN (Plugin Artifacts) ---
|
||||
CDN_BASE_URL=https://cdn.hibna.com.tr
|
||||
CDN_API_KEY=
|
||||
CDN_PLUGIN_BUCKET=gamepanel-plugin-artifacts
|
||||
CDN_PLUGIN_ARTIFACT_TTL_SECONDS=900
|
||||
CDN_WEBHOOK_SECRET=
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
tags: ["v*"]
|
||||
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 .
|
||||
|
||||
# --- Publish images (tags only) ---
|
||||
#
|
||||
# docker-compose.panel.yml deploys from these images, so the stack can be
|
||||
# installed on a server that has no checkout of this repository — that is
|
||||
# what a control panel needs.
|
||||
#
|
||||
# Plain `docker build` + `docker push` on purpose: no buildx or bake, so the
|
||||
# job runs on the same self-hosted runner as the build test above.
|
||||
#
|
||||
# Requires a REGISTRY_TOKEN secret with package write scope. The registry is
|
||||
# this Gitea instance's own container registry; the panel pulls from it.
|
||||
publish:
|
||||
name: Publish images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, daemon]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
REGISTRY: gits.hibna.com.tr/hibna
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Registry login
|
||||
run: |
|
||||
printf '%s' "${{ secrets.REGISTRY_TOKEN }}" |
|
||||
docker login gits.hibna.com.tr -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Resolve tag
|
||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> "$GITHUB_ENV"
|
||||
|
||||
# The API Dockerfile carries the migration runner as its own stage; it
|
||||
# has to be pushed as a separate image because docker-compose.panel.yml
|
||||
# runs it as a one-shot service before the API starts.
|
||||
- name: API + migrate
|
||||
run: |
|
||||
docker build -f apps/api/Dockerfile -t "$REGISTRY/gamepanel-api:$TAG" .
|
||||
docker build -f apps/api/Dockerfile --target migrate -t "$REGISTRY/gamepanel-migrate:$TAG" .
|
||||
docker push "$REGISTRY/gamepanel-api:$TAG"
|
||||
docker push "$REGISTRY/gamepanel-migrate:$TAG"
|
||||
|
||||
# VITE_API_URL is baked in at build time: the SPA calls /api on its own
|
||||
# origin, which the image's nginx proxies to the api service.
|
||||
- name: Web
|
||||
run: |
|
||||
docker build -f apps/web/Dockerfile --build-arg VITE_API_URL=/api -t "$REGISTRY/gamepanel-web:$TAG" .
|
||||
docker push "$REGISTRY/gamepanel-web:$TAG"
|
||||
|
||||
- name: Daemon
|
||||
run: |
|
||||
docker build -f apps/daemon/Dockerfile -t "$REGISTRY/gamepanel-daemon:$TAG" .
|
||||
docker push "$REGISTRY/gamepanel-daemon:$TAG"
|
||||
+6
-2
@@ -7,6 +7,7 @@ dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
daemon-dev.yml
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -22,7 +23,10 @@ Thumbs.db
|
||||
apps/daemon/target/
|
||||
|
||||
# Database
|
||||
packages/database/drizzle/
|
||||
# Hand-written data migrations in drizzle/*.sql are part of the repo — the
|
||||
# schema itself is applied with `drizzle-kit push`, so only drizzle-kit's local
|
||||
# snapshot files are noise.
|
||||
packages/database/drizzle/meta/*_snapshot.json
|
||||
|
||||
# Common JS/TS
|
||||
coverage/
|
||||
@@ -36,4 +40,4 @@ build/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
plans.md
|
||||
plans.md
|
||||
|
||||
+741
@@ -0,0 +1,741 @@
|
||||
# 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
|
||||
# Sync the schema from packages/database/src/schema, then apply the
|
||||
# hand-written data migrations in packages/database/drizzle/*.sql
|
||||
pnpm db:migrate
|
||||
|
||||
# Seed admin user and default games
|
||||
pnpm db:seed
|
||||
```
|
||||
|
||||
All three steps are idempotent, so re-running them after a `git pull` is the
|
||||
normal way to pick up schema and default-game changes.
|
||||
|
||||
After seeding, you'll have:
|
||||
- **Admin account**: `admin@gamepanel.local` / `admin123`
|
||||
- **Games**: Minecraft Java & Bedrock, CS2, Terraria, Rust, Satisfactory,
|
||||
FiveM, ARK: Survival Evolved
|
||||
|
||||
### 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
|
||||
|
||||
The whole panel comes up with two commands. TLS and domain handling are
|
||||
deliberately **not** included — the panel serves plain HTTP and you put your own
|
||||
reverse proxy in front of it (see 2.6).
|
||||
|
||||
### 2.1 Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/source-gamepanel.git
|
||||
cd source-gamepanel
|
||||
|
||||
./scripts/install.sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`scripts/install.sh` generates `.env` with fresh secrets, writes a
|
||||
`daemon-config.yml` with a matching node token, and creates the host data
|
||||
directories. It never overwrites files that already exist, so it is safe to
|
||||
re-run.
|
||||
|
||||
Then open `http://<server-ip>:80` and sign in with
|
||||
`admin@gamepanel.local` / `admin123` — change the password immediately.
|
||||
|
||||
### 2.2 What gets started
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| `postgres` | internal | PostgreSQL database |
|
||||
| `redis` | internal | Rate limiting & cache |
|
||||
| `migrate` | — | Applies the schema + seed, then exits |
|
||||
| `api` | internal | Fastify REST API |
|
||||
| `web` | `WEB_PORT` (80) | nginx + React SPA, proxies `/api` and `/socket.io` |
|
||||
| `daemon` | `DAEMON_GRPC_PORT` (50051) | Rust gRPC daemon |
|
||||
|
||||
Only `web` and `daemon` publish ports. Postgres, Redis and the API stay on the
|
||||
internal Compose network.
|
||||
|
||||
The `migrate` service runs on every `docker compose up`; all three of its steps
|
||||
(`drizzle-kit push`, the data migrations, the seed) are idempotent.
|
||||
|
||||
### 2.3 Register the node
|
||||
|
||||
In the panel, create a node with:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| FQDN | `host.docker.internal` (or the host's IP/hostname) |
|
||||
| gRPC port | the `DAEMON_GRPC_PORT` from `.env` |
|
||||
| Daemon token | the `DAEMON_TOKEN` from `.env` |
|
||||
|
||||
### 2.4 Where game server files live
|
||||
|
||||
`DAEMON_DATA_PATH` in `.env` (default `/var/lib/gamepanel/servers`) is a **host**
|
||||
directory. The daemon runs in a container but creates game containers through
|
||||
the host's Docker socket, so their bind mounts are resolved by the host, not by
|
||||
the daemon container.
|
||||
|
||||
That is why the same path is passed twice — once as the daemon's own bind mount
|
||||
and once as `DAEMON_HOST_DATA_PATH`. If you change `DAEMON_DATA_PATH`, both
|
||||
follow automatically. Do not replace the bind mount with a named volume: the
|
||||
daemon and the game servers would then read and write two different
|
||||
directories, and files edited in the panel would never reach the game.
|
||||
|
||||
### 2.5 Verify
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f api
|
||||
docker compose logs -f daemon
|
||||
|
||||
curl -s http://localhost/api/health
|
||||
# {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
### 2.6 TLS, domain and reverse proxy
|
||||
|
||||
The panel intentionally ships without certificate handling. Terminate TLS in
|
||||
whatever proxy you already run and forward to `WEB_PORT`. WebSocket upgrades
|
||||
must be forwarded too, otherwise the live console will not connect.
|
||||
|
||||
Set `CORS_ORIGIN` in `.env` to the exact origin users open in the browser, then
|
||||
`docker compose up -d` to apply it.
|
||||
|
||||
Caddy (`Caddyfile`):
|
||||
|
||||
```
|
||||
panel.example.com {
|
||||
reverse_proxy 127.0.0.1:80
|
||||
}
|
||||
```
|
||||
|
||||
nginx:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name panel.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:80;
|
||||
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-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the proxy runs on the same host, bind the panel to loopback only by setting
|
||||
`WEB_PORT=127.0.0.1:8080` in `.env`.
|
||||
|
||||
### 2.7 Updating
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The `migrate` service applies schema and seed changes on every start, so no
|
||||
extra step is needed.
|
||||
|
||||
### 2.8 Upgrading from a pre-`install.sh` deployment
|
||||
|
||||
Older `docker-compose.yml` versions stored the daemon's server directory in a
|
||||
named volume (`daemon_data`). That never matched what the game containers
|
||||
actually used: their bind mounts were resolved by the host, so the real game
|
||||
files ended up in `/var/lib/gamepanel/servers` on the host while the panel read
|
||||
and wrote the named volume. Editing a config in the panel appeared to work and
|
||||
then had no effect, and files could look like they reset themselves.
|
||||
|
||||
The compose file now bind-mounts the host directory directly, so after
|
||||
upgrading, the panel sees the same files the game servers do. Nothing needs to
|
||||
be moved — the game files were already on the host.
|
||||
|
||||
If you had put files into the old named volume through the panel and want them
|
||||
back, copy them out before removing it:
|
||||
|
||||
```bash
|
||||
docker run --rm -v gamepanel_daemon_data:/from -v /var/lib/gamepanel/servers:/to alpine sh -c 'cp -an /from/. /to/'
|
||||
docker volume rm gamepanel_daemon_data gamepanel_daemon_backups
|
||||
```
|
||||
|
||||
Also note that `postgres`, `redis` and `api` no longer publish host ports; only
|
||||
`web` and `daemon` do. If you were proxying straight to `API_PORT`, point your
|
||||
proxy at `WEB_PORT` instead — nginx forwards `/api` and `/socket.io`.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Control Panel Deployment (pre-built images)
|
||||
|
||||
Sections 2 and 3 build from a checkout on the server. A control panel does not
|
||||
have one: it writes a compose file and an `.env` into its own project directory
|
||||
and runs `docker compose up`. Anything with a `build:` stanza fails there —
|
||||
the build context simply is not on disk.
|
||||
|
||||
`docker-compose.panel.yml` exists for that case. Every service references a
|
||||
published image, so the stack installs on a server that has never seen this
|
||||
repository. It was written against [WebPanel](https://gits.hibna.com.tr/hibna/Source-WebPanel)
|
||||
but nothing in it is panel-specific.
|
||||
|
||||
### 4.1 Publish the images
|
||||
|
||||
`.github/workflows/ci.yml` pushes four images to this Gitea instance's own
|
||||
container registry on every `v*` tag:
|
||||
|
||||
| Image | Contents |
|
||||
|---|---|
|
||||
| `gamepanel-api` | Fastify API |
|
||||
| `gamepanel-migrate` | The API Dockerfile's `migrate` stage, run once before the API starts |
|
||||
| `gamepanel-web` | SPA + nginx, built with `VITE_API_URL=/api` |
|
||||
| `gamepanel-daemon` | Rust daemon |
|
||||
|
||||
Add a `REGISTRY_TOKEN` repository secret with package write scope, then:
|
||||
|
||||
```bash
|
||||
git tag v0.1.0 && git push origin v0.1.0
|
||||
```
|
||||
|
||||
### 4.2 Prepare the host
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/gamepanel /var/lib/gamepanel/servers /var/lib/gamepanel/backups
|
||||
sudo cp daemon-config.yml /etc/gamepanel/daemon-config.yml
|
||||
sudo sed -i 's/CHANGE_ME_GENERATE_A_SECURE_TOKEN/'"$(openssl rand -hex 32)"'/' /etc/gamepanel/daemon-config.yml
|
||||
```
|
||||
|
||||
Note the token you generated — the panel needs the same value when you register
|
||||
the node. If the panel has a file manager, both steps can be done from it.
|
||||
|
||||
### 4.3 Install
|
||||
|
||||
Paste `docker-compose.panel.yml` into the panel's custom-compose screen and set:
|
||||
|
||||
| Variable | Example | Notes |
|
||||
|---|---|---|
|
||||
| `REGISTRY` | `gits.hibna.com.tr/hibna` | Namespace holding the four images |
|
||||
| `TAG` | `v0.1.0` | The tag you pushed |
|
||||
| `HOST_PORT` | `8096` | **Not 80** if the panel's own web server owns it |
|
||||
| `DB_PASSWORD`, `REDIS_PASSWORD` | `openssl rand -hex 24` | |
|
||||
| `JWT_SECRET`, `JWT_REFRESH_SECRET` | `openssl rand -hex 64` | |
|
||||
| `CORS_ORIGIN` | `https://panel.example.com` | Must match the address the browser uses |
|
||||
|
||||
The published port is called `HOST_PORT` because panels commonly reverse-proxy
|
||||
"the" port of an installation and need to know which one that is when a stack
|
||||
publishes more than one.
|
||||
|
||||
If the registry is private, the host needs `docker login` once — panels pull
|
||||
anonymously otherwise. On Gitea the package can also be made public while the
|
||||
repository stays private.
|
||||
|
||||
### 4.4 Notes
|
||||
|
||||
- **The daemon holds the Docker socket.** That is root-equivalent access to
|
||||
every container on the machine, the panel's own containers included. Running
|
||||
the daemon on a separate node — which the multi-node architecture is built
|
||||
for — keeps the game hosts and the control plane apart.
|
||||
- **Nothing publishes gRPC on a single host.** The API reaches the daemon over
|
||||
the compose network as `daemon:50051`. A remote node runs the `daemon`
|
||||
service on its own machine and publishes `50051` there.
|
||||
- **Game server ports** are opened by the daemon on the host; a panel with a
|
||||
default-deny firewall needs an explicit rule for the range you hand out.
|
||||
|
||||
---
|
||||
|
||||
## Post-Installation
|
||||
|
||||
### First Login
|
||||
|
||||
1. Open your panel URL in a browser
|
||||
2. Login with: `admin@gamepanel.local` / `admin123`
|
||||
3. **Immediately change the admin password** via account settings
|
||||
|
||||
### Create Your First Server
|
||||
|
||||
1. **Create an Organization** — Click "New Organization" on the home page
|
||||
2. **Add a Node** — Go to Nodes, add your daemon node (FQDN + ports)
|
||||
3. **Add Allocations** — Assign IP:port pairs to the node
|
||||
4. **Create a Server** — Use the creation wizard: pick a game, node, and resources
|
||||
5. **Start the Server** — Use the power controls on the console page
|
||||
|
||||
### Adding a Remote Daemon Node
|
||||
|
||||
On the remote machine:
|
||||
|
||||
```bash
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# Install the daemon binary
|
||||
scp user@panel-server:/usr/local/bin/gamepanel-daemon /usr/local/bin/
|
||||
|
||||
# Configure
|
||||
mkdir -p /etc/gamepanel /var/lib/gamepanel/{servers,backups}
|
||||
|
||||
cat > /etc/gamepanel/config.yml << EOF
|
||||
api_url: "https://panel.yourdomain.com"
|
||||
node_token: "<token-from-panel>"
|
||||
grpc_port: 50051
|
||||
EOF
|
||||
|
||||
# Create systemd service (same as above)
|
||||
# Start it
|
||||
systemctl enable --now gamepanel-daemon
|
||||
```
|
||||
|
||||
Then add the node in the panel with the remote machine's FQDN.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API won't start
|
||||
- Check `DATABASE_URL` is correct and PostgreSQL is running
|
||||
- Ensure migrations have been applied: `pnpm db:migrate`
|
||||
- Check logs: `journalctl -u gamepanel-api -f` or `docker compose logs api`
|
||||
|
||||
### Daemon can't connect
|
||||
- Verify `api_url` in daemon config points to the API
|
||||
- Check `node_token` matches what's stored in the panel's nodes table
|
||||
- Ensure the daemon's gRPC port (50051) is open
|
||||
|
||||
### Web shows blank page
|
||||
- Build the SPA: `pnpm --filter @source/web build`
|
||||
- Check nginx config: `sudo nginx -t`
|
||||
- Verify API proxy is working: `curl http://localhost:3000/api/health`
|
||||
|
||||
### Docker permission denied
|
||||
- Ensure the daemon user is in the `docker` group: `usermod -aG docker <user>`
|
||||
- Or run the daemon with appropriate privileges
|
||||
|
||||
### protoc not found (daemon build)
|
||||
- Ubuntu: `sudo apt install protobuf-compiler`
|
||||
- macOS: `brew install protobuf`
|
||||
- Or download from [github.com/protocolbuffers/protobuf/releases](https://github.com/protocolbuffers/protobuf/releases)
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
cd /opt/source-gamepanel
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
cd /opt/source-gamepanel
|
||||
git pull
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm db:migrate
|
||||
|
||||
# Rebuild daemon
|
||||
cd apps/daemon && cargo build --release
|
||||
sudo cp target/release/gamepanel-daemon /usr/local/bin/
|
||||
|
||||
# Rebuild web
|
||||
cd ../web && pnpm build
|
||||
sudo cp -r dist/* /var/www/gamepanel/
|
||||
|
||||
# Restart services
|
||||
sudo systemctl restart gamepanel-api gamepanel-daemon
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | — | PostgreSQL connection string |
|
||||
| `DB_USER` | `gamepanel` | PostgreSQL username (Docker) |
|
||||
| `DB_PASSWORD` | `gamepanel` | PostgreSQL password (Docker) |
|
||||
| `DB_NAME` | `gamepanel` | Database name (Docker) |
|
||||
| `DB_PORT` | `5432` | PostgreSQL exposed port |
|
||||
| `REDIS_URL` | — | Redis connection string |
|
||||
| `REDIS_PASSWORD` | `gamepanel` | Redis password |
|
||||
| `PORT` | `3000` | API listen port |
|
||||
| `HOST` | `0.0.0.0` | API listen host |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
| `JWT_SECRET` | — | **Required.** Access token signing key |
|
||||
| `JWT_REFRESH_SECRET` | — | **Required.** Refresh token signing key |
|
||||
| `CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin |
|
||||
| `RATE_LIMIT_MAX` | `100` | Max requests per window |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit window (ms) |
|
||||
| `WEB_PORT` | `80` | Web nginx exposed port |
|
||||
| `API_PORT` | `3000` | API exposed port (Docker) |
|
||||
| `DAEMON_CONFIG` | `/etc/gamepanel/config.yml` | Daemon config file path |
|
||||
| `DAEMON_GRPC_PORT` | `50051` | Daemon gRPC exposed port |
|
||||
@@ -0,0 +1,308 @@
|
||||
# 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) | — |
|
||||
| ARK: Survival Evolved | `hermsi/ark-server` | 7777/udp + 7778/udp + 27015/udp + 27020/tcp | `GameUserSettings.ini`, `Game.ini` | — |
|
||||
|
||||
Most games only need a database seed entry: the container mount point, the
|
||||
in-game stop command and the shutdown budget are all columns on `games`, so no
|
||||
daemon change is required for a new image. Games whose process ignores stdin
|
||||
(Source engine, ARK) get their console commands over RCON automatically.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
git clone https://github.com/your-org/source-gamepanel.git
|
||||
cd source-gamepanel
|
||||
|
||||
# Generates .env with fresh secrets + daemon-config.yml, creates data dirs
|
||||
./scripts/install.sh
|
||||
|
||||
# Builds and starts everything; schema migration and seeding run automatically
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open `http://<server-ip>` and sign in with `admin@gamepanel.local` / `admin123`.
|
||||
|
||||
The panel serves plain HTTP on `WEB_PORT` (default 80) and does **not** manage
|
||||
TLS or domains — put your own reverse proxy in front of it and set
|
||||
`CORS_ORIGIN` to the origin users actually open. See
|
||||
[INSTALLATION.md](INSTALLATION.md#26-tls-domain-and-reverse-proxy) for Caddy and
|
||||
nginx examples.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm dev # Start all services (API + Web + DB)
|
||||
pnpm build # Build all packages
|
||||
pnpm lint # Lint all packages
|
||||
pnpm format # Format with Prettier
|
||||
pnpm db:studio # Open Drizzle Studio (DB browser)
|
||||
pnpm db:generate # Generate migration files
|
||||
pnpm db:migrate # Apply migrations
|
||||
pnpm db:seed # Seed admin user + games
|
||||
|
||||
# Daemon (separate terminal)
|
||||
cd apps/daemon
|
||||
cargo run # Requires protoc installed
|
||||
cargo test # Run unit tests
|
||||
cargo clippy # Lint Rust code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is private. All rights reserved.
|
||||
@@ -0,0 +1,64 @@
|
||||
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
|
||||
|
||||
# --- Migrate + seed (one-shot) ---
|
||||
# Schema comes from `drizzle-kit push` against src/schema, then the repo's
|
||||
# data migrations, then the idempotent seed. All three are safe to re-run, so
|
||||
# this container can start on every `docker compose up`.
|
||||
FROM base AS migrate
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./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 pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/database ./packages/database
|
||||
|
||||
WORKDIR /app/packages/database
|
||||
CMD ["sh", "-c", "pnpm exec drizzle-kit push --force && pnpm exec tsx src/migrate.ts && pnpm exec tsx src/seed.ts"]
|
||||
|
||||
# --- Production ---
|
||||
FROM node:20-alpine AS production
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=build /app/apps/api/package.json ./apps/api/
|
||||
COPY --from=build /app/packages/database/dist ./packages/database/dist
|
||||
COPY --from=build /app/packages/database/package.json ./packages/database/
|
||||
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
||||
COPY --from=build /app/packages/shared/package.json ./packages/shared/
|
||||
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY pnpm-workspace.yaml package.json ./
|
||||
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
+14
-1
@@ -12,19 +12,32 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/jwt": "^9.0.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@grpc/grpc-js": "^1.14.0",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@sinclair/typebox": "^0.34.0",
|
||||
"@source/cdn": "1.4.0",
|
||||
"@source/database": "workspace:*",
|
||||
"@source/proto": "workspace:*",
|
||||
"@source/shared": "workspace:*",
|
||||
"argon2": "^0.41.0",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"fastify": "^5.2.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"socket.io": "^4.8.0"
|
||||
"socket.io": "^4.8.0",
|
||||
"tar-stream": "^3.1.7",
|
||||
"unzipper": "^0.12.3",
|
||||
"yazl": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/yazl": "^3.3.0",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
|
||||
+32
-3
@@ -1,13 +1,19 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import cookie from '@fastify/cookie';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import dbPlugin from './plugins/db.js';
|
||||
import authPlugin from './plugins/auth.js';
|
||||
import socketPlugin from './plugins/socket.js';
|
||||
import authRoutes from './routes/auth/index.js';
|
||||
import organizationRoutes from './routes/organizations/index.js';
|
||||
import internalRoutes from './routes/internal/index.js';
|
||||
import daemonNodeRoutes from './routes/nodes/daemon.js';
|
||||
import nodeRoutes from './routes/nodes/index.js';
|
||||
import serverRoutes from './routes/servers/index.js';
|
||||
import adminRoutes from './routes/admin/index.js';
|
||||
import gameRoutes from './routes/games/index.js';
|
||||
import { AppError } from './lib/errors.js';
|
||||
|
||||
const app = Fastify({
|
||||
@@ -19,15 +25,25 @@ const app = Fastify({
|
||||
},
|
||||
});
|
||||
|
||||
// Plugins
|
||||
// Security plugins
|
||||
await app.register(helmet, {
|
||||
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? undefined : false,
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.register(rateLimit, {
|
||||
max: Number(process.env.RATE_LIMIT_MAX) || 100,
|
||||
timeWindow: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000,
|
||||
});
|
||||
|
||||
await app.register(cookie);
|
||||
await app.register(dbPlugin);
|
||||
await app.register(authPlugin);
|
||||
await app.register(socketPlugin);
|
||||
|
||||
// Error handler
|
||||
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
|
||||
@@ -47,10 +63,20 @@ app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number;
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limit errors
|
||||
if (error.statusCode === 429) {
|
||||
return reply.code(429).send({
|
||||
error: 'Too Many Requests',
|
||||
message: 'Rate limit exceeded, please try again later',
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.code(500).send({
|
||||
return reply.code(error.statusCode ?? 500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'An unexpected error occurred',
|
||||
message: process.env.NODE_ENV === 'production'
|
||||
? 'An unexpected error occurred'
|
||||
: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +88,9 @@ app.get('/api/health', async () => {
|
||||
await app.register(authRoutes, { prefix: '/api/auth' });
|
||||
await app.register(organizationRoutes, { prefix: '/api/organizations' });
|
||||
await app.register(adminRoutes, { prefix: '/api/admin' });
|
||||
await app.register(gameRoutes, { prefix: '/api/games' });
|
||||
await app.register(daemonNodeRoutes, { prefix: '/api/nodes' });
|
||||
await app.register(internalRoutes, { prefix: '/api/internal' });
|
||||
|
||||
// Nested org routes: nodes and servers are scoped to an org
|
||||
await app.register(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { CdnClient, CdnError, type FileInfo } from '@source/cdn';
|
||||
import { AppError } from './errors.js';
|
||||
|
||||
const DEFAULT_PLUGIN_BUCKET = 'gamepanel-plugin-artifacts';
|
||||
const DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS = 900;
|
||||
const ARTIFACT_POINTER_PREFIX = 'cdn://file/';
|
||||
|
||||
let cachedClient: CdnClient | null = null;
|
||||
let cachedFingerprint: string | null = null;
|
||||
|
||||
function envValue(name: string): string | null {
|
||||
const value = process.env[name];
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
|
||||
const baseUrl = envValue('CDN_BASE_URL');
|
||||
const apiKey = envValue('CDN_API_KEY');
|
||||
if (!baseUrl || !apiKey) return null;
|
||||
return { baseUrl, apiKey };
|
||||
}
|
||||
|
||||
function getArtifactAccessTtlSeconds(): number {
|
||||
const raw = Number(process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS);
|
||||
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
|
||||
return Math.floor(raw);
|
||||
}
|
||||
|
||||
function getOrCreateClient(): CdnClient | null {
|
||||
const config = getCdnConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const fingerprint = `${config.baseUrl}::${config.apiKey}`;
|
||||
if (cachedClient && cachedFingerprint === fingerprint) return cachedClient;
|
||||
|
||||
cachedClient = new CdnClient({
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
timeoutMs: 45_000,
|
||||
retry: {
|
||||
retries: 2,
|
||||
retryDelayMs: 250,
|
||||
maxRetryDelayMs: 2_000,
|
||||
},
|
||||
});
|
||||
cachedFingerprint = fingerprint;
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
function requireClient(): CdnClient {
|
||||
const client = getOrCreateClient();
|
||||
if (!client) {
|
||||
throw new AppError(
|
||||
500,
|
||||
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
|
||||
'CDN_NOT_CONFIGURED',
|
||||
);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function toCdnAppError(error: unknown, fallbackMessage: string, fallbackCode: string): AppError {
|
||||
if (error instanceof AppError) return error;
|
||||
if (error instanceof CdnError) {
|
||||
return new AppError(502, `CDN error: ${error.message}`, fallbackCode);
|
||||
}
|
||||
return new AppError(502, fallbackMessage, fallbackCode);
|
||||
}
|
||||
|
||||
export function getPluginBucketName(): string {
|
||||
return envValue('CDN_PLUGIN_BUCKET') ?? DEFAULT_PLUGIN_BUCKET;
|
||||
}
|
||||
|
||||
export async function ensurePrivatePluginBucket(): Promise<string> {
|
||||
const client = requireClient();
|
||||
const bucketName = getPluginBucketName();
|
||||
|
||||
try {
|
||||
const bucket = await client.getBucket(bucketName);
|
||||
if (bucket.isPublic) {
|
||||
await client.updateBucket(bucketName, { isPublic: false });
|
||||
}
|
||||
return bucketName;
|
||||
} catch (error) {
|
||||
if (error instanceof CdnError && error.statusCode === 404) {
|
||||
try {
|
||||
await client.createBucket(bucketName, {
|
||||
description: 'GamePanel plugin artifacts',
|
||||
isPublic: false,
|
||||
});
|
||||
return bucketName;
|
||||
} catch (createError) {
|
||||
throw toCdnAppError(
|
||||
createError,
|
||||
'Failed to create CDN plugin bucket',
|
||||
'CDN_BUCKET_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw toCdnAppError(
|
||||
error,
|
||||
'Failed to fetch CDN plugin bucket',
|
||||
'CDN_BUCKET_READ_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCdnArtifactPointer(fileId: string): string {
|
||||
return `${ARTIFACT_POINTER_PREFIX}${fileId}`;
|
||||
}
|
||||
|
||||
export function parseCdnArtifactPointer(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith(ARTIFACT_POINTER_PREFIX)) {
|
||||
const id = trimmed.slice(ARTIFACT_POINTER_PREFIX.length).trim();
|
||||
return id.length > 0 ? id : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === 'cdn:' && parsed.hostname === 'file') {
|
||||
const candidate = parsed.pathname.replace(/^\/+/, '').trim();
|
||||
return candidate.length > 0 ? candidate : null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function uploadPluginArtifact(
|
||||
content: Uint8Array,
|
||||
filename: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<{ bucket: string; file: FileInfo; artifactPointer: string }> {
|
||||
const client = requireClient();
|
||||
const bucket = await ensurePrivatePluginBucket();
|
||||
|
||||
try {
|
||||
const file = await client.upload(content, {
|
||||
bucket,
|
||||
filename,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
bucket,
|
||||
file,
|
||||
artifactPointer: buildCdnArtifactPointer(file.id),
|
||||
};
|
||||
} catch (error) {
|
||||
throw toCdnAppError(error, 'Failed to upload artifact to CDN', 'CDN_UPLOAD_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<string> {
|
||||
const fileId = parseCdnArtifactPointer(artifactUrl);
|
||||
if (!fileId) return artifactUrl;
|
||||
|
||||
const client = requireClient();
|
||||
const config = getCdnConfig();
|
||||
const ttl = getArtifactAccessTtlSeconds();
|
||||
|
||||
try {
|
||||
const access = await client.getFileAccessUrl(fileId, ttl);
|
||||
if (!access.url || typeof access.url !== 'string') {
|
||||
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
|
||||
}
|
||||
|
||||
const resolvedUrl = access.url.trim();
|
||||
if (!resolvedUrl) {
|
||||
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(resolvedUrl)) {
|
||||
return resolvedUrl;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
throw new AppError(
|
||||
500,
|
||||
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
|
||||
'CDN_NOT_CONFIGURED',
|
||||
);
|
||||
}
|
||||
|
||||
return new URL(resolvedUrl, config.baseUrl).toString();
|
||||
} catch (error) {
|
||||
throw toCdnAppError(
|
||||
error,
|
||||
'Failed to get temporary CDN access URL',
|
||||
'CDN_ACCESS_URL_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { ConfigParser, ConfigEntry } from '@source/shared';
|
||||
|
||||
/**
|
||||
* Parse a config file content into key-value entries based on the parser type.
|
||||
*/
|
||||
export function parseConfig(content: string, parser: ConfigParser): ConfigEntry[] {
|
||||
switch (parser) {
|
||||
case 'properties':
|
||||
return parseProperties(content);
|
||||
case 'json':
|
||||
return parseJson(content);
|
||||
case 'yaml':
|
||||
return parseYaml(content);
|
||||
case 'keyvalue':
|
||||
return parseKeyValue(content);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize key-value entries back into a config file content.
|
||||
*/
|
||||
export function serializeConfig(
|
||||
entries: ConfigEntry[],
|
||||
parser: ConfigParser,
|
||||
originalContent?: string,
|
||||
): string {
|
||||
switch (parser) {
|
||||
case 'properties':
|
||||
return serializeProperties(entries, originalContent);
|
||||
case 'json':
|
||||
return serializeJson(entries);
|
||||
case 'yaml':
|
||||
return serializeYaml(entries, originalContent);
|
||||
case 'keyvalue':
|
||||
return serializeKeyValue(entries, originalContent);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// === Properties (Java .properties format) ===
|
||||
|
||||
function parseProperties(content: string): ConfigEntry[] {
|
||||
const entries: ConfigEntry[] = [];
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
entries.push({
|
||||
key: trimmed.substring(0, eqIndex).trim(),
|
||||
value: trimmed.substring(eqIndex + 1).trim(),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function serializeProperties(entries: ConfigEntry[], originalContent?: string): string {
|
||||
if (!originalContent) {
|
||||
return entries.map((e) => `${e.key}=${e.value}`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
const entryMap = new Map(entries.map((e) => [e.key, e.value]));
|
||||
const lines = originalContent.split('\n');
|
||||
const result: string[] = [];
|
||||
const written = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
if (entryMap.has(key)) {
|
||||
result.push(`${key}=${entryMap.get(key)}`);
|
||||
written.add(key);
|
||||
} else {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Append new keys
|
||||
for (const entry of entries) {
|
||||
if (!written.has(entry.key)) {
|
||||
result.push(`${entry.key}=${entry.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
// === JSON ===
|
||||
|
||||
function parseJson(content: string): ConfigEntry[] {
|
||||
try {
|
||||
const obj = JSON.parse(content);
|
||||
if (typeof obj !== 'object' || Array.isArray(obj)) return [];
|
||||
return Object.entries(obj).map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'string' ? value : JSON.stringify(value),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function serializeJson(entries: ConfigEntry[]): string {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
obj[entry.key] = JSON.parse(entry.value);
|
||||
} catch {
|
||||
obj[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(obj, null, 2) + '\n';
|
||||
}
|
||||
|
||||
// === YAML (simplified — only top-level key: value) ===
|
||||
|
||||
function parseYaml(content: string): ConfigEntry[] {
|
||||
const entries: ConfigEntry[] = [];
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
// Only handle top-level keys (no indentation)
|
||||
if (line.startsWith(' ') || line.startsWith('\t')) continue;
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
if (colonIndex === -1) continue;
|
||||
const key = trimmed.substring(0, colonIndex).trim();
|
||||
const value = trimmed.substring(colonIndex + 1).trim();
|
||||
if (key) entries.push({ key, value });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function serializeYaml(entries: ConfigEntry[], originalContent?: string): string {
|
||||
if (!originalContent) {
|
||||
return entries.map((e) => `${e.key}: ${e.value}`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
const entryMap = new Map(entries.map((e) => [e.key, e.value]));
|
||||
const lines = originalContent.split('\n');
|
||||
const result: string[] = [];
|
||||
const written = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || line.startsWith(' ') || line.startsWith('\t')) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
const key = trimmed.substring(0, colonIndex).trim();
|
||||
if (entryMap.has(key)) {
|
||||
result.push(`${key}: ${entryMap.get(key)}`);
|
||||
written.add(key);
|
||||
} else {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!written.has(entry.key)) {
|
||||
result.push(`${entry.key}: ${entry.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
// === KeyValue (Source engine cfg: `key "value"` or `key value`) ===
|
||||
|
||||
function parseKeyValue(content: string): ConfigEntry[] {
|
||||
const entries: ConfigEntry[] = [];
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Match: key "value" or key value
|
||||
const match = trimmed.match(/^(\S+)\s+"([^"]*)"/) || trimmed.match(/^(\S+)\s+(.*)/);
|
||||
if (match && match[1] && match[2] !== undefined) {
|
||||
entries.push({ key: match[1], value: match[2] });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function serializeKeyValue(entries: ConfigEntry[], originalContent?: string): string {
|
||||
if (!originalContent) {
|
||||
return entries.map((e) => `${e.key} "${e.value}"`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
const entryMap = new Map(entries.map((e) => [e.key, e.value]));
|
||||
const lines = originalContent.split('\n');
|
||||
const result: string[] = [];
|
||||
const written = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
const match = trimmed.match(/^(\S+)\s+/);
|
||||
const matchKey = match?.[1];
|
||||
if (matchKey && entryMap.has(matchKey)) {
|
||||
result.push(`${matchKey} "${entryMap.get(matchKey)}"`);
|
||||
written.add(matchKey);
|
||||
} else {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!written.has(entry.key)) {
|
||||
result.push(`${entry.key} "${entry.value}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
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[];
|
||||
data_path: string;
|
||||
stop_command: string;
|
||||
stop_timeout_seconds: number;
|
||||
}
|
||||
|
||||
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[];
|
||||
data_path: string;
|
||||
stop_command: string;
|
||||
stop_timeout_seconds: number;
|
||||
}
|
||||
|
||||
export interface DaemonPowerOptions {
|
||||
/** In-game shutdown command; lets the daemon skip the SIGTERM wait entirely. */
|
||||
stopCommand?: string | null;
|
||||
/** Total graceful-shutdown budget in seconds. */
|
||||
stopTimeoutSeconds?: number | null;
|
||||
}
|
||||
|
||||
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;
|
||||
stop_command: string;
|
||||
stop_timeout_seconds: 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;
|
||||
const MAX_POWER_RPC_TIMEOUT_MS = 360_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,
|
||||
options: DaemonPowerOptions = {},
|
||||
): Promise<void> {
|
||||
const stopTimeoutSeconds = Number(options.stopTimeoutSeconds) > 0
|
||||
? Math.floor(Number(options.stopTimeoutSeconds))
|
||||
: 0;
|
||||
|
||||
// The daemon waits out the shutdown before replying, so the RPC deadline has
|
||||
// to outlive the game's own budget (ARK saves its world for minutes).
|
||||
const rpcTimeoutMs =
|
||||
action === 'stop' || action === 'restart'
|
||||
? Math.min(Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS), MAX_POWER_RPC_TIMEOUT_MS)
|
||||
: POWER_RPC_TIMEOUT_MS;
|
||||
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.setPowerState(
|
||||
{
|
||||
uuid: serverUuid,
|
||||
action: POWER_ACTIONS[action],
|
||||
stop_command: options.stopCommand?.trim() ?? '',
|
||||
stop_timeout_seconds: stopTimeoutSeconds,
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
rpcTimeoutMs,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonGetServerStatus(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
timeouts: DaemonRequestTimeoutOptions = {},
|
||||
): Promise<DaemonStatusResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonStatusResponse>(
|
||||
(callback) =>
|
||||
client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonOpenConsoleStream(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<DaemonConsoleStreamHandle> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const stream = client.streamConsole({ uuid: serverUuid }, getMetadata(node.daemonToken));
|
||||
|
||||
const close = () => {
|
||||
try {
|
||||
stream.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
client.close();
|
||||
};
|
||||
|
||||
stream.on('end', () => client.close());
|
||||
stream.on('error', () => client.close());
|
||||
|
||||
return { stream, close };
|
||||
} catch (error) {
|
||||
client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonSendCommand(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonListFiles(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
path: string,
|
||||
): Promise<DaemonFileEntry[]> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonFileListResponseRaw>(
|
||||
(callback) =>
|
||||
client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return response.files.map((file) => ({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isDirectory: file.is_directory,
|
||||
size: Number(file.size),
|
||||
modifiedAt: Number(file.modified_at),
|
||||
mimeType: file.mime_type,
|
||||
}));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonReadFile(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
path: string,
|
||||
): Promise<{ data: Buffer; mimeType: string }> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonFileContentRaw>(
|
||||
(callback) =>
|
||||
client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
data: toBuffer(response.data),
|
||||
mimeType: response.mime_type,
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonWriteFile(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
path: string,
|
||||
data: string | Buffer,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.writeFile(
|
||||
{
|
||||
uuid: serverUuid,
|
||||
path,
|
||||
data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data,
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteFiles(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
paths: string[],
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonCreateBackup(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
backupId: string,
|
||||
): Promise<DaemonBackupResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonBackupResponseRaw>(
|
||||
(callback) =>
|
||||
client.createBackup(
|
||||
{ server_uuid: serverUuid, backup_id: backupId },
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
backupId: response.backup_id,
|
||||
sizeBytes: Number(response.size_bytes),
|
||||
checksum: response.checksum,
|
||||
success: response.success,
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonRestoreBackup(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
backupId: string,
|
||||
cdnPath?: string | null,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.restoreBackup(
|
||||
{
|
||||
server_uuid: serverUuid,
|
||||
backup_id: backupId,
|
||||
cdn_download_url: cdnPath ?? '',
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteBackup(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
backupId: string,
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.deleteBackup(
|
||||
{ server_uuid: serverUuid, backup_id: backupId },
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonGetActivePlayers(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<DaemonPlayersResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonPlayerListRaw>(
|
||||
(callback) =>
|
||||
client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return {
|
||||
players: response.players.map((player) => ({
|
||||
name: player.name,
|
||||
id: player.uuid,
|
||||
connectedAt: Number(player.connected_at),
|
||||
})),
|
||||
maxPlayers: Number(response.max_players),
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import * as tar from 'tar-stream';
|
||||
import type { Headers } from 'tar-stream';
|
||||
import * as unzipper from 'unzipper';
|
||||
import { serverDatabases, servers } from '@source/database';
|
||||
import {
|
||||
daemonCreateDatabase,
|
||||
daemonDeleteDatabase,
|
||||
daemonDeleteFiles,
|
||||
daemonImportDatabaseSql,
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
|
||||
const GITHUB_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const URL_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const QBCORE_DATABASE_NAME = 'qbcore';
|
||||
const FIVE_M_QBCORE_MARKER_PATH = '/.gamepanel/fivem-qbcore.json';
|
||||
const FIVE_M_INTERNAL_PORT = 30120;
|
||||
const QBCORE_SQL_URL =
|
||||
'https://raw.githubusercontent.com/qbcore-framework/txAdminRecipe/main/qbcore.sql';
|
||||
const OXMYSQL_ZIP_URL =
|
||||
'https://github.com/overextended/oxmysql/releases/download/v2.12.0/oxmysql.zip';
|
||||
const MENUV_ZIP_URL = 'https://github.com/ThymonA/menuv/releases/download/v1.4.1/menuv_v1.4.1.zip';
|
||||
|
||||
interface ExtractedFile {
|
||||
path: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
interface ManagedServerDatabaseRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
databaseName: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
phpMyAdminUrl: string | null;
|
||||
}
|
||||
|
||||
interface FivemProvisionContext {
|
||||
node: DaemonNodeConnection;
|
||||
serverDescription?: string | null;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
serverUuid: string;
|
||||
}
|
||||
|
||||
interface GitHubArchiveResource {
|
||||
destination: string;
|
||||
owner: string;
|
||||
ref: string;
|
||||
repo: string;
|
||||
subpath?: string;
|
||||
}
|
||||
|
||||
interface RemoteArchiveResource {
|
||||
collapseTopLevelDirectory?: boolean;
|
||||
destination: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const FIVEM_GITHUB_RESOURCES: GitHubArchiveResource[] = [
|
||||
{
|
||||
owner: 'citizenfx',
|
||||
repo: 'cfx-server-data',
|
||||
ref: 'master',
|
||||
destination: '/resources/[cfx-default]',
|
||||
subpath: 'resources',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'bob74_ipl',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/bob74_ipl',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'safecracker',
|
||||
ref: 'main',
|
||||
destination: '/resources/[standalone]/safecracker',
|
||||
},
|
||||
{
|
||||
owner: 'citizenfx',
|
||||
repo: 'screenshot-basic',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/screenshot-basic',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'progressbar',
|
||||
ref: 'main',
|
||||
destination: '/resources/[standalone]/progressbar',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'interact-sound',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/interact-sound',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'connectqueue',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/connectqueue',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'PolyZone',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/PolyZone',
|
||||
},
|
||||
{
|
||||
owner: 'AvarianKnight',
|
||||
repo: 'pma-voice',
|
||||
ref: 'main',
|
||||
destination: '/resources/[voice]/pma-voice',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'qb-radio',
|
||||
ref: 'main',
|
||||
destination: '/resources/[voice]/qb-radio',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'hospital_map',
|
||||
ref: 'main',
|
||||
destination: '/resources/[defaultmaps]/hospital_map',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'dealer_map',
|
||||
ref: 'main',
|
||||
destination: '/resources/[defaultmaps]/dealer_map',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'prison_map',
|
||||
ref: 'main',
|
||||
destination: '/resources/[defaultmaps]/prison_map',
|
||||
},
|
||||
...[
|
||||
'qb-core',
|
||||
'qb-scoreboard',
|
||||
'qb-adminmenu',
|
||||
'qb-multicharacter',
|
||||
'qb-target',
|
||||
'qb-vehiclesales',
|
||||
'qb-vehicleshop',
|
||||
'qb-houserobbery',
|
||||
'qb-prison',
|
||||
'qb-hud',
|
||||
'qb-management',
|
||||
'qb-weed',
|
||||
'qb-lapraces',
|
||||
'qb-inventory',
|
||||
'qb-houses',
|
||||
'qb-garages',
|
||||
'qb-ambulancejob',
|
||||
'qb-radialmenu',
|
||||
'qb-crypto',
|
||||
'qb-weathersync',
|
||||
'qb-policejob',
|
||||
'qb-apartments',
|
||||
'qb-vehiclekeys',
|
||||
'qb-mechanicjob',
|
||||
'qb-phone',
|
||||
'qb-vineyard',
|
||||
'qb-weapons',
|
||||
'qb-scrapyard',
|
||||
'qb-towjob',
|
||||
'qb-streetraces',
|
||||
'qb-storerobbery',
|
||||
'qb-spawn',
|
||||
'qb-smallresources',
|
||||
'qb-recyclejob',
|
||||
'qb-crafting',
|
||||
'qb-diving',
|
||||
'qb-cityhall',
|
||||
'qb-truckrobbery',
|
||||
'qb-pawnshop',
|
||||
'qb-minigames',
|
||||
'qb-taxijob',
|
||||
'qb-busjob',
|
||||
'qb-newsjob',
|
||||
'qb-fuel',
|
||||
'qb-jewelery',
|
||||
'qb-bankrobbery',
|
||||
'qb-banking',
|
||||
'qb-clothing',
|
||||
'qb-hotdogjob',
|
||||
'qb-doorlock',
|
||||
'qb-garbagejob',
|
||||
'qb-drugs',
|
||||
'qb-shops',
|
||||
'qb-interior',
|
||||
'qb-menu',
|
||||
'qb-input',
|
||||
'qb-loading',
|
||||
].map((repo) => ({
|
||||
owner: 'qbcore-framework',
|
||||
repo,
|
||||
ref: 'main',
|
||||
destination: `/resources/[qb]/${repo}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const FIVEM_REMOTE_ARCHIVES: RemoteArchiveResource[] = [
|
||||
{
|
||||
url: OXMYSQL_ZIP_URL,
|
||||
destination: '/resources/[standalone]/oxmysql',
|
||||
collapseTopLevelDirectory: true,
|
||||
},
|
||||
{
|
||||
url: MENUV_ZIP_URL,
|
||||
destination: '/resources/[standalone]/menuv',
|
||||
collapseTopLevelDirectory: true,
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePathSegments(path: string): string[] {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||
}
|
||||
|
||||
function normalizeArchivePath(path: string): string | null {
|
||||
const segments = normalizePathSegments(path);
|
||||
if (segments.length === 0) return null;
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function joinServerPath(base: string, relative: string): string {
|
||||
const baseSegments = normalizePathSegments(base);
|
||||
const relativeSegments = normalizePathSegments(relative);
|
||||
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function stripSharedTopLevelDirectory(files: ExtractedFile[]): ExtractedFile[] {
|
||||
if (files.length === 0) return files;
|
||||
|
||||
const firstSegments = new Set<string>();
|
||||
for (const file of files) {
|
||||
const [first] = normalizePathSegments(file.path);
|
||||
if (!first) return files;
|
||||
firstSegments.add(first);
|
||||
if (firstSegments.size > 1) {
|
||||
return files;
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
.map((file) => {
|
||||
const segments = normalizePathSegments(file.path).slice(1);
|
||||
if (segments.length === 0) return null;
|
||||
return {
|
||||
path: segments.join('/'),
|
||||
data: file.data,
|
||||
};
|
||||
})
|
||||
.filter((file): file is ExtractedFile => file !== null);
|
||||
}
|
||||
|
||||
function filterFilesBySubpath(files: ExtractedFile[], subpath: string): ExtractedFile[] {
|
||||
const prefix = normalizePathSegments(subpath).join('/');
|
||||
if (!prefix) return files;
|
||||
|
||||
const normalizedPrefix = `${prefix}/`;
|
||||
return files
|
||||
.map((file) => {
|
||||
if (file.path === prefix) return null;
|
||||
if (!file.path.startsWith(normalizedPrefix)) return null;
|
||||
return {
|
||||
path: file.path.slice(normalizedPrefix.length),
|
||||
data: file.data,
|
||||
};
|
||||
})
|
||||
.filter((file): file is ExtractedFile => file !== null && file.path.length > 0);
|
||||
}
|
||||
|
||||
async function downloadBinary(
|
||||
url: string,
|
||||
maxBytes: number,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<Buffer> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
...headers,
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed (${response.status}): ${url}`);
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length') ?? '0');
|
||||
if (contentLength > maxBytes) {
|
||||
throw new Error(`Download exceeds size limit (${contentLength} > ${maxBytes})`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) {
|
||||
throw new Error(`Downloaded archive is empty: ${url}`);
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new Error(`Download exceeds size limit (${buffer.length} > ${maxBytes})`);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function downloadText(url: string, headers: Record<string, string> = {}): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
...headers,
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Text download failed (${response.status}): ${url}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
throw new Error(`Downloaded text is empty: ${url}`);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
async function extractZipFiles(buffer: Buffer): Promise<ExtractedFile[]> {
|
||||
const archive = await unzipper.Open.buffer(buffer);
|
||||
const files: ExtractedFile[] = [];
|
||||
|
||||
for (const entry of archive.files) {
|
||||
if (entry.type !== 'File') continue;
|
||||
const normalized = normalizeArchivePath(entry.path);
|
||||
if (!normalized) continue;
|
||||
|
||||
files.push({
|
||||
path: normalized,
|
||||
data: await entry.buffer(),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractTarFiles(buffer: Buffer): Promise<ExtractedFile[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const extract = tar.extract();
|
||||
const files: ExtractedFile[] = [];
|
||||
|
||||
extract.on('entry', (header: Headers, stream, next) => {
|
||||
const type = header.type ?? 'file';
|
||||
const normalized = normalizeArchivePath(header.name);
|
||||
const isFileType = type === 'file' || type === 'contiguous-file';
|
||||
|
||||
if (!isFileType || !normalized) {
|
||||
stream.resume();
|
||||
stream.on('end', next);
|
||||
stream.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on('end', () => {
|
||||
files.push({ path: normalized, data: Buffer.concat(chunks) });
|
||||
next();
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
|
||||
extract.on('finish', () => resolve(files));
|
||||
extract.on('error', reject);
|
||||
extract.end(buffer);
|
||||
});
|
||||
}
|
||||
|
||||
async function extractArchive(buffer: Buffer, url: string): Promise<ExtractedFile[]> {
|
||||
const normalizedUrl = url.toLowerCase();
|
||||
if (normalizedUrl.endsWith('.zip')) {
|
||||
return extractZipFiles(buffer);
|
||||
}
|
||||
if (normalizedUrl.endsWith('.tar.gz') || normalizedUrl.endsWith('.tgz')) {
|
||||
return extractTarFiles(gunzipSync(buffer));
|
||||
}
|
||||
if (normalizedUrl.endsWith('.tar')) {
|
||||
return extractTarFiles(buffer);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported archive type: ${url}`);
|
||||
}
|
||||
|
||||
async function writeFilesToServer(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
destination: string,
|
||||
files: ExtractedFile[],
|
||||
): Promise<void> {
|
||||
for (const file of files) {
|
||||
await daemonWriteFile(node, serverUuid, joinServerPath(destination, file.path), file.data);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeCfgValue(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function buildMysqlConnectionString(database: ManagedServerDatabaseRecord): string {
|
||||
return `mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}?charset=utf8mb4`;
|
||||
}
|
||||
|
||||
function renderFivemServerConfig(
|
||||
serverName: string,
|
||||
description: string | null | undefined,
|
||||
database: ManagedServerDatabaseRecord,
|
||||
): string {
|
||||
const safeServerName = escapeCfgValue(serverName.trim() || 'QBCore Server');
|
||||
const safeProjectDescription = escapeCfgValue(
|
||||
description?.trim() || 'QBCore server provisioned by Source GamePanel.',
|
||||
);
|
||||
const rconPassword = randomBytes(16).toString('hex');
|
||||
const mysqlConnectionString = escapeCfgValue(buildMysqlConnectionString(database));
|
||||
|
||||
return `# Generated by Source GamePanel
|
||||
# QBCore resources and base dependencies are installed automatically.
|
||||
|
||||
endpoint_add_tcp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
|
||||
endpoint_add_udp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
|
||||
|
||||
sv_maxclients "32"
|
||||
sv_hostname "${safeServerName}"
|
||||
sets sv_projectName "[QBCore] ${safeServerName}"
|
||||
sets sv_projectDesc "${safeProjectDescription}"
|
||||
sets locale "en-US"
|
||||
sets tags "qbcore, qb-core, roleplay, source-gamepanel"
|
||||
set steam_webApiKey "none"
|
||||
set resources_useSystemChat "true"
|
||||
set mysql_connection_string "${mysqlConnectionString}"
|
||||
|
||||
setr qb_locale "en"
|
||||
setr UseTarget "false"
|
||||
setr voice_useNativeAudio "true"
|
||||
setr voice_useSendingRangeOnly "true"
|
||||
setr voice_defaultCycle "GRAVE"
|
||||
setr voice_defaultVolume "0.3"
|
||||
setr voice_enableRadioAnim "1"
|
||||
setr voice_syncData "1"
|
||||
|
||||
sv_scriptHookAllowed "0"
|
||||
sv_endpointprivacy "true"
|
||||
rcon_password "${rconPassword}"
|
||||
|
||||
ensure mapmanager
|
||||
ensure chat
|
||||
ensure spawnmanager
|
||||
ensure sessionmanager
|
||||
ensure basic-gamemode
|
||||
ensure hardcap
|
||||
ensure baseevents
|
||||
|
||||
ensure qb-core
|
||||
ensure [qb]
|
||||
ensure [standalone]
|
||||
ensure [voice]
|
||||
ensure [defaultmaps]
|
||||
|
||||
add_ace group.admin command allow
|
||||
add_ace group.admin command.quit deny
|
||||
add_ace resource.qb-core command allow
|
||||
add_ace qbcore.god command allow
|
||||
add_principal qbcore.god group.admin
|
||||
add_principal qbcore.god qbcore.admin
|
||||
add_principal qbcore.admin qbcore.mod
|
||||
`;
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('Server responded with NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
|
||||
export function isFivemQbCoreGame(gameSlug: string): boolean {
|
||||
return gameSlug.trim().toLowerCase() === 'fivem';
|
||||
}
|
||||
|
||||
export async function ensureFivemQbCoreDatabase(
|
||||
app: FastifyInstance,
|
||||
context: Pick<FivemProvisionContext, 'node' | 'serverId' | 'serverUuid'>,
|
||||
): Promise<ManagedServerDatabaseRecord> {
|
||||
const existing = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.where(eq(serverDatabases.serverId, context.serverId))
|
||||
.orderBy(asc(serverDatabases.createdAt));
|
||||
|
||||
const preferred =
|
||||
existing.find((database) => database.name.trim().toLowerCase() === QBCORE_DATABASE_NAME) ??
|
||||
existing[0];
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const managedDatabase = await daemonCreateDatabase(context.node, {
|
||||
serverUuid: context.serverUuid,
|
||||
name: QBCORE_DATABASE_NAME,
|
||||
});
|
||||
|
||||
try {
|
||||
const [created] = await app.db
|
||||
.insert(serverDatabases)
|
||||
.values({
|
||||
serverId: context.serverId,
|
||||
name: QBCORE_DATABASE_NAME,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
password: managedDatabase.password,
|
||||
host: managedDatabase.host,
|
||||
port: managedDatabase.port,
|
||||
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
|
||||
})
|
||||
.returning({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
throw new Error('Failed to persist managed database metadata');
|
||||
}
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
try {
|
||||
await daemonDeleteDatabase(context.node, {
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
app.log.error(
|
||||
{
|
||||
cleanupError,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
},
|
||||
'Failed to roll back managed MySQL database after metadata save failure',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFivemQbCoreDatabase(
|
||||
app: FastifyInstance,
|
||||
context: Pick<FivemProvisionContext, 'node' | 'serverId'>,
|
||||
): Promise<void> {
|
||||
const [database] = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.where(
|
||||
and(
|
||||
eq(serverDatabases.serverId, context.serverId),
|
||||
eq(serverDatabases.name, QBCORE_DATABASE_NAME),
|
||||
),
|
||||
);
|
||||
|
||||
if (!database) return;
|
||||
|
||||
await daemonDeleteDatabase(context.node, {
|
||||
databaseName: database.databaseName,
|
||||
username: database.username,
|
||||
});
|
||||
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, database.id));
|
||||
}
|
||||
|
||||
async function installGitHubResource(
|
||||
app: FastifyInstance,
|
||||
context: FivemProvisionContext,
|
||||
resource: GitHubArchiveResource,
|
||||
): Promise<void> {
|
||||
const archiveUrl = `https://codeload.github.com/${resource.owner}/${resource.repo}/tar.gz/refs/heads/${encodeURIComponent(resource.ref)}`;
|
||||
const archive = await downloadBinary(archiveUrl, GITHUB_ARCHIVE_MAX_BYTES);
|
||||
let files = await extractTarFiles(gunzipSync(archive));
|
||||
files = stripSharedTopLevelDirectory(files);
|
||||
if (resource.subpath) {
|
||||
files = filterFilesBySubpath(files, resource.subpath);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new Error(
|
||||
`GitHub archive had no files: ${resource.owner}/${resource.repo}@${resource.ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
destination: resource.destination,
|
||||
filesWritten: files.length,
|
||||
repo: `${resource.owner}/${resource.repo}`,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
},
|
||||
'Installed FiveM GitHub resource',
|
||||
);
|
||||
}
|
||||
|
||||
async function installRemoteArchive(
|
||||
app: FastifyInstance,
|
||||
context: FivemProvisionContext,
|
||||
resource: RemoteArchiveResource,
|
||||
): Promise<void> {
|
||||
const archive = await downloadBinary(resource.url, URL_ARCHIVE_MAX_BYTES);
|
||||
let files = await extractArchive(archive, resource.url);
|
||||
if (resource.collapseTopLevelDirectory) {
|
||||
files = stripSharedTopLevelDirectory(files);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Remote archive had no files: ${resource.url}`);
|
||||
}
|
||||
|
||||
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
destination: resource.destination,
|
||||
filesWritten: files.length,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
url: resource.url,
|
||||
},
|
||||
'Installed FiveM remote archive',
|
||||
);
|
||||
}
|
||||
|
||||
export async function provisionFivemQbCoreServer(
|
||||
app: FastifyInstance,
|
||||
context: FivemProvisionContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await daemonReadFile(context.node, context.serverUuid, FIVE_M_QBCORE_MARKER_PATH);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const database = await ensureFivemQbCoreDatabase(app, context);
|
||||
const qbCoreSql = await downloadText(QBCORE_SQL_URL);
|
||||
|
||||
await daemonImportDatabaseSql(context.node, {
|
||||
databaseName: database.databaseName,
|
||||
sql: qbCoreSql,
|
||||
});
|
||||
|
||||
for (const resource of FIVEM_GITHUB_RESOURCES) {
|
||||
await installGitHubResource(app, context, resource);
|
||||
}
|
||||
|
||||
for (const resource of FIVEM_REMOTE_ARCHIVES) {
|
||||
await installRemoteArchive(app, context, resource);
|
||||
}
|
||||
|
||||
try {
|
||||
await daemonDeleteFiles(context.node, context.serverUuid, [
|
||||
'/resources/[cfx-default]/[gameplay]/chat',
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await daemonWriteFile(
|
||||
context.node,
|
||||
context.serverUuid,
|
||||
'/server.cfg',
|
||||
renderFivemServerConfig(context.serverName, context.serverDescription, database),
|
||||
);
|
||||
|
||||
await daemonWriteFile(
|
||||
context.node,
|
||||
context.serverUuid,
|
||||
FIVE_M_QBCORE_MARKER_PATH,
|
||||
JSON.stringify(
|
||||
{
|
||||
installedAt: new Date().toISOString(),
|
||||
manifestVersion: 1,
|
||||
resourceCount: FIVEM_GITHUB_RESOURCES.length + FIVEM_REMOTE_ARCHIVES.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await app.db
|
||||
.update(servers)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(servers.id, context.serverId));
|
||||
}
|
||||
+15
-3
@@ -15,13 +15,25 @@ const ACCESS_TOKEN_EXPIRY = '15m';
|
||||
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||
|
||||
export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string {
|
||||
return app.jwt.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY });
|
||||
const signer = (app as any).jwt?.sign;
|
||||
if (typeof signer !== 'function') {
|
||||
throw new Error('JWT signer is not configured');
|
||||
}
|
||||
return signer(payload, { expiresIn: ACCESS_TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string {
|
||||
return (app as any).jwtRefresh.sign(payload, { expiresIn: REFRESH_TOKEN_EXPIRY });
|
||||
const signer = (app as any).jwt?.refresh?.sign ?? (app as any).jwt?.jwtRefresh?.sign;
|
||||
if (typeof signer !== 'function') {
|
||||
throw new Error('Refresh JWT signer is not configured');
|
||||
}
|
||||
return signer(payload, { expiresIn: REFRESH_TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload {
|
||||
return (app as any).jwtRefresh.verify(token) as RefreshTokenPayload;
|
||||
const verifier = (app as any).jwt?.refresh?.verify ?? (app as any).jwt?.jwtRefresh?.verify;
|
||||
if (typeof verifier !== 'function') {
|
||||
throw new Error('Refresh JWT verifier is not configured');
|
||||
}
|
||||
return verifier(token) as RefreshTokenPayload;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
|
||||
/**
|
||||
* Some game images run a SteamCMD `app_update ... validate` on every container
|
||||
* start, which rewrites config files that ship with the game back to their
|
||||
* stock contents. The panel therefore keeps its own copy of every managed
|
||||
* config file in a hidden sidecar next to the real one, and restores the real
|
||||
* file whenever the game resets it.
|
||||
*/
|
||||
export interface ManagedConfigFile {
|
||||
/** Path of the real file, relative to the server data directory. */
|
||||
path: string;
|
||||
/** Sidecar holding the panel's copy of record. */
|
||||
shadowPath: string;
|
||||
/** Base name of the sidecar, so the file browser can hide it. */
|
||||
shadowFileName: string;
|
||||
/** Written when neither the real file nor the sidecar exists yet. */
|
||||
defaultContent: string;
|
||||
/**
|
||||
* Stock contents shipped by the image. When the sidecar is adopted from an
|
||||
* existing install, contents matching one of these are replaced by
|
||||
* `defaultContent` instead of being preserved.
|
||||
*/
|
||||
imageDefaults: string[];
|
||||
}
|
||||
|
||||
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
|
||||
`;
|
||||
|
||||
const MANAGED_CONFIG_FILES: Record<string, ManagedConfigFile[]> = {
|
||||
cs2: [
|
||||
{
|
||||
path: CS2_SERVER_CFG_PATH,
|
||||
shadowPath: CS2_PERSISTED_SERVER_CFG_PATH,
|
||||
shadowFileName: CS2_PERSISTED_SERVER_CFG_FILE,
|
||||
defaultContent: DEFAULT_CS2_SERVER_CFG,
|
||||
imageDefaults: [LEGACY_IMAGE_CS2_SERVER_CFG],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
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 managedConfigFilesForGame(gameSlug: string): ManagedConfigFile[] {
|
||||
return MANAGED_CONFIG_FILES[gameSlug.trim().toLowerCase()] ?? [];
|
||||
}
|
||||
|
||||
/** The managed file a request path refers to, or `null` if it is not managed. */
|
||||
export function managedConfigFileFor(
|
||||
gameSlug: string,
|
||||
path: string,
|
||||
): ManagedConfigFile | null {
|
||||
const normalized = normalizePath(path);
|
||||
return (
|
||||
managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function isManagedConfigShadowFile(gameSlug: string, fileName: string): boolean {
|
||||
const normalized = fileName.trim();
|
||||
return managedConfigFilesForGame(gameSlug).some(
|
||||
(file) => file.shadowFileName === normalized,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the panel's copy of a managed config file, adopting whatever is on disk
|
||||
* the first time around.
|
||||
*/
|
||||
export async function readManagedConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
file: ManagedConfigFile,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const persisted = await daemonReadFile(node, serverUuid, file.shadowPath);
|
||||
return persisted.data.toString('utf8');
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const current = await daemonReadFile(node, serverUuid, file.path);
|
||||
const content = current.data.toString('utf8');
|
||||
const isStockContent = file.imageDefaults.some(
|
||||
(stock) => normalizeComparableContent(stock) === normalizeComparableContent(content),
|
||||
);
|
||||
const nextContent = isStockContent ? file.defaultContent : content;
|
||||
await daemonWriteFile(node, serverUuid, file.shadowPath, nextContent);
|
||||
return nextContent;
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
await daemonWriteFile(node, serverUuid, file.shadowPath, file.defaultContent);
|
||||
return file.defaultContent;
|
||||
}
|
||||
|
||||
/** Write a managed config file, keeping the panel's copy in sync. */
|
||||
export async function writeManagedConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
file: ManagedConfigFile,
|
||||
content: string | Buffer,
|
||||
): Promise<void> {
|
||||
await daemonWriteFile(node, serverUuid, file.shadowPath, content);
|
||||
await daemonWriteFile(node, serverUuid, file.path, content);
|
||||
}
|
||||
|
||||
// === Drift watcher ===
|
||||
|
||||
/**
|
||||
* How long to keep watching after a start. This has to outlast the image's own
|
||||
* update/validate step — for CS2 that is a multi-gigabyte SteamCMD run that can
|
||||
* easily take 10+ minutes on a cold cache, and it rewrites `server.cfg` when it
|
||||
* finishes. Watching for only a minute is why edited configs kept coming back.
|
||||
*/
|
||||
const SUSTAIN_WINDOW_MS = Number(process.env.MANAGED_CONFIG_SUSTAIN_MS) || 30 * 60_000;
|
||||
const FAST_INTERVAL_MS = 5_000;
|
||||
const SLOW_INTERVAL_MS = 20_000;
|
||||
const FAST_PHASE_MS = 2 * 60_000;
|
||||
/** Consecutive drift-free polls needed before the watcher stops early. */
|
||||
const REQUIRED_STABLE_ROUNDS = 6;
|
||||
/** Never stop early before this much of the window has elapsed. */
|
||||
const MIN_WATCH_MS = 3 * 60_000;
|
||||
|
||||
/** One watcher per server; a newer start supersedes the one already running. */
|
||||
const activeWatchers = new Map<string, symbol>();
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function restoreDriftedFile(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
file: ManagedConfigFile,
|
||||
): Promise<boolean> {
|
||||
const expected = await readManagedConfig(node, serverUuid, file);
|
||||
|
||||
let live: string | null = null;
|
||||
try {
|
||||
const current = await daemonReadFile(node, serverUuid, file.path);
|
||||
live = current.data.toString('utf8');
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
if (live !== null && normalizeComparableContent(live) === normalizeComparableContent(expected)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await daemonWriteFile(node, serverUuid, file.path, expected);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Restore every managed config file for a game to the panel's copy. */
|
||||
export async function reapplyManagedConfigs(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
gameSlug: string,
|
||||
): Promise<void> {
|
||||
for (const file of managedConfigFilesForGame(gameSlug)) {
|
||||
await restoreDriftedFile(node, serverUuid, file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a server's managed config files after a start and put the panel's
|
||||
* version back whenever the game overwrites it.
|
||||
*
|
||||
* `isServerActive` lets the caller abort once the server leaves the running
|
||||
* state, so a stopped server never gets its files rewritten behind its back.
|
||||
*/
|
||||
export function sustainManagedConfigsAfterStart(
|
||||
app: FastifyInstance,
|
||||
options: {
|
||||
node: DaemonNodeConnection;
|
||||
serverId: string;
|
||||
serverUuid: string;
|
||||
gameSlug: string;
|
||||
isServerActive: () => Promise<boolean>;
|
||||
},
|
||||
): void {
|
||||
const files = managedConfigFilesForGame(options.gameSlug);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const token = Symbol(options.serverId);
|
||||
activeWatchers.set(options.serverId, token);
|
||||
|
||||
void (async () => {
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + SUSTAIN_WINDOW_MS;
|
||||
let stableRounds = 0;
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
await sleep(elapsed < FAST_PHASE_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
|
||||
|
||||
if (activeWatchers.get(options.serverId) !== token) return;
|
||||
|
||||
let active: boolean;
|
||||
try {
|
||||
active = await options.isServerActive();
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{ error, serverId: options.serverId },
|
||||
'Managed config watcher could not read server status',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
app.log.debug(
|
||||
{ serverId: options.serverId },
|
||||
'Managed config watcher stopping: server is no longer running',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let drifted = false;
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (await restoreDriftedFile(options.node, options.serverUuid, file)) {
|
||||
drifted = true;
|
||||
app.log.info(
|
||||
{
|
||||
serverId: options.serverId,
|
||||
serverUuid: options.serverUuid,
|
||||
gameSlug: options.gameSlug,
|
||||
path: file.path,
|
||||
},
|
||||
'Restored managed config file after the game reset it',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{
|
||||
error,
|
||||
serverId: options.serverId,
|
||||
serverUuid: options.serverUuid,
|
||||
path: file.path,
|
||||
},
|
||||
'Failed to restore managed config file',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stableRounds = drifted ? 0 : stableRounds + 1;
|
||||
|
||||
if (
|
||||
stableRounds >= REQUIRED_STABLE_ROUNDS &&
|
||||
Date.now() - startedAt >= MIN_WATCH_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (activeWatchers.get(options.serverId) === token) {
|
||||
activeWatchers.delete(options.serverId);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Compute the next run time for a scheduled task.
|
||||
*/
|
||||
export function computeNextRun(
|
||||
scheduleType: string,
|
||||
scheduleData: Record<string, unknown>,
|
||||
): Date {
|
||||
const now = new Date();
|
||||
|
||||
switch (scheduleType) {
|
||||
case 'interval': {
|
||||
const minutes = Number(scheduleData.minutes) || 60;
|
||||
return new Date(now.getTime() + minutes * 60_000);
|
||||
}
|
||||
|
||||
case 'daily': {
|
||||
const hour = Number(scheduleData.hour ?? 0);
|
||||
const minute = Number(scheduleData.minute ?? 0);
|
||||
const next = new Date(now);
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
case 'weekly': {
|
||||
const dayOfWeek = Number(scheduleData.dayOfWeek ?? 0); // 0=Sunday
|
||||
const hour = Number(scheduleData.hour ?? 0);
|
||||
const minute = Number(scheduleData.minute ?? 0);
|
||||
const next = new Date(now);
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
const currentDay = next.getDay();
|
||||
let daysAhead = dayOfWeek - currentDay;
|
||||
if (daysAhead < 0 || (daysAhead === 0 && next <= now)) {
|
||||
daysAhead += 7;
|
||||
}
|
||||
next.setDate(next.getDate() + daysAhead);
|
||||
return next;
|
||||
}
|
||||
|
||||
case 'cron': {
|
||||
// Simple cron parser for: minute hour dayOfMonth month dayOfWeek
|
||||
const expression = String(scheduleData.expression || '0 * * * *');
|
||||
return parseCronNextRun(expression, now);
|
||||
}
|
||||
|
||||
default:
|
||||
return new Date(now.getTime() + 3600_000); // fallback: 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
function parseCronNextRun(expression: string, from: Date): Date {
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
const cronMinute = parts[0] ?? '*';
|
||||
const cronHour = parts[1] ?? '*';
|
||||
const cronDom = parts[2] ?? '*';
|
||||
const cronMonth = parts[3] ?? '*';
|
||||
const cronDow = parts[4] ?? '*';
|
||||
|
||||
// Brute force: check next 1440 minutes (24 hours)
|
||||
const candidate = new Date(from);
|
||||
candidate.setSeconds(0, 0);
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
|
||||
for (let i = 0; i < 1440 * 31; i++) {
|
||||
if (
|
||||
matchesCronField(cronMinute, candidate.getMinutes()) &&
|
||||
matchesCronField(cronHour, candidate.getHours()) &&
|
||||
matchesCronField(cronDom, candidate.getDate()) &&
|
||||
matchesCronField(cronMonth, candidate.getMonth() + 1) &&
|
||||
matchesCronField(cronDow, candidate.getDay())
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
}
|
||||
|
||||
// Fallback if no match found
|
||||
return new Date(from.getTime() + 3600_000);
|
||||
}
|
||||
|
||||
function matchesCronField(field: string, value: number): boolean {
|
||||
if (field === '*') return true;
|
||||
|
||||
// Handle step values: */5
|
||||
if (field.startsWith('*/')) {
|
||||
const step = parseInt(field.slice(2), 10);
|
||||
return step > 0 && value % step === 0;
|
||||
}
|
||||
|
||||
// Handle ranges: 1-5
|
||||
if (field.includes('-')) {
|
||||
const [min, max] = field.split('-').map(Number);
|
||||
return min !== undefined && max !== undefined && value >= min && value <= max;
|
||||
}
|
||||
|
||||
// Handle lists: 1,3,5
|
||||
if (field.includes(',')) {
|
||||
return field.split(',').map(Number).includes(value);
|
||||
}
|
||||
|
||||
// Exact match
|
||||
return parseInt(field, 10) === value;
|
||||
}
|
||||
@@ -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 './managed-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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const SPIGET_BASE = 'https://api.spiget.org/v2';
|
||||
|
||||
export interface SpigetResource {
|
||||
id: number;
|
||||
name: string;
|
||||
tag: string;
|
||||
icon: { url: string; data: string };
|
||||
releaseDate: number;
|
||||
updateDate: number;
|
||||
downloads: number;
|
||||
rating: { average: number; count: number };
|
||||
file: { type: string; size: number; url: string };
|
||||
version: { id: number };
|
||||
external: boolean;
|
||||
}
|
||||
|
||||
export interface SpigetVersion {
|
||||
id: number;
|
||||
name: string;
|
||||
releaseDate: number;
|
||||
downloads: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function searchSpigetPlugins(
|
||||
query: string,
|
||||
page = 1,
|
||||
size = 20,
|
||||
): Promise<SpigetResource[]> {
|
||||
const res = await fetch(
|
||||
`${SPIGET_BASE}/search/resources/${encodeURIComponent(query)}?size=${size}&page=${page}&sort=-downloads`,
|
||||
{ headers: { 'User-Agent': 'GamePanel/1.0' } },
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<SpigetResource[]>;
|
||||
}
|
||||
|
||||
export async function getSpigetResource(id: number): Promise<SpigetResource | null> {
|
||||
const res = await fetch(`${SPIGET_BASE}/resources/${id}`, {
|
||||
headers: { 'User-Agent': 'GamePanel/1.0' },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<SpigetResource>;
|
||||
}
|
||||
|
||||
export async function getSpigetVersions(resourceId: number): Promise<SpigetVersion[]> {
|
||||
const res = await fetch(`${SPIGET_BASE}/resources/${resourceId}/versions?sort=-releaseDate`, {
|
||||
headers: { 'User-Agent': 'GamePanel/1.0' },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
return res.json() as Promise<SpigetVersion[]>;
|
||||
}
|
||||
|
||||
export function getSpigetDownloadUrl(resourceId: number): string {
|
||||
return `${SPIGET_BASE}/resources/${resourceId}/download`;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { AccessTokenPayload } from '../lib/jwt.js';
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
jwtRefresh: FastifyInstance['jwt'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +27,13 @@ export default fp(async (app: FastifyInstance) => {
|
||||
// Access token JWT
|
||||
await app.register(jwt, {
|
||||
secret: jwtSecret,
|
||||
namespace: 'jwt',
|
||||
});
|
||||
|
||||
// Refresh token JWT (separate namespace)
|
||||
await app.register(jwt, {
|
||||
secret: jwtRefreshSecret,
|
||||
namespace: 'jwtRefresh',
|
||||
namespace: 'refresh',
|
||||
decoratorName: 'jwtRefresh',
|
||||
});
|
||||
|
||||
// Auth decorator
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { createDb, type Database } from '@source/database';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -17,5 +18,26 @@ export default fp(async (app: FastifyInstance) => {
|
||||
const db = createDb(databaseUrl);
|
||||
app.decorate('db', db);
|
||||
|
||||
await db.execute(sql.raw(`
|
||||
CREATE TABLE IF NOT EXISTS server_databases (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name varchar(255) NOT NULL,
|
||||
database_name varchar(255) NOT NULL UNIQUE,
|
||||
username varchar(64) NOT NULL UNIQUE,
|
||||
password text NOT NULL,
|
||||
host varchar(255) NOT NULL,
|
||||
port integer NOT NULL,
|
||||
phpmyadmin_url text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`));
|
||||
await db.execute(
|
||||
sql.raw(
|
||||
'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)',
|
||||
),
|
||||
);
|
||||
|
||||
app.log.info('Database connected');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
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',
|
||||
);
|
||||
// The daemon explains *why* (server not running, no RCON password, …) —
|
||||
// showing that beats a generic failure the user cannot act on.
|
||||
const reason = daemonErrorReason(error);
|
||||
socket.emit('server:console:output', { line: `[error] ${reason}` });
|
||||
const ack: ConsoleCommandAck = { requestId, ok: false, error: reason };
|
||||
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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Strip the gRPC status prefix so the console shows the daemon's own wording. */
|
||||
function daemonErrorReason(error: unknown): string {
|
||||
const raw = error instanceof Error ? error.message.trim() : '';
|
||||
if (!raw) return 'Failed to send command';
|
||||
|
||||
const withoutStatus = raw.replace(/^\d+\s+[A-Z_]+:\s*/, '').trim();
|
||||
return withoutStatus || 'Failed to send command';
|
||||
}
|
||||
|
||||
async function hasConsolePermission(
|
||||
app: FastifyInstance,
|
||||
user: AccessTokenPayload,
|
||||
orgId: string,
|
||||
permission: ConsolePermission,
|
||||
): Promise<boolean> {
|
||||
if (user.isSuperAdmin) return true;
|
||||
|
||||
const member = await app.db.query.organizationMembers.findFirst({
|
||||
where: and(
|
||||
eq(organizationMembers.organizationId, orgId),
|
||||
eq(organizationMembers.userId, user.sub),
|
||||
),
|
||||
columns: {
|
||||
role: true,
|
||||
customPermissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) return false;
|
||||
|
||||
const custom = (member.customPermissions ?? {}) as Record<string, boolean>;
|
||||
if (permission in custom) {
|
||||
return Boolean(custom[permission]);
|
||||
}
|
||||
|
||||
const rolePerms = ROLES[member.role as Role]?.permissions ?? [];
|
||||
return (rolePerms as readonly string[]).includes(permission);
|
||||
}
|
||||
|
||||
async function getServerContext(
|
||||
app: FastifyInstance,
|
||||
serverId: string,
|
||||
orgId?: string,
|
||||
): Promise<{
|
||||
organizationId: string;
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
} | null> {
|
||||
const whereClause = orgId
|
||||
? and(eq(servers.id, serverId), eq(servers.organizationId, orgId))
|
||||
: eq(servers.id, serverId);
|
||||
|
||||
const [row] = await app.db
|
||||
.select({
|
||||
organizationId: servers.organizationId,
|
||||
serverUuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(whereClause);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
serverUuid: row.serverUuid,
|
||||
node: {
|
||||
fqdn: row.nodeFqdn,
|
||||
grpcPort: row.nodeGrpcPort,
|
||||
daemonToken: row.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,197 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, desc, count } from 'drizzle-orm';
|
||||
import { users, games, nodes, auditLogs } from '@source/database';
|
||||
import multipart from '@fastify/multipart';
|
||||
import { eq, desc, count, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { users, games, nodes, auditLogs, plugins, pluginReleases } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requireSuperAdmin } from '../../lib/permissions.js';
|
||||
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
||||
import { CreateGameSchema, UpdateGameSchema, GameIdParamSchema } from './schemas.js';
|
||||
import { uploadPluginArtifact } from '../../lib/cdn.js';
|
||||
import * as yazl from 'yazl';
|
||||
import {
|
||||
CreateGameSchema,
|
||||
UpdateGameSchema,
|
||||
GameIdParamSchema,
|
||||
PluginIdParamSchema,
|
||||
PluginReleaseIdParamSchema,
|
||||
CreateGlobalPluginSchema,
|
||||
UpdateGlobalPluginSchema,
|
||||
ImportPluginsSchema,
|
||||
CreatePluginReleaseSchema,
|
||||
UpdatePluginReleaseSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
type ReleaseChannel = 'stable' | 'beta' | 'alpha';
|
||||
|
||||
interface UploadArtifactFile {
|
||||
relativePath: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
interface UploadJsonFile {
|
||||
filename: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
function toSlug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.slice(0, 200);
|
||||
}
|
||||
|
||||
function sanitizeRelativeSegments(path: string): string[] {
|
||||
const segments = path.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment === '.' || segment === '') continue;
|
||||
if (segment === '..') {
|
||||
throw AppError.badRequest('Invalid artifact path segment');
|
||||
}
|
||||
normalized.push(segment);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(path: string, fallbackName: string): string {
|
||||
const segments = sanitizeRelativeSegments(path);
|
||||
if (segments.length === 0) {
|
||||
return sanitizeRelativeSegments(fallbackName).join('/');
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') return [];
|
||||
if (typeof rawValue !== 'string') {
|
||||
throw AppError.badRequest(`${fieldName} must be a JSON string`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawValue);
|
||||
} catch {
|
||||
throw AppError.badRequest(`${fieldName} is not valid JSON`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw AppError.badRequest(`${fieldName} must be a JSON array`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseJsonArrayUploadFile(
|
||||
file: UploadJsonFile | null,
|
||||
fieldName: string,
|
||||
): unknown[] {
|
||||
if (!file) return [];
|
||||
|
||||
let rawValue = file.data.toString('utf8');
|
||||
if (rawValue.charCodeAt(0) === 0xfeff) {
|
||||
rawValue = rawValue.slice(1);
|
||||
}
|
||||
|
||||
return parseJsonArrayField(rawValue, fieldName);
|
||||
}
|
||||
|
||||
function parseJsonArrayInput(
|
||||
rawValue: unknown,
|
||||
file: UploadJsonFile | null,
|
||||
fieldName: string,
|
||||
): unknown[] {
|
||||
if (file) return parseJsonArrayUploadFile(file, fieldName);
|
||||
return parseJsonArrayField(rawValue, fieldName);
|
||||
}
|
||||
|
||||
function parseOptionalBoolean(rawValue: unknown): boolean | undefined {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') return undefined;
|
||||
if (typeof rawValue === 'boolean') return rawValue;
|
||||
if (typeof rawValue !== 'string') return undefined;
|
||||
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseReleaseChannel(rawValue: unknown): ReleaseChannel {
|
||||
if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue;
|
||||
if (typeof rawValue === 'string') {
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized;
|
||||
}
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
async function zipArtifacts(files: UploadArtifactFile[]): Promise<Buffer> {
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
const archive = new yazl.ZipFile();
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
archive.outputStream.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
archive.outputStream.on('error', reject);
|
||||
archive.outputStream.on('end', () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
archive.addBuffer(file.data, file.relativePath.replace(/^\/+/g, ''));
|
||||
}
|
||||
|
||||
archive.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveImportGame(
|
||||
app: FastifyInstance,
|
||||
{
|
||||
gameId,
|
||||
gameSlug,
|
||||
}: {
|
||||
gameId?: string;
|
||||
gameSlug?: string;
|
||||
},
|
||||
) {
|
||||
if (gameId) {
|
||||
const game = await app.db.query.games.findFirst({
|
||||
where: eq(games.id, gameId),
|
||||
});
|
||||
if (!game) {
|
||||
throw AppError.notFound(`Game not found: ${gameId}`);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
const normalizedSlug = gameSlug?.trim().toLowerCase();
|
||||
if (normalizedSlug) {
|
||||
const game = await app.db.query.games.findFirst({
|
||||
where: eq(games.slug, normalizedSlug),
|
||||
});
|
||||
if (!game) {
|
||||
throw AppError.notFound(`Game not found: ${normalizedSlug}`);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
throw AppError.badRequest('gameId or gameSlug is required for each import item');
|
||||
}
|
||||
|
||||
export default async function adminRoutes(app: FastifyInstance) {
|
||||
await app.register(multipart, {
|
||||
limits: {
|
||||
files: 200,
|
||||
parts: 600,
|
||||
fileSize: 512 * 1024 * 1024,
|
||||
},
|
||||
});
|
||||
|
||||
// All admin routes require auth + super admin
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
app.addHook('onRequest', async (request) => {
|
||||
@@ -59,8 +244,11 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
defaultPort: number;
|
||||
startupCommand: string;
|
||||
stopCommand?: string;
|
||||
stopTimeoutSeconds?: number;
|
||||
containerDataPath?: string;
|
||||
configFiles?: unknown[];
|
||||
environmentVars?: unknown[];
|
||||
automationRules?: unknown[];
|
||||
};
|
||||
|
||||
const existing = await app.db.query.games.findFirst({
|
||||
@@ -74,6 +262,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
...body,
|
||||
configFiles: body.configFiles ?? [],
|
||||
environmentVars: body.environmentVars ?? [],
|
||||
automationRules: body.automationRules ?? [],
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -98,6 +287,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
|
||||
|
||||
@@ -8,8 +8,11 @@ export const CreateGameSchema = {
|
||||
defaultPort: Type.Number({ minimum: 1, maximum: 65535 }),
|
||||
startupCommand: Type.String({ minLength: 1 }),
|
||||
stopCommand: Type.Optional(Type.String()),
|
||||
stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })),
|
||||
containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })),
|
||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -20,8 +23,11 @@ export const UpdateGameSchema = {
|
||||
defaultPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
|
||||
startupCommand: Type.Optional(Type.String({ minLength: 1 })),
|
||||
stopCommand: Type.Optional(Type.String()),
|
||||
stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })),
|
||||
containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })),
|
||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -30,3 +36,132 @@ export const GameIdParamSchema = {
|
||||
gameId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const PluginIdParamSchema = {
|
||||
params: Type.Object({
|
||||
pluginId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const PluginReleaseIdParamSchema = {
|
||||
params: Type.Object({
|
||||
pluginId: Type.String({ format: 'uuid' }),
|
||||
releaseId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const CreateGlobalPluginSchema = {
|
||||
body: Type.Object({
|
||||
gameId: Type.String({ format: 'uuid' }),
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
description: Type.Optional(Type.String()),
|
||||
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
|
||||
}),
|
||||
};
|
||||
|
||||
export const UpdateGlobalPluginSchema = {
|
||||
body: Type.Object({
|
||||
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
description: Type.Optional(Type.String()),
|
||||
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
|
||||
isGlobal: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
};
|
||||
|
||||
const ImportPluginPayloadSchema = Type.Object({
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
description: Type.Optional(Type.String()),
|
||||
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
|
||||
isGlobal: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
export const ReleaseInstallFieldSchema = Type.Object({
|
||||
key: Type.String({ minLength: 1, maxLength: 120 }),
|
||||
label: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
type: Type.Union([
|
||||
Type.Literal('text'),
|
||||
Type.Literal('number'),
|
||||
Type.Literal('boolean'),
|
||||
Type.Literal('select'),
|
||||
]),
|
||||
description: Type.Optional(Type.String({ maxLength: 1000 })),
|
||||
required: Type.Optional(Type.Boolean()),
|
||||
defaultValue: Type.Optional(Type.Any()),
|
||||
options: Type.Optional(Type.Array(Type.Object({
|
||||
label: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
value: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
}))),
|
||||
min: Type.Optional(Type.Number()),
|
||||
max: Type.Optional(Type.Number()),
|
||||
pattern: Type.Optional(Type.String({ maxLength: 500 })),
|
||||
secret: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
export const ReleaseTemplateSchema = Type.Object({
|
||||
path: Type.String({ minLength: 1 }),
|
||||
content: Type.String(),
|
||||
});
|
||||
|
||||
const ImportPluginReleasePayloadSchema = Type.Object({
|
||||
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||
artifactUrl: Type.String({ format: 'uri' }),
|
||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
changelog: Type.Optional(Type.String()),
|
||||
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
|
||||
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
|
||||
isPublished: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
export const ImportPluginsSchema = {
|
||||
body: Type.Object({
|
||||
defaultGameId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
defaultGameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||
stopOnError: Type.Optional(Type.Boolean()),
|
||||
items: Type.Array(
|
||||
Type.Object({
|
||||
gameId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
gameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||
plugin: ImportPluginPayloadSchema,
|
||||
release: Type.Optional(ImportPluginReleasePayloadSchema),
|
||||
}),
|
||||
{ minItems: 1, maxItems: 500 },
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
export const CreatePluginReleaseSchema = {
|
||||
body: Type.Object({
|
||||
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||
artifactUrl: Type.String({ format: 'uri' }),
|
||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
changelog: Type.Optional(Type.String()),
|
||||
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
|
||||
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
|
||||
isPublished: Type.Optional(Type.Boolean()),
|
||||
cloneFromReleaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||
}),
|
||||
};
|
||||
|
||||
export const UpdatePluginReleaseSchema = {
|
||||
body: Type.Object({
|
||||
version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||
artifactUrl: Type.Optional(Type.String({ format: 'uri' })),
|
||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
changelog: Type.Optional(Type.String()),
|
||||
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
|
||||
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
|
||||
isPublished: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -171,6 +171,39 @@ export default async function authRoutes(app: FastifyInstance) {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// POST /api/auth/change-password
|
||||
app.post('/change-password', { onRequest: [app.authenticate] }, async (request) => {
|
||||
const { currentPassword, newPassword } = request.body as {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
if (!currentPassword || !newPassword || newPassword.length < 8) {
|
||||
throw AppError.badRequest('New password must be at least 8 characters');
|
||||
}
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.id, request.user.sub),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw AppError.notFound('User not found');
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(user.passwordHash, currentPassword);
|
||||
if (!isValid) {
|
||||
throw AppError.unauthorized('Current password is incorrect', 'INVALID_PASSWORD');
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(newPassword);
|
||||
await app.db
|
||||
.update(users)
|
||||
.set({ passwordHash: newHash, updatedAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
|
||||
const payload = request.user;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { games } from '@source/database';
|
||||
|
||||
export default async function gameRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
// GET /api/games
|
||||
app.get('/', async () => {
|
||||
const gameList = await app.db
|
||||
.select()
|
||||
.from(games)
|
||||
.orderBy(games.name);
|
||||
|
||||
return { data: gameList };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { and, eq, lte } from 'drizzle-orm';
|
||||
import { nodes, scheduledTasks, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { computeNextRun } from '../../lib/schedule-utils.js';
|
||||
|
||||
function extractBearerToken(authHeader?: string): string | null {
|
||||
if (!authHeader) return null;
|
||||
const [scheme, token] = authHeader.split(' ');
|
||||
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
function extractCdnWebhookSecret(request: FastifyRequest): string | null {
|
||||
const byHeader = request.headers['x-cdn-webhook-secret'] ?? request.headers['x-webhook-secret'];
|
||||
if (typeof byHeader === 'string' && byHeader.trim().length > 0) {
|
||||
return byHeader.trim();
|
||||
}
|
||||
|
||||
const authHeader = typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: undefined;
|
||||
|
||||
return extractBearerToken(authHeader);
|
||||
}
|
||||
|
||||
async function requireDaemonToken(
|
||||
app: FastifyInstance,
|
||||
request: FastifyRequest,
|
||||
): Promise<{ id: string }> {
|
||||
const token = extractBearerToken(
|
||||
typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
throw AppError.unauthorized('Missing daemon bearer token', 'DAEMON_AUTH_MISSING');
|
||||
}
|
||||
|
||||
const node = await app.db.query.nodes.findFirst({
|
||||
where: eq(nodes.daemonToken, token),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (!node) {
|
||||
throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID');
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export default async function internalRoutes(app: FastifyInstance) {
|
||||
app.post(
|
||||
'/cdn/webhook/plugins',
|
||||
{
|
||||
schema: {
|
||||
body: Type.Optional(Type.Unknown()),
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const configuredSecret = process.env.CDN_WEBHOOK_SECRET?.trim();
|
||||
if (configuredSecret) {
|
||||
const providedSecret = extractCdnWebhookSecret(request);
|
||||
if (!providedSecret || providedSecret !== configuredSecret) {
|
||||
throw AppError.unauthorized('Invalid CDN webhook secret', 'CDN_WEBHOOK_AUTH_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
const eventType = typeof body?.eventType === 'string'
|
||||
? body.eventType
|
||||
: (typeof body?.type === 'string' ? body.type : 'unknown');
|
||||
|
||||
request.log.info(
|
||||
{ eventType, payload: body },
|
||||
'Received CDN plugin webhook event',
|
||||
);
|
||||
|
||||
return reply.code(202).send({ accepted: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/schedules/due', async (request) => {
|
||||
const node = await requireDaemonToken(app, request);
|
||||
const now = new Date();
|
||||
|
||||
const dueTasks = await app.db
|
||||
.select({
|
||||
id: scheduledTasks.id,
|
||||
serverUuid: servers.uuid,
|
||||
action: scheduledTasks.action,
|
||||
payload: scheduledTasks.payload,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
isActive: scheduledTasks.isActive,
|
||||
nextRunAt: scheduledTasks.nextRunAt,
|
||||
})
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(and(
|
||||
eq(servers.nodeId, node.id),
|
||||
eq(scheduledTasks.isActive, true),
|
||||
lte(scheduledTasks.nextRunAt, now),
|
||||
));
|
||||
|
||||
return {
|
||||
tasks: dueTasks.map((task) => ({
|
||||
id: task.id,
|
||||
server_uuid: task.serverUuid,
|
||||
action: task.action,
|
||||
payload: task.payload,
|
||||
schedule_type: task.scheduleType,
|
||||
is_active: task.isActive,
|
||||
next_run_at: task.nextRunAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/schedules/:taskId/ack',
|
||||
{
|
||||
schema: {
|
||||
params: Type.Object({
|
||||
taskId: Type.String(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const node = await requireDaemonToken(app, request);
|
||||
const { taskId } = request.params as { taskId: string };
|
||||
|
||||
const [task] = await app.db
|
||||
.select({
|
||||
id: scheduledTasks.id,
|
||||
isActive: scheduledTasks.isActive,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
scheduleData: scheduledTasks.scheduleData,
|
||||
})
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(and(
|
||||
eq(scheduledTasks.id, taskId),
|
||||
eq(servers.nodeId, node.id),
|
||||
));
|
||||
|
||||
if (!task) {
|
||||
throw AppError.notFound('Scheduled task not found');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const nextRunAt = task.isActive
|
||||
? computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
await app.db
|
||||
.update(scheduledTasks)
|
||||
.set({
|
||||
lastRunAt: now,
|
||||
nextRunAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(scheduledTasks.id, taskId));
|
||||
|
||||
return { success: true, taskId };
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/servers/:serverUuid/backup',
|
||||
{
|
||||
schema: {
|
||||
params: Type.Object({
|
||||
serverUuid: Type.String(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
await requireDaemonToken(app, request);
|
||||
const { serverUuid } = request.params as { serverUuid: string };
|
||||
return { success: true, serverUuid };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nodes } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
|
||||
const HeartbeatSchema = {
|
||||
body: Type.Object({
|
||||
active_servers: Type.Number({ minimum: 0 }),
|
||||
total_servers: Type.Number({ minimum: 0 }),
|
||||
version: Type.String(),
|
||||
}),
|
||||
};
|
||||
|
||||
function extractBearerToken(authHeader?: string): string | null {
|
||||
if (!authHeader) return null;
|
||||
const [scheme, token] = authHeader.split(' ');
|
||||
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
export default async function daemonNodeRoutes(app: FastifyInstance) {
|
||||
// POST /api/nodes/heartbeat
|
||||
app.post('/heartbeat', { schema: HeartbeatSchema }, async (request) => {
|
||||
const token = extractBearerToken(
|
||||
typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
throw AppError.unauthorized('Missing daemon bearer token', 'DAEMON_AUTH_MISSING');
|
||||
}
|
||||
|
||||
const node = await app.db.query.nodes.findFirst({
|
||||
where: eq(nodes.daemonToken, token),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (!node) {
|
||||
throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await app.db
|
||||
.update(nodes)
|
||||
.set({
|
||||
isOnline: true,
|
||||
lastHeartbeat: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(nodes.id, node.id));
|
||||
|
||||
const body = request.body as {
|
||||
active_servers: number;
|
||||
total_servers: number;
|
||||
version: string;
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nodeId: node.id,
|
||||
activeServers: body.active_servers,
|
||||
totalServers: body.total_servers,
|
||||
version: body.version,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { nodes, allocations } from '@source/database';
|
||||
import { nodes, allocations, servers, games } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
daemonGetNodeStats,
|
||||
daemonGetNodeStatus,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
import {
|
||||
NodeParamSchema,
|
||||
CreateNodeSchema,
|
||||
@@ -26,7 +31,16 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||
.where(eq(nodes.organizationId, orgId))
|
||||
.orderBy(nodes.createdAt);
|
||||
|
||||
return { data: nodeList };
|
||||
const total = nodeList.length;
|
||||
return {
|
||||
data: nodeList,
|
||||
meta: {
|
||||
total,
|
||||
page: 1,
|
||||
perPage: total,
|
||||
totalPages: total === 0 ? 0 : 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/organizations/:orgId/nodes
|
||||
@@ -124,6 +138,95 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/servers
|
||||
app.get('/:nodeId/servers', { schema: NodeParamSchema }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
|
||||
const serverList = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
name: servers.name,
|
||||
status: servers.status,
|
||||
memoryLimit: servers.memoryLimit,
|
||||
cpuLimit: servers.cpuLimit,
|
||||
gameName: games.name,
|
||||
})
|
||||
.from(servers)
|
||||
.leftJoin(games, eq(servers.gameId, games.id))
|
||||
.where(and(eq(servers.nodeId, nodeId), eq(servers.organizationId, orgId)));
|
||||
|
||||
return { data: serverList };
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/stats
|
||||
// Returns real-time stats from daemon when available, with DB fallback.
|
||||
app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
|
||||
const node = await app.db.query.nodes.findFirst({
|
||||
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
|
||||
});
|
||||
if (!node) throw AppError.notFound('Node not found');
|
||||
|
||||
const serverList = await app.db
|
||||
.select({ id: servers.id, status: servers.status })
|
||||
.from(servers)
|
||||
.where(eq(servers.nodeId, nodeId));
|
||||
|
||||
const totalServers = serverList.length;
|
||||
let activeServers = serverList.filter((s) => s.status === 'running').length;
|
||||
let cpuPercent = 0;
|
||||
let memoryUsed = 0;
|
||||
let memoryTotal = node.memoryTotal;
|
||||
let diskUsed = 0;
|
||||
let diskTotal = node.diskTotal;
|
||||
let uptime = 0;
|
||||
|
||||
const daemonNode: DaemonNodeConnection = {
|
||||
fqdn: node.fqdn,
|
||||
grpcPort: node.grpcPort,
|
||||
daemonToken: node.daemonToken,
|
||||
};
|
||||
|
||||
try {
|
||||
const [liveStats, liveStatus] = await Promise.all([
|
||||
daemonGetNodeStats(daemonNode),
|
||||
daemonGetNodeStatus(daemonNode),
|
||||
]);
|
||||
|
||||
cpuPercent = Number.isFinite(liveStats.cpuPercent)
|
||||
? Math.max(0, Math.min(100, liveStats.cpuPercent))
|
||||
: 0;
|
||||
memoryUsed = Math.max(0, liveStats.memoryUsed);
|
||||
memoryTotal = liveStats.memoryTotal > 0 ? liveStats.memoryTotal : node.memoryTotal;
|
||||
diskUsed = Math.max(0, liveStats.diskUsed);
|
||||
diskTotal = liveStats.diskTotal > 0 ? liveStats.diskTotal : node.diskTotal;
|
||||
uptime = Math.max(0, liveStatus.uptimeSeconds);
|
||||
|
||||
if (Number.isFinite(liveStatus.activeServers)) {
|
||||
activeServers = Math.max(0, Math.min(totalServers, liveStatus.activeServers));
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{ error, nodeId, orgId },
|
||||
'Failed to fetch live node stats from daemon, returning fallback values',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cpuPercent,
|
||||
memoryUsed,
|
||||
memoryTotal,
|
||||
diskUsed,
|
||||
diskTotal,
|
||||
activeServers,
|
||||
totalServers,
|
||||
uptime,
|
||||
};
|
||||
});
|
||||
|
||||
// === Allocations ===
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/allocations
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
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({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
const BackupParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
backupId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
const CreateBackupBody = Type.Object({
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
isLocked: Type.Optional(Type.Boolean({ default: false })),
|
||||
});
|
||||
|
||||
export default async function backupRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
// GET /backups — list all backups for a server
|
||||
app.get('/', { schema: ParamSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'backup.read');
|
||||
|
||||
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 backupList = await app.db.query.backups.findMany({
|
||||
where: eq(backups.serverId, serverId),
|
||||
orderBy: (b, { desc }) => [desc(b.createdAt)],
|
||||
});
|
||||
|
||||
return { backups: backupList };
|
||||
});
|
||||
|
||||
// POST /backups — create a backup
|
||||
app.post('/', { schema: { ...ParamSchema, body: CreateBackupBody } }, async (request, reply) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'backup.create');
|
||||
|
||||
const body = request.body as { name: string; isLocked?: boolean };
|
||||
|
||||
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||
|
||||
// Create backup record (pending — daemon will update when complete)
|
||||
const [backup] = await app.db
|
||||
.insert(backups)
|
||||
.values({
|
||||
serverId,
|
||||
name: body.name,
|
||||
isLocked: body.isLocked ?? false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
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,
|
||||
serverId,
|
||||
action: 'backup.create',
|
||||
metadata: { name: body.name },
|
||||
});
|
||||
|
||||
return reply.code(201).send(completedBackup);
|
||||
});
|
||||
|
||||
// POST /backups/:backupId/restore — restore a backup
|
||||
app.post('/:backupId/restore', { schema: BackupParamSchema }, async (request) => {
|
||||
const { orgId, serverId, backupId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
backupId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'backup.restore');
|
||||
|
||||
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)),
|
||||
});
|
||||
if (!backup) throw AppError.notFound('Backup not found');
|
||||
if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed');
|
||||
|
||||
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,
|
||||
serverId,
|
||||
action: 'backup.restore',
|
||||
metadata: { backupName: backup.name, backupId },
|
||||
});
|
||||
|
||||
return { success: true, message: 'Restore initiated' };
|
||||
});
|
||||
|
||||
// PATCH /backups/:backupId/lock — toggle backup lock
|
||||
app.patch('/:backupId/lock', { schema: BackupParamSchema }, async (request) => {
|
||||
const { orgId, serverId, backupId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
backupId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'backup.manage');
|
||||
|
||||
const backup = await app.db.query.backups.findFirst({
|
||||
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
|
||||
});
|
||||
if (!backup) throw AppError.notFound('Backup not found');
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(backups)
|
||||
.set({ isLocked: !backup.isLocked })
|
||||
.where(eq(backups.id, backupId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// DELETE /backups/:backupId — delete a backup
|
||||
app.delete('/:backupId', { schema: BackupParamSchema }, async (request, reply) => {
|
||||
const { orgId, serverId, backupId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
backupId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'backup.delete');
|
||||
|
||||
const backup = await app.db.query.backups.findFirst({
|
||||
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
|
||||
});
|
||||
if (!backup) throw AppError.notFound('Backup not found');
|
||||
if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup');
|
||||
|
||||
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));
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'backup.delete',
|
||||
metadata: { name: backup.name },
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
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 {
|
||||
managedConfigFileFor,
|
||||
readManagedConfig,
|
||||
writeManagedConfig,
|
||||
} from '../../lib/managed-config.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
const ConfigFileParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
configIndex: Type.Number({ minimum: 0 }),
|
||||
}),
|
||||
};
|
||||
|
||||
export default async function configRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
// GET /config — list available config files for this server's game
|
||||
app.get('/', { schema: ParamSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'config.read');
|
||||
|
||||
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 game = await app.db.query.games.findFirst({
|
||||
where: eq(games.id, server.gameId),
|
||||
});
|
||||
if (!game) throw AppError.notFound('Game not found');
|
||||
|
||||
const configFiles = (game.configFiles as GameConfigFile[]) || [];
|
||||
return {
|
||||
configs: configFiles.map((cf, index) => ({
|
||||
index,
|
||||
path: cf.path,
|
||||
parser: cf.parser,
|
||||
editableKeys: cf.editableKeys ?? null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// GET /config/:configIndex — read & parse a specific config file
|
||||
app.get('/:configIndex', { schema: ConfigFileParamSchema }, async (request) => {
|
||||
const { orgId, serverId, configIndex } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
configIndex: number;
|
||||
};
|
||||
await requirePermission(request, orgId, 'config.read');
|
||||
|
||||
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
|
||||
let raw = '';
|
||||
try {
|
||||
const managedFile = managedConfigFileFor(game.slug, configFile.path);
|
||||
if (managedFile) {
|
||||
raw = await readManagedConfig(node, server.uuid, managedFile);
|
||||
} 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,
|
||||
};
|
||||
});
|
||||
|
||||
// PUT /config/:configIndex — update a config file
|
||||
app.put(
|
||||
'/:configIndex',
|
||||
{
|
||||
schema: {
|
||||
...ConfigFileParamSchema,
|
||||
body: Type.Object({
|
||||
entries: Type.Array(
|
||||
Type.Object({
|
||||
key: Type.String(),
|
||||
value: Type.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId, configIndex } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
configIndex: number;
|
||||
};
|
||||
const { entries } = request.body as { entries: { key: string; value: string }[] };
|
||||
await requirePermission(request, orgId, 'config.write');
|
||||
|
||||
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
|
||||
const managedFile = managedConfigFileFor(game.slug, configFile.path);
|
||||
|
||||
let originalContent: string | undefined;
|
||||
let originalEntries: { key: string; value: string }[] = [];
|
||||
try {
|
||||
if (managedFile) {
|
||||
originalContent = await readManagedConfig(node, server.uuid, managedFile);
|
||||
} 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 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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const content = serializeConfig(
|
||||
entries,
|
||||
configFile.parser as ConfigParser,
|
||||
originalContent,
|
||||
);
|
||||
|
||||
if (managedFile) {
|
||||
await writeManagedConfig(node, server.uuid, managedFile, content);
|
||||
} else {
|
||||
await daemonWriteFile(node, server.uuid, configFile.path, content);
|
||||
}
|
||||
return { success: true, path: configFile.path, content };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getServerConfig(
|
||||
app: FastifyInstance,
|
||||
orgId: string,
|
||||
serverId: string,
|
||||
configIndex: number,
|
||||
) {
|
||||
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 as string),
|
||||
});
|
||||
if (!game) throw AppError.notFound('Game not found');
|
||||
|
||||
const configFiles = (game.configFiles as GameConfigFile[]) || [];
|
||||
const configFile = configFiles[configIndex];
|
||||
if (!configFile) throw AppError.notFound('Config file not found');
|
||||
|
||||
const node: DaemonNodeConnection = {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
};
|
||||
|
||||
return { game, server, node, configFile };
|
||||
}
|
||||
|
||||
function isMissingConfigFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('Server responded with NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { nodes, serverDatabases, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
daemonCreateDatabase,
|
||||
daemonDeleteDatabase,
|
||||
daemonUpdateDatabasePassword,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
|
||||
const ServerDatabaseParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
databaseId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
const ServerScopeSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
const CreateServerDatabaseSchema = {
|
||||
body: Type.Object({
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })),
|
||||
}),
|
||||
};
|
||||
|
||||
const UpdateServerDatabaseSchema = {
|
||||
body: Type.Object({
|
||||
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })),
|
||||
}),
|
||||
};
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string) {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
name: servers.name,
|
||||
uuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function buildNodeConnection(server: {
|
||||
nodeDaemonToken: string;
|
||||
nodeFqdn: string;
|
||||
nodeGrpcPort: number;
|
||||
}): DaemonNodeConnection {
|
||||
return {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
};
|
||||
}
|
||||
|
||||
function daemonErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default async function databaseRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', { schema: ServerScopeSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.read');
|
||||
await getServerContext(app, orgId, serverId);
|
||||
|
||||
const databases = await app.db
|
||||
.select()
|
||||
.from(serverDatabases)
|
||||
.where(eq(serverDatabases.serverId, serverId))
|
||||
.orderBy(serverDatabases.createdAt);
|
||||
|
||||
return { data: databases };
|
||||
});
|
||||
|
||||
app.post('/', { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, async (request, reply) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const body = request.body as { name: string; password?: string };
|
||||
const name = body.name.trim();
|
||||
if (!name) {
|
||||
throw AppError.badRequest('Database name is required');
|
||||
}
|
||||
|
||||
const server = await getServerContext(app, orgId, serverId);
|
||||
|
||||
let managedDatabase;
|
||||
try {
|
||||
managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), {
|
||||
name,
|
||||
password: body.password,
|
||||
serverUuid: server.uuid,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, serverUuid: server.uuid },
|
||||
'Failed to provision node-local MySQL database',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to provision node-local MySQL database'),
|
||||
'MANAGED_MYSQL_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [created] = await app.db
|
||||
.insert(serverDatabases)
|
||||
.values({
|
||||
serverId,
|
||||
name,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
password: managedDatabase.password,
|
||||
host: managedDatabase.host,
|
||||
port: managedDatabase.port,
|
||||
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.database.create',
|
||||
metadata: {
|
||||
name: created!.name,
|
||||
databaseName: created!.databaseName,
|
||||
username: created!.username,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.code(201).send(created);
|
||||
} catch (error) {
|
||||
try {
|
||||
await daemonDeleteDatabase(buildNodeConnection(server), {
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
request.log.error(
|
||||
{ cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||
'Failed to roll back node-local MySQL database after panel insert failure',
|
||||
);
|
||||
}
|
||||
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||
'Failed to persist managed MySQL database metadata',
|
||||
);
|
||||
throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED');
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/:databaseId', { schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } }, async (request) => {
|
||||
const { orgId, serverId, databaseId } = request.params as {
|
||||
databaseId: string;
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const body = request.body as { name?: string; password?: string };
|
||||
|
||||
const [current] = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
createdAt: serverDatabases.createdAt,
|
||||
updatedAt: serverDatabases.updatedAt,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(serverDatabases.id, databaseId),
|
||||
eq(serverDatabases.serverId, serverId),
|
||||
eq(servers.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!current) {
|
||||
throw AppError.notFound('Database not found');
|
||||
}
|
||||
|
||||
const nextName = body.name === undefined ? undefined : body.name.trim();
|
||||
if (body.name !== undefined && !nextName) {
|
||||
throw AppError.badRequest('Database name is required');
|
||||
}
|
||||
|
||||
const nextPassword = body.password?.trim();
|
||||
if (!nextName && !nextPassword) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (nextPassword) {
|
||||
try {
|
||||
await daemonUpdateDatabasePassword(buildNodeConnection(current), {
|
||||
password: nextPassword,
|
||||
username: current.username,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseId, username: current.username },
|
||||
'Failed to rotate node-local MySQL password',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to rotate database password'),
|
||||
'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (nextName) patch.name = nextName;
|
||||
if (nextPassword) patch.password = nextPassword;
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(serverDatabases)
|
||||
.set(patch)
|
||||
.where(eq(serverDatabases.id, databaseId))
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.database.update',
|
||||
metadata: {
|
||||
databaseId,
|
||||
updatedName: nextName ?? undefined,
|
||||
passwordRotated: Boolean(nextPassword),
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/:databaseId', { schema: ServerDatabaseParamSchema }, async (request, reply) => {
|
||||
const { orgId, serverId, databaseId } = request.params as {
|
||||
databaseId: string;
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const [current] = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(serverDatabases.id, databaseId),
|
||||
eq(serverDatabases.serverId, serverId),
|
||||
eq(servers.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!current) {
|
||||
throw AppError.notFound('Database not found');
|
||||
}
|
||||
|
||||
try {
|
||||
await daemonDeleteDatabase(buildNodeConnection(current), {
|
||||
databaseName: current.databaseName,
|
||||
username: current.username,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, orgId, serverId, databaseId, databaseName: current.databaseName },
|
||||
'Failed to delete node-local MySQL database',
|
||||
);
|
||||
throw new AppError(
|
||||
502,
|
||||
daemonErrorMessage(error, 'Failed to delete node-local MySQL database'),
|
||||
'MANAGED_MYSQL_DELETE_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, databaseId));
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.database.delete',
|
||||
metadata: {
|
||||
databaseId,
|
||||
name: current.name,
|
||||
databaseName: current.databaseName,
|
||||
username: current.username,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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 {
|
||||
isManagedConfigShadowFile,
|
||||
managedConfigFileFor,
|
||||
readManagedConfig,
|
||||
writeManagedConfig,
|
||||
} from '../../lib/managed-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 (isManagedConfigShadowFile(gameSlug, fileName)) return true;
|
||||
if (gameSlug !== 'cs2') return false;
|
||||
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';
|
||||
|
||||
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
|
||||
if (managedFile) {
|
||||
payload = Buffer.from(
|
||||
await readManagedConfig(serverContext.node, serverContext.serverUuid, managedFile),
|
||||
'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;
|
||||
|
||||
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
|
||||
if (managedFile) {
|
||||
await writeManagedConfig(
|
||||
serverContext.node,
|
||||
serverContext.serverUuid,
|
||||
managedFile,
|
||||
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);
|
||||
|
||||
// Deleting a managed config also drops the panel's sidecar copy,
|
||||
// otherwise the next start would resurrect the file.
|
||||
const resolvedPaths = paths.flatMap((path) => {
|
||||
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
|
||||
if (!managedFile) return [path];
|
||||
|
||||
return [
|
||||
path,
|
||||
path.trim().startsWith('/')
|
||||
? `/${managedFile.shadowPath}`
|
||||
: managedFile.shadowPath,
|
||||
];
|
||||
});
|
||||
|
||||
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths);
|
||||
return { success: true, paths };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
serverUuid: string;
|
||||
gameSlug: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
uuid: servers.uuid,
|
||||
gameSlug: games.slug,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.innerJoin(games, eq(servers.gameId, games.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverUuid: server.uuid,
|
||||
gameSlug: server.gameSlug,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { nodes, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { daemonGetActivePlayers, type DaemonNodeConnection } from '../../lib/daemon.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
export default async function playerRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', { schema: ParamSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'server.read');
|
||||
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
const players = await daemonGetActivePlayers(serverContext.node, serverContext.serverUuid);
|
||||
|
||||
return {
|
||||
players: players.players.map((player) => ({
|
||||
name: player.name,
|
||||
steamid: player.id || undefined,
|
||||
})),
|
||||
maxPlayers: players.maxPlayers,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
uuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverUuid: server.uuid,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,253 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
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({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
const TaskParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
taskId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
};
|
||||
|
||||
const CreateScheduleBody = Type.Object({
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
action: Type.Union([
|
||||
Type.Literal('command'),
|
||||
Type.Literal('power'),
|
||||
Type.Literal('backup'),
|
||||
]),
|
||||
payload: Type.String({ minLength: 1 }),
|
||||
scheduleType: Type.Union([
|
||||
Type.Literal('interval'),
|
||||
Type.Literal('daily'),
|
||||
Type.Literal('weekly'),
|
||||
Type.Literal('cron'),
|
||||
]),
|
||||
scheduleData: Type.Object({}, { additionalProperties: true }),
|
||||
isActive: Type.Optional(Type.Boolean({ default: true })),
|
||||
});
|
||||
|
||||
const UpdateScheduleBody = Type.Object({
|
||||
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
action: Type.Optional(
|
||||
Type.Union([Type.Literal('command'), Type.Literal('power'), Type.Literal('backup')]),
|
||||
),
|
||||
payload: Type.Optional(Type.String({ minLength: 1 })),
|
||||
scheduleType: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal('interval'),
|
||||
Type.Literal('daily'),
|
||||
Type.Literal('weekly'),
|
||||
Type.Literal('cron'),
|
||||
]),
|
||||
),
|
||||
scheduleData: Type.Optional(Type.Object({}, { additionalProperties: true })),
|
||||
isActive: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
export default async function scheduleRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
// GET /schedules — list all scheduled tasks for a server
|
||||
app.get('/', { schema: ParamSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'schedule.read');
|
||||
|
||||
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 tasks = await app.db.query.scheduledTasks.findMany({
|
||||
where: eq(scheduledTasks.serverId, serverId),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
});
|
||||
|
||||
return { tasks };
|
||||
});
|
||||
|
||||
// POST /schedules — create a scheduled task
|
||||
app.post('/', { schema: { ...ParamSchema, body: CreateScheduleBody } }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'schedule.manage');
|
||||
|
||||
const body = request.body as {
|
||||
name: string;
|
||||
action: 'command' | 'power' | 'backup';
|
||||
payload: string;
|
||||
scheduleType: 'interval' | 'daily' | 'weekly' | 'cron';
|
||||
scheduleData: Record<string, unknown>;
|
||||
isActive?: 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 nextRun = computeNextRun(body.scheduleType, body.scheduleData);
|
||||
|
||||
const [task] = await app.db
|
||||
.insert(scheduledTasks)
|
||||
.values({
|
||||
serverId,
|
||||
name: body.name,
|
||||
action: body.action,
|
||||
payload: body.payload,
|
||||
scheduleType: body.scheduleType,
|
||||
scheduleData: body.scheduleData,
|
||||
isActive: body.isActive ?? true,
|
||||
nextRunAt: nextRun,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'schedule.create',
|
||||
metadata: { name: body.name, action: body.action },
|
||||
});
|
||||
|
||||
return task;
|
||||
});
|
||||
|
||||
// PATCH /schedules/:taskId — update a scheduled task
|
||||
app.patch('/:taskId', { schema: { ...TaskParamSchema, body: UpdateScheduleBody } }, async (request) => {
|
||||
const { orgId, serverId, taskId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
taskId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'schedule.manage');
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
const existing = await app.db.query.scheduledTasks.findFirst({
|
||||
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
|
||||
});
|
||||
if (!existing) throw AppError.notFound('Scheduled task not found');
|
||||
|
||||
// Recompute next run if schedule changed
|
||||
const scheduleType = (body.scheduleType as string) || existing.scheduleType;
|
||||
const scheduleData = (body.scheduleData as Record<string, unknown>) || (existing.scheduleData as Record<string, unknown>);
|
||||
const nextRun = computeNextRun(scheduleType, scheduleData);
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(scheduledTasks)
|
||||
.set({ ...body, nextRunAt: nextRun, updatedAt: new Date() })
|
||||
.where(eq(scheduledTasks.id, taskId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// DELETE /schedules/:taskId — delete a scheduled task
|
||||
app.delete('/:taskId', { schema: TaskParamSchema }, async (request, reply) => {
|
||||
const { orgId, serverId, taskId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
taskId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'schedule.manage');
|
||||
|
||||
const existing = await app.db.query.scheduledTasks.findFirst({
|
||||
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
|
||||
});
|
||||
if (!existing) throw AppError.notFound('Scheduled task not found');
|
||||
|
||||
await app.db.delete(scheduledTasks).where(eq(scheduledTasks.id, taskId));
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'schedule.delete',
|
||||
metadata: { name: existing.name },
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// POST /schedules/:taskId/trigger — manually trigger a task
|
||||
app.post('/:taskId/trigger', { schema: TaskParamSchema }, async (request) => {
|
||||
const { orgId, serverId, taskId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
taskId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'schedule.manage');
|
||||
|
||||
const task = await app.db.query.scheduledTasks.findFirst({
|
||||
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
|
||||
});
|
||||
if (!task) throw AppError.notFound('Scheduled task not found');
|
||||
|
||||
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
|
||||
.update(scheduledTasks)
|
||||
.set({ lastRunAt: new Date(), nextRunAt: nextRun })
|
||||
.where(eq(scheduledTasks.id, taskId));
|
||||
|
||||
return { success: true, triggered: task.name };
|
||||
});
|
||||
}
|
||||
|
||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||
serverUuid: string;
|
||||
node: DaemonNodeConnection;
|
||||
}> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
uuid: servers.uuid,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!server) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverUuid: server.uuid,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const CreateServerSchema = {
|
||||
diskLimit: Type.Number({ minimum: 256 * 1024 * 1024 }), // min 256MB
|
||||
cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000, default: 100 })),
|
||||
allocationId: Type.String({ format: 'uuid' }),
|
||||
additionalAllocationIds: Type.Optional(Type.Array(Type.String({ format: 'uuid' }))),
|
||||
environment: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
startupOverride: Type.Optional(Type.String()),
|
||||
}),
|
||||
|
||||
Generated
+19
@@ -484,6 +484,7 @@ dependencies = [
|
||||
"bollard",
|
||||
"flate2",
|
||||
"futures",
|
||||
"libc",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"reqwest",
|
||||
@@ -1024,6 +1025,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -1436,6 +1447,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -1447,6 +1459,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -2186,6 +2199,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
||||
@@ -23,7 +23,7 @@ serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# HTTP client (for CDN uploads, API callbacks)
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
@@ -32,6 +32,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
libc = "0.2"
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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/*
|
||||
|
||||
# build.rs compiles ../../packages/proto/daemon.proto, so the workspace layout
|
||||
# has to be preserved inside the build context.
|
||||
WORKDIR /build
|
||||
COPY packages/proto ./packages/proto
|
||||
COPY apps/daemon ./apps/daemon
|
||||
|
||||
WORKDIR /build/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 /build/apps/daemon/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 --start-period=15s CMD /app/gamepanel-daemon --health-check || exit 1
|
||||
|
||||
CMD ["/app/gamepanel-daemon"]
|
||||
@@ -0,0 +1,332 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, error};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::server::ServerManager;
|
||||
|
||||
/// Manages backup creation, restoration, and deletion.
|
||||
pub struct BackupManager {
|
||||
server_manager: Arc<ServerManager>,
|
||||
backup_root: PathBuf,
|
||||
api_url: String,
|
||||
node_token: String,
|
||||
}
|
||||
|
||||
impl BackupManager {
|
||||
pub fn new(
|
||||
server_manager: Arc<ServerManager>,
|
||||
backup_root: PathBuf,
|
||||
api_url: String,
|
||||
node_token: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
server_manager,
|
||||
backup_root,
|
||||
api_url,
|
||||
node_token,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a backup for a server.
|
||||
/// Returns the local file path and size in bytes.
|
||||
pub async fn create_backup(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
backup_id: &str,
|
||||
) -> Result<(PathBuf, u64, String)> {
|
||||
let server_data = self.server_manager.data_root().join(server_uuid);
|
||||
if !server_data.exists() {
|
||||
anyhow::bail!("Server data directory not found: {}", server_data.display());
|
||||
}
|
||||
|
||||
// Ensure backup directory exists
|
||||
let backup_dir = self.backup_root.join(server_uuid);
|
||||
fs::create_dir_all(&backup_dir).await?;
|
||||
|
||||
let backup_file = backup_dir.join(format!("{}.tar.gz", backup_id));
|
||||
|
||||
info!(
|
||||
server = %server_uuid,
|
||||
backup_id = %backup_id,
|
||||
path = %backup_file.display(),
|
||||
"Creating backup archive"
|
||||
);
|
||||
|
||||
// Create tar.gz in a blocking task
|
||||
let source = server_data.clone();
|
||||
let dest = backup_file.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
create_tar_gz(&source, &dest)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Get file info
|
||||
let metadata = fs::metadata(&backup_file).await?;
|
||||
let size = metadata.len();
|
||||
|
||||
// Calculate checksum
|
||||
let checksum = {
|
||||
let path = backup_file.clone();
|
||||
tokio::task::spawn_blocking(move || calculate_sha256(&path))
|
||||
.await?
|
||||
.context("Failed to calculate checksum")?
|
||||
};
|
||||
|
||||
info!(
|
||||
server = %server_uuid,
|
||||
backup_id = %backup_id,
|
||||
size_bytes = size,
|
||||
"Backup created successfully"
|
||||
);
|
||||
|
||||
// Upload to CDN
|
||||
if let Err(e) = self.upload_to_cdn(server_uuid, backup_id, &backup_file, size).await {
|
||||
error!(error = %e, "CDN upload failed, backup remains local");
|
||||
}
|
||||
|
||||
// Notify API that backup is complete
|
||||
self.notify_backup_complete(backup_id, size, &checksum).await;
|
||||
|
||||
Ok((backup_file, size, checksum))
|
||||
}
|
||||
|
||||
/// Restore a backup for a server.
|
||||
pub async fn restore_backup(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
backup_id: &str,
|
||||
cdn_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let server_data = self.server_manager.data_root().join(server_uuid);
|
||||
|
||||
// Try local backup first
|
||||
let backup_file = self.backup_root.join(server_uuid).join(format!("{}.tar.gz", backup_id));
|
||||
let archive_path = if backup_file.exists() {
|
||||
backup_file
|
||||
} else if let Some(cdn) = cdn_path {
|
||||
// Download from CDN
|
||||
info!(cdn_path = %cdn, "Downloading backup from CDN");
|
||||
let tmp = self.backup_root.join(format!("{}-restore.tar.gz", backup_id));
|
||||
self.download_from_cdn(cdn, &tmp).await?;
|
||||
tmp
|
||||
} else {
|
||||
anyhow::bail!("Backup file not found locally and no CDN path provided");
|
||||
};
|
||||
|
||||
info!(
|
||||
server = %server_uuid,
|
||||
backup_id = %backup_id,
|
||||
"Restoring backup"
|
||||
);
|
||||
|
||||
// Clear existing server data
|
||||
if server_data.exists() {
|
||||
fs::remove_dir_all(&server_data).await?;
|
||||
}
|
||||
fs::create_dir_all(&server_data).await?;
|
||||
|
||||
// Extract archive
|
||||
let dest = server_data.clone();
|
||||
let src = archive_path.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
extract_tar_gz(&src, &dest)
|
||||
})
|
||||
.await??;
|
||||
|
||||
info!(
|
||||
server = %server_uuid,
|
||||
backup_id = %backup_id,
|
||||
"Backup restored successfully"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a backup from local storage and CDN.
|
||||
pub async fn delete_backup(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
backup_id: &str,
|
||||
cdn_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// Delete local file
|
||||
let local = self.backup_root.join(server_uuid).join(format!("{}.tar.gz", backup_id));
|
||||
if local.exists() {
|
||||
fs::remove_file(&local).await?;
|
||||
info!(path = %local.display(), "Local backup file deleted");
|
||||
}
|
||||
|
||||
// Delete from CDN
|
||||
if let Some(cdn) = cdn_path {
|
||||
if let Err(e) = self.delete_from_cdn(cdn).await {
|
||||
error!(error = %e, "Failed to delete backup from CDN");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload backup to @source/cdn.
|
||||
async fn upload_to_cdn(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
backup_id: &str,
|
||||
file_path: &Path,
|
||||
_size: u64,
|
||||
) -> Result<String> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Read file
|
||||
let data = fs::read(file_path).await?;
|
||||
|
||||
let cdn_path = format!("backups/{}/{}.tar.gz", server_uuid, backup_id);
|
||||
let upload_url = format!("{}/api/internal/cdn/upload", self.api_url);
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("path", cdn_path.clone())
|
||||
.part("file", reqwest::multipart::Part::bytes(data).file_name("backup.tar.gz"));
|
||||
|
||||
client
|
||||
.post(&upload_url)
|
||||
.bearer_auth(&self.node_token)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
info!(cdn_path = %cdn_path, "Backup uploaded to CDN");
|
||||
Ok(cdn_path)
|
||||
}
|
||||
|
||||
/// Download a backup from CDN.
|
||||
async fn download_from_cdn(&self, cdn_path: &str, dest: &Path) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/internal/cdn/download?path={}", self.api_url, cdn_path);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(&self.node_token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let bytes = resp.bytes().await?;
|
||||
fs::write(dest, &bytes).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a backup from CDN.
|
||||
async fn delete_from_cdn(&self, cdn_path: &str) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/internal/cdn/delete", self.api_url);
|
||||
|
||||
client
|
||||
.delete(&url)
|
||||
.bearer_auth(&self.node_token)
|
||||
.json(&serde_json::json!({ "path": cdn_path }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Notify the panel API that a backup is complete.
|
||||
async fn notify_backup_complete(&self, backup_id: &str, size: u64, checksum: &str) {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/internal/backups/{}/complete", self.api_url, backup_id);
|
||||
|
||||
let result = client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.node_token)
|
||||
.json(&serde_json::json!({
|
||||
"size_bytes": size,
|
||||
"checksum": checksum,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
info!(backup_id = %backup_id, "Backup completion notified");
|
||||
}
|
||||
Ok(resp) => {
|
||||
error!(status = %resp.status(), "Failed to notify backup completion");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "Failed to notify backup completion");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tar.gz archive from a source directory.
|
||||
fn create_tar_gz(source: &Path, dest: &Path) -> Result<()> {
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
|
||||
let file = std::fs::File::create(dest)?;
|
||||
let encoder = GzEncoder::new(file, Compression::default());
|
||||
let mut archive = tar::Builder::new(encoder);
|
||||
|
||||
archive.append_dir_all(".", source)?;
|
||||
archive.finish()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract a tar.gz archive to a destination directory.
|
||||
fn extract_tar_gz(source: &Path, dest: &Path) -> Result<()> {
|
||||
use flate2::read::GzDecoder;
|
||||
|
||||
let file = std::fs::File::open(source)?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
|
||||
archive.unpack(dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate SHA-256 checksum of a file.
|
||||
fn calculate_sha256(path: &Path) -> Result<String> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
|
||||
loop {
|
||||
let n = file.read(&mut buffer)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..n]);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Simple SHA-256 implementation using the digest approach.
|
||||
/// In production you'd use the `sha2` crate; this is a placeholder
|
||||
/// that hashes via a simple checksum for now.
|
||||
struct Sha256 {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl Sha256 {
|
||||
fn new() -> Self {
|
||||
Self { state: 0xcbf29ce484222325 }
|
||||
}
|
||||
fn update(&mut self, data: &[u8]) {
|
||||
// FNV-1a 64-bit hash (simple, not cryptographic — placeholder)
|
||||
for &byte in data {
|
||||
self.state ^= byte as u64;
|
||||
self.state = self.state.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
fn finalize(self) -> u64 {
|
||||
self.state
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -12,8 +12,16 @@ pub struct DaemonConfig {
|
||||
pub docker: DockerConfig,
|
||||
#[serde(default = "default_data_path")]
|
||||
pub data_path: PathBuf,
|
||||
/// Where `data_path` lives on the Docker host. Only differs from `data_path`
|
||||
/// when the daemon itself runs in a container: bind mounts for the game
|
||||
/// containers are resolved by the host Docker engine, not by the daemon's
|
||||
/// own mount namespace. Defaults to `data_path`.
|
||||
#[serde(default)]
|
||||
pub host_data_path: Option<PathBuf>,
|
||||
#[serde(default = "default_backup_path")]
|
||||
pub backup_path: PathBuf,
|
||||
#[serde(default)]
|
||||
pub managed_mysql: Option<ManagedMysqlConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -36,6 +44,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
|
||||
}
|
||||
@@ -77,7 +98,24 @@ grpc_port: 50051
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let config: DaemonConfig = serde_yaml::from_str(&content)?;
|
||||
let mut config: DaemonConfig = serde_yaml::from_str(&content)?;
|
||||
|
||||
// Environment overrides make containerised deployments configurable
|
||||
// without templating the YAML file.
|
||||
if let Ok(host_data_path) = std::env::var("DAEMON_HOST_DATA_PATH") {
|
||||
let trimmed = host_data_path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
config.host_data_path = Some(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Path prefix the Docker host uses for server data directories.
|
||||
pub fn host_data_path(&self) -> PathBuf {
|
||||
self.host_data_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.data_path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,442 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
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::{ServerRuntime, 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");
|
||||
|
||||
/// Labels used to persist panel-supplied runtime options on the container, so
|
||||
/// they survive a daemon restart (in-memory specs are rebuilt from Docker).
|
||||
const LABEL_DATA_PATH: &str = "gamepanel.data_mount_path";
|
||||
const LABEL_STOP_COMMAND: &str = "gamepanel.stop_command";
|
||||
const LABEL_STOP_TIMEOUT: &str = "gamepanel.stop_timeout_seconds";
|
||||
|
||||
/// Docker's SIGTERM grace period once the in-game stop command has had its turn.
|
||||
const SIGTERM_GRACE_SECS: i64 = 15;
|
||||
/// Fallback shutdown budget when the game defines no explicit timeout.
|
||||
pub const DEFAULT_STOP_TIMEOUT_SECS: i64 = 30;
|
||||
|
||||
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";
|
||||
}
|
||||
if normalized.contains("ark-server") || normalized.contains("ark-survival-evolved") {
|
||||
return "/app";
|
||||
}
|
||||
"/data"
|
||||
}
|
||||
|
||||
/// Mount point of the server data directory inside the container. The panel can
|
||||
/// override the image-derived default per game.
|
||||
fn container_data_path(spec: &ServerSpec) -> String {
|
||||
spec.runtime
|
||||
.data_mount_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| path.starts_with('/'))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| container_data_path_for_image(&spec.docker_image).to_string())
|
||||
}
|
||||
|
||||
/// Games whose process does not read stdin, so console commands have to go over
|
||||
/// RCON instead of the container's attached stdin.
|
||||
fn prefers_rcon_console(image: &str) -> bool {
|
||||
let normalized = image.to_ascii_lowercase();
|
||||
normalized.contains("cs2")
|
||||
|| normalized.contains("csgo")
|
||||
|| normalized.contains("ark-server")
|
||||
|| normalized.contains("ark-survival-evolved")
|
||||
}
|
||||
|
||||
fn runtime_labels(spec: &ServerSpec) -> HashMap<String, String> {
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert(LABEL_DATA_PATH.to_string(), container_data_path(spec));
|
||||
|
||||
if let Some(stop_command) = spec.runtime.stop_command.as_deref() {
|
||||
labels.insert(LABEL_STOP_COMMAND.to_string(), stop_command.to_string());
|
||||
}
|
||||
if let Some(timeout) = spec.runtime.stop_timeout_seconds {
|
||||
labels.insert(LABEL_STOP_TIMEOUT.to_string(), timeout.to_string());
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
fn runtime_from_labels(labels: Option<&HashMap<String, String>>) -> ServerRuntime {
|
||||
let Some(labels) = labels else {
|
||||
return ServerRuntime::default();
|
||||
};
|
||||
|
||||
ServerRuntime {
|
||||
data_mount_path: labels.get(LABEL_DATA_PATH).cloned(),
|
||||
stop_command: labels.get(LABEL_STOP_COMMAND).cloned(),
|
||||
stop_timeout_seconds: labels
|
||||
.get(LABEL_STOP_TIMEOUT)
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.filter(|value| *value > 0),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
container_id: String,
|
||||
) -> 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(
|
||||
container_id,
|
||||
input,
|
||||
drain_task,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Resolve the id of the container backing a server, but only while it is
|
||||
/// actually running. Writing to a stopped container's stdin looks like it
|
||||
/// succeeds and then goes nowhere, so this is the gate for every command.
|
||||
async fn running_container_id(&self, server_uuid: &str) -> Result<String> {
|
||||
let name = container_name(server_uuid);
|
||||
let info = match self.client().inspect_container(&name, None).await {
|
||||
Ok(info) => info,
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"server container does not exist (server has not been installed yet)"
|
||||
))
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
let running = info
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.running)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !running {
|
||||
return Err(anyhow::anyhow!(
|
||||
"server is not running, start it before sending console commands"
|
||||
));
|
||||
}
|
||||
|
||||
info.id
|
||||
.ok_or_else(|| anyhow::anyhow!("Docker did not report a container id"))
|
||||
}
|
||||
|
||||
async fn get_or_attach_command_stream(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
container_id: &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() {
|
||||
if existing.container_id() == container_id {
|
||||
return Ok(existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Container was recreated or restarted since we last attached — the old
|
||||
// hijacked socket is dead, drop it before opening a fresh one.
|
||||
self.clear_command_stream(server_uuid).await;
|
||||
|
||||
let created = self
|
||||
.attach_command_stream(&name, container_id.to_string())
|
||||
.await?;
|
||||
|
||||
let mut streams = self.command_streams().write().await;
|
||||
if let Some(existing) = streams.get(&name).cloned() {
|
||||
if existing.container_id() == container_id {
|
||||
created.abort();
|
||||
return Ok(existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Another task may have raced in with a stream for a different
|
||||
// container; its drain task has to be stopped or it leaks.
|
||||
if let Some(displaced) = streams.insert(name, created.clone()) {
|
||||
displaced.abort();
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/// IP of the container on the panel's Docker network. Reaching the game
|
||||
/// over RCON via the container IP works whether the daemon runs on the host
|
||||
/// or as a sibling container on the same network — unlike `127.0.0.1`.
|
||||
pub async fn container_ip(&self, server_uuid: &str) -> Option<String> {
|
||||
let name = container_name(server_uuid);
|
||||
let info = self.client().inspect_container(&name, None).await.ok()?;
|
||||
let networks = info.network_settings.as_ref()?;
|
||||
|
||||
if let Some(named) = networks.networks.as_ref() {
|
||||
if let Some(ip) = named
|
||||
.get(self.network_name())
|
||||
.and_then(|net| net.ip_address.clone())
|
||||
.filter(|ip| !ip.is_empty())
|
||||
{
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = named
|
||||
.values()
|
||||
.filter_map(|net| net.ip_address.clone())
|
||||
.find(|ip| !ip.is_empty())
|
||||
{
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
|
||||
networks
|
||||
.ip_address
|
||||
.clone()
|
||||
.filter(|ip| !ip.is_empty())
|
||||
}
|
||||
|
||||
/// Resolve `(address, password)` for the container's RCON endpoint from its
|
||||
/// image and environment.
|
||||
async fn rcon_endpoint(&self, server_uuid: &str) -> Result<(String, String)> {
|
||||
let (image, env) = self.container_runtime_metadata(server_uuid).await?;
|
||||
let normalized = image.to_ascii_lowercase();
|
||||
|
||||
let lookup = |keys: &[&str]| -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| env.get(*key))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
};
|
||||
|
||||
let is_ark = normalized.contains("ark-server")
|
||||
|| normalized.contains("ark-survival-evolved");
|
||||
|
||||
let (password_keys, port_keys, default_port): (&[&str], &[&str], u16) = if is_ark {
|
||||
(
|
||||
&["ARK_RCON_PASSWORD", "RCON_PASSWORD", "ADMIN_PASSWORD"],
|
||||
&["RCON_PORT"],
|
||||
27020,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
&["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"],
|
||||
&["RCON_PORT", "CS2_PORT"],
|
||||
27015,
|
||||
)
|
||||
};
|
||||
|
||||
let password = lookup(password_keys)
|
||||
.ok_or_else(|| anyhow::anyhow!("no RCON password is configured for this server"))?;
|
||||
let port = lookup(port_keys)
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(default_port);
|
||||
|
||||
let host = match lookup(&["RCON_HOST"]) {
|
||||
Some(host) => host,
|
||||
None => self
|
||||
.container_ip(server_uuid)
|
||||
.await
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
};
|
||||
|
||||
Ok((format!("{host}:{port}"), password))
|
||||
}
|
||||
|
||||
async fn send_command_via_rcon(&self, server_uuid: &str, command: &str) -> Result<()> {
|
||||
let (address, password) = self.rcon_endpoint(server_uuid).await?;
|
||||
let mut client = crate::game::rcon::RconClient::connect(&address, &password).await?;
|
||||
client.command(command).await?;
|
||||
debug!(server_uuid = %server_uuid, address = %address, "Console command delivered over RCON");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_command_via_stdin(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
container_id: &str,
|
||||
command: &str,
|
||||
) -> Result<()> {
|
||||
let payload = format!("{command}\n");
|
||||
|
||||
for attempt in 0..2 {
|
||||
let stream = self
|
||||
.get_or_attach_command_stream(server_uuid, container_id)
|
||||
.await?;
|
||||
|
||||
match stream.write_all(payload.as_bytes()).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => {
|
||||
debug!(
|
||||
server_uuid = %server_uuid,
|
||||
attempt,
|
||||
error = %error,
|
||||
"Failed to write to container stdin, resetting attach stream",
|
||||
);
|
||||
self.clear_command_stream(server_uuid).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("failed to write command to container stdin"))
|
||||
}
|
||||
|
||||
/// 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 +465,9 @@ 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(spec);
|
||||
let data_mount_path = data_mount_path.as_str();
|
||||
let bind_source = self.host_bind_source(&spec.data_path);
|
||||
|
||||
// Build port bindings
|
||||
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
||||
@@ -84,8 +503,9 @@ impl DockerManager {
|
||||
port_bindings: Some(port_bindings),
|
||||
network_mode: Some(self.network_name().to_string()),
|
||||
binds: Some(vec![format!(
|
||||
"{}:/data",
|
||||
spec.data_path.display()
|
||||
"{}:{}",
|
||||
bind_source.display(),
|
||||
data_mount_path
|
||||
)]),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -96,7 +516,14 @@ impl DockerManager {
|
||||
env: Some(env),
|
||||
exposed_ports: Some(exposed_ports),
|
||||
host_config: Some(host_config),
|
||||
working_dir: Some("/data".to_string()),
|
||||
labels: Some(runtime_labels(spec)),
|
||||
// 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 +545,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 +566,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,
|
||||
@@ -147,9 +579,104 @@ impl DockerManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shut a server down the way its game expects.
|
||||
///
|
||||
/// Sending the in-game stop command first is what makes shutdown fast: most
|
||||
/// dedicated servers ignore SIGTERM entirely and only exit once Docker's
|
||||
/// timeout expires and SIGKILL lands, which is why "stop" used to sit there
|
||||
/// for the full grace period every single time.
|
||||
pub async fn stop_container_graceful(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
stop_command: Option<&str>,
|
||||
stop_timeout_secs: i64,
|
||||
) -> Result<()> {
|
||||
let budget = if stop_timeout_secs > 0 {
|
||||
stop_timeout_secs
|
||||
} else {
|
||||
DEFAULT_STOP_TIMEOUT_SECS
|
||||
}
|
||||
.max(5);
|
||||
|
||||
let stop_command = stop_command
|
||||
.map(str::trim)
|
||||
.filter(|command| !command.is_empty());
|
||||
|
||||
if let Some(command) = stop_command {
|
||||
match self.send_command(server_uuid, command).await {
|
||||
Ok(_) => {
|
||||
let graceful_budget = (budget - SIGTERM_GRACE_SECS).max(5);
|
||||
if self.wait_until_exited(server_uuid, graceful_budget).await? {
|
||||
self.clear_command_stream(server_uuid).await;
|
||||
info!(
|
||||
uuid = %server_uuid,
|
||||
command = %command,
|
||||
"Server exited after in-game stop command",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
uuid = %server_uuid,
|
||||
command = %command,
|
||||
graceful_budget,
|
||||
"Server ignored the in-game stop command, falling back to SIGTERM",
|
||||
);
|
||||
return self.stop_container(server_uuid, SIGTERM_GRACE_SECS).await;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
uuid = %server_uuid,
|
||||
command = %command,
|
||||
error = %error,
|
||||
"Could not deliver the in-game stop command, falling back to SIGTERM",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No usable stop command: SIGTERM gets the whole budget. Docker returns
|
||||
// as soon as the container exits, so a well-behaved image (ARK, itzg)
|
||||
// still stops quickly.
|
||||
self.stop_container(server_uuid, budget).await
|
||||
}
|
||||
|
||||
/// Poll until the container is no longer running. Returns `false` on timeout.
|
||||
async fn wait_until_exited(&self, server_uuid: &str, timeout_secs: i64) -> Result<bool> {
|
||||
let deadline =
|
||||
tokio::time::Instant::now() + Duration::from_secs(timeout_secs.max(1) as u64);
|
||||
let name = container_name(server_uuid);
|
||||
|
||||
loop {
|
||||
match self.client().inspect_container(&name, None).await {
|
||||
Ok(info) => {
|
||||
let running = info
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.running)
|
||||
.unwrap_or(false);
|
||||
if !running {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => return Ok(true),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +687,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 +745,202 @@ 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 runtime = runtime_from_labels(
|
||||
info.config.as_ref().and_then(|cfg| cfg.labels.as_ref()),
|
||||
);
|
||||
|
||||
// `mount.source` is a host path; map it back into the daemon's own
|
||||
// mount namespace before we try to read or write it.
|
||||
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(|source| self.daemon_data_path(Path::new(source)))
|
||||
})
|
||||
})
|
||||
.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,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
/// Stream container logs (stdout + stderr). Returns an owned stream.
|
||||
pub fn stream_logs(
|
||||
self: &Arc<Self>,
|
||||
@@ -237,27 +961,57 @@ impl DockerManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a command to a container via exec (attach to stdin).
|
||||
/// Send a console command to a server.
|
||||
///
|
||||
/// Most images pipe the game's stdin straight through, so the attached
|
||||
/// stdin stream is the default. Source-engine and ARK servers never read
|
||||
/// stdin, so those go over RCON first — with the other transport used as a
|
||||
/// fallback in both directions.
|
||||
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');
|
||||
if trimmed.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!("Command cannot be empty"));
|
||||
}
|
||||
|
||||
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?;
|
||||
let container_id = self.running_container_id(server_uuid).await?;
|
||||
let image = self
|
||||
.container_runtime_metadata(server_uuid)
|
||||
.await
|
||||
.map(|(image, _)| image)
|
||||
.unwrap_or_default();
|
||||
|
||||
self.client()
|
||||
.start_exec(&exec.id, None::<bollard::exec::StartExecOptions>)
|
||||
.await?;
|
||||
if prefers_rcon_console(&image) {
|
||||
match self.send_command_via_rcon(server_uuid, trimmed).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(rcon_error) => {
|
||||
debug!(
|
||||
server_uuid = %server_uuid,
|
||||
error = %rcon_error,
|
||||
"RCON console delivery failed, trying container stdin",
|
||||
);
|
||||
return self
|
||||
.send_command_via_stdin(server_uuid, &container_id, trimmed)
|
||||
.await
|
||||
.map_err(|stdin_error| {
|
||||
anyhow::anyhow!(
|
||||
"RCON failed ({rcon_error}) and stdin failed ({stdin_error})"
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
match self
|
||||
.send_command_via_stdin(server_uuid, &container_id, trimmed)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(stdin_error) => self
|
||||
.send_command_via_rcon(server_uuid, trimmed)
|
||||
.await
|
||||
.map_err(|rcon_error| {
|
||||
anyhow::anyhow!("stdin failed ({stdin_error}) and RCON failed ({rcon_error})")
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,73 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
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;
|
||||
use crate::config::DaemonConfig;
|
||||
|
||||
type AttachedInput = Pin<Box<dyn AsyncWrite + Send>>;
|
||||
|
||||
pub(crate) struct CommandStreamHandle {
|
||||
/// Docker id of the container this stdin stream was opened against. A
|
||||
/// container that gets recreated (or restarted) keeps the same name but
|
||||
/// gets a new id, and writes to the old hijacked socket are silently
|
||||
/// swallowed — so the id is what makes a cached stream reusable.
|
||||
container_id: String,
|
||||
input: Mutex<AttachedInput>,
|
||||
drain_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CommandStreamHandle {
|
||||
pub(crate) fn new(
|
||||
container_id: String,
|
||||
input: AttachedInput,
|
||||
drain_task: JoinHandle<()>,
|
||||
) -> Self {
|
||||
Self {
|
||||
container_id,
|
||||
input: Mutex::new(input),
|
||||
drain_task,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn container_id(&self) -> &str {
|
||||
&self.container_id
|
||||
}
|
||||
|
||||
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,
|
||||
data_root: PathBuf,
|
||||
host_data_root: PathBuf,
|
||||
command_streams: Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>,
|
||||
}
|
||||
|
||||
impl DockerManager {
|
||||
pub async fn new(config: &DockerConfig) -> Result<Self> {
|
||||
pub async fn new(config: &DaemonConfig) -> Result<Self> {
|
||||
let client = Docker::connect_with_socket(
|
||||
&config.socket,
|
||||
&config.docker.socket,
|
||||
120, // timeout
|
||||
bollard::API_DEFAULT_VERSION,
|
||||
)?;
|
||||
@@ -27,12 +79,25 @@ impl DockerManager {
|
||||
"Connected to Docker"
|
||||
);
|
||||
|
||||
let data_root = config.data_path.clone();
|
||||
let host_data_root = config.host_data_path();
|
||||
if data_root != host_data_root {
|
||||
info!(
|
||||
data_root = %data_root.display(),
|
||||
host_data_root = %host_data_root.display(),
|
||||
"Server data directories are bind-mounted from a different host path",
|
||||
);
|
||||
}
|
||||
|
||||
let manager = Self {
|
||||
client,
|
||||
network_name: config.network.clone(),
|
||||
network_name: config.docker.network.clone(),
|
||||
data_root,
|
||||
host_data_root,
|
||||
command_streams: Arc::new(RwLock::new(HashMap::new())),
|
||||
};
|
||||
|
||||
manager.ensure_network(&config.network_subnet).await?;
|
||||
manager.ensure_network(&config.docker.network_subnet).await?;
|
||||
|
||||
Ok(manager)
|
||||
}
|
||||
@@ -45,6 +110,36 @@ impl DockerManager {
|
||||
&self.network_name
|
||||
}
|
||||
|
||||
/// Translate a daemon-local server data directory into the path the Docker
|
||||
/// host must bind-mount. These differ when the daemon runs in a container.
|
||||
pub fn host_bind_source(&self, data_path: &Path) -> PathBuf {
|
||||
if self.data_root == self.host_data_root {
|
||||
return data_path.to_path_buf();
|
||||
}
|
||||
|
||||
match data_path.strip_prefix(&self.data_root) {
|
||||
Ok(relative) => self.host_data_root.join(relative),
|
||||
Err(_) => data_path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::host_bind_source`]: turn a bind-mount source reported
|
||||
/// by Docker back into a path the daemon can read and write itself.
|
||||
pub fn daemon_data_path(&self, host_path: &Path) -> PathBuf {
|
||||
if self.data_root == self.host_data_root {
|
||||
return host_path.to_path_buf();
|
||||
}
|
||||
|
||||
match host_path.strip_prefix(&self.host_data_root) {
|
||||
Ok(relative) => self.data_root.join(relative),
|
||||
Err(_) => host_path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn command_streams(&self) -> &Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>> {
|
||||
&self.command_streams
|
||||
}
|
||||
|
||||
async fn ensure_network(&self, subnet: &str) -> Result<()> {
|
||||
let networks = self.client.list_networks::<String>(None).await?;
|
||||
let exists = networks
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tracing::debug;
|
||||
|
||||
@@ -94,14 +94,24 @@ impl FileSystem {
|
||||
/// Write file contents.
|
||||
pub async fn write_file(&self, path: &str, data: &[u8]) -> Result<(), DaemonError> {
|
||||
let resolved = self.resolve(path)?;
|
||||
let owner = resolved
|
||||
.parent()
|
||||
.and_then(resolve_target_ownership);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = resolved.parent() {
|
||||
fs::create_dir_all(parent).await.map_err(DaemonError::Io)?;
|
||||
if let Some(ref owner) = owner {
|
||||
apply_ownership_to_path_chain(parent, &owner)?;
|
||||
}
|
||||
}
|
||||
|
||||
debug!(path = %resolved.display(), "Writing file");
|
||||
fs::write(&resolved, data).await.map_err(DaemonError::Io)
|
||||
fs::write(&resolved, data).await.map_err(DaemonError::Io)?;
|
||||
if let Some(ref owner) = owner {
|
||||
apply_ownership(&resolved, owner.uid, owner.gid)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete files or directories.
|
||||
@@ -119,6 +129,100 @@ impl FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct OwnershipTarget {
|
||||
anchor: PathBuf,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn resolve_target_ownership(start: &Path) -> Option<OwnershipTarget> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let mut cursor = Some(start);
|
||||
let mut fallback: Option<OwnershipTarget> = None;
|
||||
|
||||
while let Some(path) = cursor {
|
||||
if let Ok(metadata) = std::fs::metadata(path) {
|
||||
let candidate = OwnershipTarget {
|
||||
anchor: path.to_path_buf(),
|
||||
uid: metadata.uid(),
|
||||
gid: metadata.gid(),
|
||||
};
|
||||
|
||||
if fallback.is_none() {
|
||||
fallback = Some(candidate.clone());
|
||||
}
|
||||
|
||||
if candidate.uid != 0 || candidate.gid != 0 {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
cursor = path.parent();
|
||||
}
|
||||
|
||||
fallback
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn resolve_target_ownership(_start: &Path) -> Option<OwnershipTarget> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn apply_ownership_to_path_chain(target: &Path, owner: &OwnershipTarget) -> Result<(), DaemonError> {
|
||||
if !target.starts_with(&owner.anchor) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut current = owner.anchor.clone();
|
||||
apply_ownership(¤t, owner.uid, owner.gid)?;
|
||||
|
||||
let remainder = match target.strip_prefix(&owner.anchor) {
|
||||
Ok(path) => path,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
for component in remainder.components() {
|
||||
current.push(component.as_os_str());
|
||||
apply_ownership(¤t, owner.uid, owner.gid)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn apply_ownership_to_path_chain(_target: &Path, _owner: &OwnershipTarget) -> Result<(), DaemonError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn apply_ownership(path: &Path, uid: u32, gid: u32) -> Result<(), DaemonError> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let bytes = path.as_os_str().as_bytes();
|
||||
let c_path = CString::new(bytes).map_err(|err| {
|
||||
DaemonError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid path for chown: {err}"),
|
||||
))
|
||||
})?;
|
||||
|
||||
let result = unsafe { libc::chown(c_path.as_ptr(), uid, gid) };
|
||||
if result != 0 {
|
||||
return Err(DaemonError::Io(std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn apply_ownership(_path: &Path, _uid: u32, _gid: u32) -> Result<(), DaemonError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use super::rcon::RconClient;
|
||||
|
||||
/// Player information from an ARK RCON `ListPlayers` response.
|
||||
pub struct ArkPlayer {
|
||||
pub name: String,
|
||||
pub steamid: String,
|
||||
}
|
||||
|
||||
/// Query an ARK server for its connected players.
|
||||
pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<Vec<ArkPlayer>> {
|
||||
let mut client = RconClient::connect(rcon_address, rcon_password).await?;
|
||||
let response = client.command("ListPlayers").await?;
|
||||
|
||||
let players = parse_list_players_response(&response);
|
||||
info!(count = players.len(), "ARK player list retrieved");
|
||||
|
||||
Ok(players)
|
||||
}
|
||||
|
||||
/// Parses lines shaped like `0. PlayerName, 76561198000000000`.
|
||||
fn parse_list_players_response(response: &str) -> Vec<ArkPlayer> {
|
||||
let mut players = Vec::new();
|
||||
|
||||
for line in response.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// "No Players Connected"
|
||||
if trimmed.eq_ignore_ascii_case("no players connected") {
|
||||
break;
|
||||
}
|
||||
|
||||
// Strip the "<index>. " prefix.
|
||||
let entry = match trimmed.split_once('.') {
|
||||
Some((index, rest)) if index.trim().chars().all(|c| c.is_ascii_digit()) => rest.trim(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let (name, steamid) = match entry.rsplit_once(',') {
|
||||
Some((name, steamid)) => (name.trim(), steamid.trim()),
|
||||
None => (entry, ""),
|
||||
};
|
||||
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
players.push(ArkPlayer {
|
||||
name: name.to_string(),
|
||||
steamid: steamid.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
players
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_connected_players() {
|
||||
let response = "0. Alper, 76561198000000001\n1. Rezan, 76561198000000002\n";
|
||||
let players = parse_list_players_response(response);
|
||||
|
||||
assert_eq!(players.len(), 2);
|
||||
assert_eq!(players[0].name, "Alper");
|
||||
assert_eq!(players[0].steamid, "76561198000000001");
|
||||
assert_eq!(players[1].name, "Rezan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_server() {
|
||||
assert!(parse_list_players_response("No Players Connected\n").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use super::rcon::RconClient;
|
||||
|
||||
/// Player information from CS2 RCON.
|
||||
pub struct Cs2Player {
|
||||
pub name: String,
|
||||
pub steamid: String,
|
||||
pub score: i32,
|
||||
pub ping: u32,
|
||||
}
|
||||
|
||||
/// Query CS2 server for active players using RCON `status` command.
|
||||
pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<(Vec<Cs2Player>, u32)> {
|
||||
let mut client = RconClient::connect(rcon_address, rcon_password).await?;
|
||||
let response = client.command("status").await?;
|
||||
|
||||
let (players, max) = parse_status_response(&response);
|
||||
|
||||
info!(
|
||||
count = players.len(),
|
||||
max = max,
|
||||
"CS2 player list retrieved"
|
||||
);
|
||||
|
||||
Ok((players, max))
|
||||
}
|
||||
|
||||
fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
|
||||
let mut players = Vec::new();
|
||||
let mut max_players = 0u32;
|
||||
let mut in_player_section = false;
|
||||
|
||||
for line in response.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed.contains("---------players--------") || trimmed.starts_with("# userid") {
|
||||
in_player_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_player_section && (trimmed == "#end" || trimmed.starts_with("---------")) {
|
||||
in_player_section = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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,
|
||||
score: 0,
|
||||
ping: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(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::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_status_basic() {
|
||||
let response = r#"hostname: Test Server
|
||||
version : 2.0.0
|
||||
players : 2 humans, 0 bots (16/0 max) (not hibernating)
|
||||
# userid name steamid connected ping loss state rate
|
||||
# 2 "Player1" STEAM_1:0:12345 00:05 50 0 active 128000
|
||||
# 3 "Player2" STEAM_1:0:67890 00:10 30 0 active 128000
|
||||
"#;
|
||||
let (players, max) = parse_status_response(response);
|
||||
assert_eq!(max, 16);
|
||||
assert_eq!(players.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_status_current_cs2_format() {
|
||||
let response = r#"Server: Running [0.0.0.0:27015]
|
||||
players : 1 humans, 2 bots (0 max) (not hibernating) (unreserved)
|
||||
---------players--------
|
||||
id time ping loss state rate adr name
|
||||
65535 [NoChan] 0 0 challenging 0unknown ''
|
||||
1 BOT 0 0 active 0 'Rezan'
|
||||
2 00:21 11 0 active 786432 212.154.6.153:57008 'hibna'
|
||||
3 BOT 0 0 active 0 'Squad'
|
||||
#end
|
||||
"#;
|
||||
|
||||
let (players, max) = parse_status_response(response);
|
||||
assert_eq!(max, 0);
|
||||
assert_eq!(players.len(), 3);
|
||||
assert_eq!(players[0].name, "Rezan");
|
||||
assert_eq!(players[1].name, "hibna");
|
||||
assert_eq!(players[2].name, "Squad");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use super::rcon::RconClient;
|
||||
|
||||
/// Player information from Minecraft RCON.
|
||||
pub struct MinecraftPlayer {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Query Minecraft server for active players using RCON `list` command.
|
||||
pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<(Vec<MinecraftPlayer>, u32)> {
|
||||
let mut client = RconClient::connect(rcon_address, rcon_password).await?;
|
||||
let response = client.command("list").await?;
|
||||
|
||||
// Parse response: "There are X of a max of Y players online: player1, player2"
|
||||
let (count, max, players) = parse_list_response(&response);
|
||||
|
||||
info!(
|
||||
count = count,
|
||||
max = max,
|
||||
"Minecraft player list retrieved"
|
||||
);
|
||||
|
||||
Ok((players, max))
|
||||
}
|
||||
|
||||
fn parse_list_response(response: &str) -> (u32, u32, Vec<MinecraftPlayer>) {
|
||||
// Format: "There are X of a max of Y players online: player1, player2, ..."
|
||||
// Or: "There are X of a max Y players online:"
|
||||
let parts: Vec<&str> = response.splitn(2, ':').collect();
|
||||
|
||||
let mut count = 0u32;
|
||||
let mut max = 0u32;
|
||||
let mut found_count = false;
|
||||
|
||||
if let Some(header) = parts.first() {
|
||||
// Extract numbers from "There are X of a max of Y players online"
|
||||
let words: Vec<&str> = header.split_whitespace().collect();
|
||||
for word in words.iter() {
|
||||
if let Ok(n) = word.parse::<u32>() {
|
||||
if !found_count {
|
||||
count = n;
|
||||
found_count = true;
|
||||
} else {
|
||||
max = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut players = Vec::new();
|
||||
if parts.len() > 1 {
|
||||
let player_list = parts[1].trim();
|
||||
if !player_list.is_empty() {
|
||||
for name in player_list.split(',') {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() {
|
||||
players.push(MinecraftPlayer {
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(count, max, players)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_list_response() {
|
||||
let (count, max, players) = parse_list_response(
|
||||
"There are 3 of a max of 20 players online: Steve, Alex, Notch",
|
||||
);
|
||||
assert_eq!(count, 3);
|
||||
assert_eq!(max, 20);
|
||||
assert_eq!(players.len(), 3);
|
||||
assert_eq!(players[0].name, "Steve");
|
||||
assert_eq!(players[1].name, "Alex");
|
||||
assert_eq!(players[2].name, "Notch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_list() {
|
||||
let (count, max, players) = parse_list_response(
|
||||
"There are 0 of a max of 20 players online:",
|
||||
);
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(max, 20);
|
||||
assert_eq!(players.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod rcon;
|
||||
pub mod minecraft;
|
||||
pub mod cs2;
|
||||
pub mod ark;
|
||||
@@ -0,0 +1,87 @@
|
||||
use anyhow::{Result, Context};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::debug;
|
||||
|
||||
/// RCON packet types
|
||||
const PACKET_LOGIN: i32 = 3;
|
||||
const PACKET_COMMAND: i32 = 2;
|
||||
const PACKET_RESPONSE: i32 = 0;
|
||||
|
||||
/// A minimal Source RCON client.
|
||||
pub struct RconClient {
|
||||
stream: TcpStream,
|
||||
request_id: i32,
|
||||
}
|
||||
|
||||
impl RconClient {
|
||||
/// Connect to an RCON server and authenticate.
|
||||
pub async fn connect(address: &str, password: &str) -> Result<Self> {
|
||||
let stream = TcpStream::connect(address)
|
||||
.await
|
||||
.context("Failed to connect to RCON")?;
|
||||
|
||||
let mut client = Self {
|
||||
stream,
|
||||
request_id: 0,
|
||||
};
|
||||
|
||||
// Authenticate
|
||||
let response = client.send_packet(PACKET_LOGIN, password).await?;
|
||||
if response.id == -1 {
|
||||
anyhow::bail!("RCON authentication failed");
|
||||
}
|
||||
|
||||
debug!(address = %address, "RCON connected and authenticated");
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Send a command and return the response body.
|
||||
pub async fn command(&mut self, cmd: &str) -> Result<String> {
|
||||
let response = self.send_packet(PACKET_COMMAND, cmd).await?;
|
||||
Ok(response.body)
|
||||
}
|
||||
|
||||
async fn send_packet(&mut self, packet_type: i32, body: &str) -> Result<RconPacket> {
|
||||
self.request_id += 1;
|
||||
let id = self.request_id;
|
||||
|
||||
let body_bytes = body.as_bytes();
|
||||
let length = 4 + 4 + body_bytes.len() + 2; // id + type + body + 2 null bytes
|
||||
|
||||
// Write packet
|
||||
self.stream.write_i32_le(length as i32).await?;
|
||||
self.stream.write_i32_le(id).await?;
|
||||
self.stream.write_i32_le(packet_type).await?;
|
||||
self.stream.write_all(body_bytes).await?;
|
||||
self.stream.write_all(&[0, 0]).await?; // two null terminators
|
||||
self.stream.flush().await?;
|
||||
|
||||
// Read response
|
||||
let resp_length = self.stream.read_i32_le().await?;
|
||||
let resp_id = self.stream.read_i32_le().await?;
|
||||
let resp_type = self.stream.read_i32_le().await?;
|
||||
|
||||
let body_length = (resp_length - 4 - 4 - 2) as usize;
|
||||
let mut body_buf = vec![0u8; body_length];
|
||||
self.stream.read_exact(&mut body_buf).await?;
|
||||
|
||||
// Read two null terminators
|
||||
let mut null_buf = [0u8; 2];
|
||||
self.stream.read_exact(&mut null_buf).await?;
|
||||
|
||||
let response_body = String::from_utf8_lossy(&body_buf).to_string();
|
||||
|
||||
Ok(RconPacket {
|
||||
id: resp_id,
|
||||
packet_type: resp_type,
|
||||
body: response_body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct RconPacket {
|
||||
id: i32,
|
||||
packet_type: i32,
|
||||
body: String,
|
||||
}
|
||||
@@ -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
|
||||
+635
-41
@@ -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::server::{ServerManager, PortMap};
|
||||
use crate::command::CommandDispatcher;
|
||||
use crate::server::{ServerManager, ServerRuntime, 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,71 @@ 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())
|
||||
}
|
||||
|
||||
/// Host to reach a server's RCON port on. The container's own IP works both
|
||||
/// when the daemon runs on the host and when it runs as a sibling container;
|
||||
/// `127.0.0.1` only works in the former case.
|
||||
async fn rcon_host(&self, uuid: &str, env: &HashMap<String, String>) -> String {
|
||||
if let Some(host) = Self::env_value(env, &["RCON_HOST"]) {
|
||||
return host;
|
||||
}
|
||||
|
||||
self.server_manager
|
||||
.docker()
|
||||
.container_ip(uuid)
|
||||
.await
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string())
|
||||
}
|
||||
|
||||
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 +181,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,19 +206,11 @@ 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();
|
||||
let runtime = ServerRuntime::from_request(
|
||||
req.data_path,
|
||||
req.stop_command,
|
||||
req.stop_timeout_seconds,
|
||||
);
|
||||
|
||||
self.server_manager
|
||||
.create_server(
|
||||
@@ -140,7 +221,8 @@ impl DaemonService for DaemonServiceImpl {
|
||||
req.cpu_limit,
|
||||
req.startup_command,
|
||||
req.environment,
|
||||
ports,
|
||||
Self::map_ports(&req.ports),
|
||||
runtime,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Status::from(e))?;
|
||||
@@ -151,6 +233,40 @@ 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 runtime = ServerRuntime::from_request(
|
||||
req.data_path,
|
||||
req.stop_command,
|
||||
req.stop_timeout_seconds,
|
||||
);
|
||||
|
||||
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),
|
||||
runtime,
|
||||
)
|
||||
.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 +297,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(
|
||||
@@ -190,15 +407,29 @@ impl DaemonService for DaemonServiceImpl {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
match req.action() {
|
||||
let action = req.action();
|
||||
let stop_command = if req.stop_command.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(req.stop_command.as_str())
|
||||
};
|
||||
let stop_timeout = i64::from(req.stop_timeout_seconds);
|
||||
|
||||
match action {
|
||||
PowerAction::Start => {
|
||||
self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?;
|
||||
}
|
||||
PowerAction::Stop => {
|
||||
self.server_manager.stop_server(&req.uuid).await.map_err(Status::from)?;
|
||||
self.server_manager
|
||||
.stop_server(&req.uuid, stop_command, stop_timeout)
|
||||
.await
|
||||
.map_err(Status::from)?;
|
||||
}
|
||||
PowerAction::Restart => {
|
||||
let _ = self.server_manager.stop_server(&req.uuid).await;
|
||||
let _ = self
|
||||
.server_manager
|
||||
.stop_server(&req.uuid, stop_command, stop_timeout)
|
||||
.await;
|
||||
self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?;
|
||||
}
|
||||
PowerAction::Kill => {
|
||||
@@ -241,9 +472,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 +511,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 +618,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 +639,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 +661,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 +741,297 @@ 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("ark-server") || image.contains("ark-survival-evolved") {
|
||||
max_from_runtime_env = Self::env_i32(&env, &["MAX_PLAYERS"]).unwrap_or(0);
|
||||
|
||||
let host = self.rcon_host(&uuid, &env).await;
|
||||
let port = Self::env_u16(&env, &["RCON_PORT"]).unwrap_or(27020);
|
||||
let password = Self::env_value(
|
||||
&env,
|
||||
&["ARK_RCON_PASSWORD", "RCON_PASSWORD", "ADMIN_PASSWORD"],
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let address = format!("{}:{}", host, port);
|
||||
|
||||
match crate::game::ark::get_players(&address, &password).await {
|
||||
Ok(players) => {
|
||||
let mapped = players
|
||||
.into_iter()
|
||||
.map(|p| Player {
|
||||
name: p.name,
|
||||
uuid: p.steamid,
|
||||
connected_at: 0,
|
||||
})
|
||||
.collect();
|
||||
return Ok(Response::new(PlayerList {
|
||||
players: mapped,
|
||||
max_players: max_from_runtime_env,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(uuid = %uuid, error = %e, "ARK 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.rcon_host(&uuid, &env).await;
|
||||
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 +1051,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)
|
||||
}
|
||||
|
||||
+55
-2
@@ -5,20 +5,43 @@ use tracing::info;
|
||||
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<()> {
|
||||
// `--health-check` is what the container HEALTHCHECK runs: succeed only if
|
||||
// the gRPC listener is actually accepting connections.
|
||||
if std::env::args().any(|arg| arg == "--health-check") {
|
||||
let config = config::DaemonConfig::load()?;
|
||||
let address = format!("127.0.0.1:{}", config.grpc_port);
|
||||
return match tokio::net::TcpStream::connect(&address).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) => {
|
||||
eprintln!("daemon health check failed for {address}: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -33,17 +56,31 @@ async fn main() -> Result<()> {
|
||||
info!(grpc_port = config.grpc_port, "Configuration loaded");
|
||||
|
||||
// Initialize Docker
|
||||
let docker = Arc::new(DockerManager::new(&config.docker).await?);
|
||||
let docker = Arc::new(DockerManager::new(&config).await?);
|
||||
info!("Docker manager initialized");
|
||||
|
||||
// Initialize server manager
|
||||
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
|
||||
@@ -58,9 +95,25 @@ async fn main() -> Result<()> {
|
||||
heartbeat_loop(&api_url, &node_token, sm).await;
|
||||
});
|
||||
|
||||
// Scheduler task
|
||||
let sched = Arc::new(scheduler::Scheduler::new(
|
||||
server_manager.clone(),
|
||||
command_dispatcher.clone(),
|
||||
config.api_url.clone(),
|
||||
config.node_token.clone(),
|
||||
));
|
||||
tokio::spawn(async move {
|
||||
sched.run().await;
|
||||
});
|
||||
info!("Scheduler initialized");
|
||||
|
||||
// Start serving
|
||||
let daemon_service = DaemonServiceServer::new(daemon_service)
|
||||
.max_decoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES)
|
||||
.max_encoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES);
|
||||
|
||||
Server::builder()
|
||||
.add_service(DaemonServiceServer::new(daemon_service))
|
||||
.add_service(daemon_service)
|
||||
.serve_with_shutdown(addr, async {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
info!("Shutdown signal received");
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
use std::io::ErrorKind;
|
||||
use std::process::Stdio;
|
||||
|
||||
use reqwest::Url;
|
||||
use thiserror::Error;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use tonic::Status;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::ManagedMysqlConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ManagedMysqlRuntimeConfig {
|
||||
admin_database: String,
|
||||
admin_host: String,
|
||||
admin_password: String,
|
||||
admin_port: u16,
|
||||
admin_username: String,
|
||||
client_bin: Option<String>,
|
||||
connection_host: String,
|
||||
connection_port: u16,
|
||||
phpmyadmin_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ManagedMysqlDatabase {
|
||||
pub database_name: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub phpmyadmin_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManagedMysqlError {
|
||||
#[error("Managed MySQL is not configured on this node")]
|
||||
NotConfigured,
|
||||
|
||||
#[error("Managed MySQL configuration is invalid: {0}")]
|
||||
InvalidConfig(String),
|
||||
|
||||
#[error("Managed MySQL client binary is not installed on this node")]
|
||||
ClientMissing,
|
||||
|
||||
#[error("Managed MySQL command failed: {0}")]
|
||||
CommandFailed(String),
|
||||
|
||||
#[error("Managed MySQL I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<ManagedMysqlError> for Status {
|
||||
fn from(error: ManagedMysqlError) -> Self {
|
||||
match error {
|
||||
ManagedMysqlError::NotConfigured | ManagedMysqlError::ClientMissing => {
|
||||
Status::failed_precondition(error.to_string())
|
||||
}
|
||||
ManagedMysqlError::InvalidConfig(_) => Status::internal(error.to_string()),
|
||||
ManagedMysqlError::CommandFailed(_) => Status::internal(error.to_string()),
|
||||
ManagedMysqlError::Io(_) => Status::internal(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ManagedMysqlManager {
|
||||
config: Option<ManagedMysqlRuntimeConfig>,
|
||||
}
|
||||
|
||||
impl ManagedMysqlManager {
|
||||
pub fn new(config: Option<ManagedMysqlConfig>) -> Result<Self, ManagedMysqlError> {
|
||||
let runtime = match config {
|
||||
Some(config) => Some(resolve_runtime_config(config)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Self { config: runtime })
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.config.is_some()
|
||||
}
|
||||
|
||||
pub async fn create_database(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
label: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<ManagedMysqlDatabase, ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
let label = label.trim();
|
||||
if label.is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"Database name is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let database_name = build_database_name(server_uuid, label);
|
||||
let username = build_username(server_uuid);
|
||||
let password = build_password(password);
|
||||
|
||||
self.run_sql(
|
||||
config,
|
||||
&format!(
|
||||
"CREATE DATABASE {} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||
escape_identifier(&database_name)
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(error) = self
|
||||
.run_sql(
|
||||
config,
|
||||
&format!(
|
||||
"CREATE USER {}@'%' IDENTIFIED BY {};GRANT ALL PRIVILEGES ON {}.* TO {}@'%'",
|
||||
escape_string(&username),
|
||||
escape_string(&password),
|
||||
escape_identifier(&database_name),
|
||||
escape_string(&username),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = self
|
||||
.run_sql(
|
||||
config,
|
||||
&format!("DROP DATABASE IF EXISTS {}", escape_identifier(&database_name)),
|
||||
)
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(ManagedMysqlDatabase {
|
||||
database_name: database_name.clone(),
|
||||
username,
|
||||
password,
|
||||
host: config.connection_host.clone(),
|
||||
port: config.connection_port,
|
||||
phpmyadmin_url: build_phpmyadmin_url(config.phpmyadmin_url.as_deref(), &database_name),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_password(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
let password = password.trim();
|
||||
if password.is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"Database password is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.run_sql(
|
||||
config,
|
||||
&format!(
|
||||
"ALTER USER {}@'%' IDENTIFIED BY {}",
|
||||
escape_string(username),
|
||||
escape_string(password),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn import_sql(
|
||||
&self,
|
||||
database_name: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
let database_name = database_name.trim();
|
||||
if database_name.is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"Database name is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if sql.trim().is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"SQL payload is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.run_sql_script(config, database_name, sql).await
|
||||
}
|
||||
|
||||
pub async fn delete_database(
|
||||
&self,
|
||||
database_name: &str,
|
||||
username: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
|
||||
self.run_sql(
|
||||
config,
|
||||
&format!(
|
||||
"DROP DATABASE IF EXISTS {};DROP USER IF EXISTS {}@'%'",
|
||||
escape_identifier(database_name),
|
||||
escape_string(username),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_sql(
|
||||
&self,
|
||||
config: &ManagedMysqlRuntimeConfig,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let binaries = match config.client_bin.as_deref() {
|
||||
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
|
||||
_ => vec!["mariadb".to_string(), "mysql".to_string()],
|
||||
};
|
||||
|
||||
let mut missing_binary = false;
|
||||
|
||||
for binary in binaries {
|
||||
let output = Command::new(&binary)
|
||||
.args([
|
||||
"--protocol=TCP",
|
||||
"--batch",
|
||||
"--skip-column-names",
|
||||
"-h",
|
||||
&config.admin_host,
|
||||
"-P",
|
||||
&config.admin_port.to_string(),
|
||||
"-u",
|
||||
&config.admin_username,
|
||||
&config.admin_database,
|
||||
"-e",
|
||||
sql,
|
||||
])
|
||||
.env("MYSQL_PWD", &config.admin_password)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => return Ok(()),
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let message = if !stderr.is_empty() {
|
||||
stderr
|
||||
} else if !stdout.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{} exited with status {}", binary, output.status)
|
||||
};
|
||||
return Err(ManagedMysqlError::CommandFailed(message));
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
missing_binary = true;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(ManagedMysqlError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
if missing_binary {
|
||||
return Err(ManagedMysqlError::ClientMissing);
|
||||
}
|
||||
|
||||
Err(ManagedMysqlError::ClientMissing)
|
||||
}
|
||||
|
||||
async fn run_sql_script(
|
||||
&self,
|
||||
config: &ManagedMysqlRuntimeConfig,
|
||||
database_name: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let binaries = match config.client_bin.as_deref() {
|
||||
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
|
||||
_ => vec!["mariadb".to_string(), "mysql".to_string()],
|
||||
};
|
||||
|
||||
let mut missing_binary = false;
|
||||
|
||||
for binary in binaries {
|
||||
let child = Command::new(&binary)
|
||||
.args([
|
||||
"--protocol=TCP",
|
||||
"--batch",
|
||||
"--skip-column-names",
|
||||
"-h",
|
||||
&config.admin_host,
|
||||
"-P",
|
||||
&config.admin_port.to_string(),
|
||||
"-u",
|
||||
&config.admin_username,
|
||||
database_name,
|
||||
])
|
||||
.env("MYSQL_PWD", &config.admin_password)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
match child {
|
||||
Ok(mut child) => {
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(sql.as_bytes()).await?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().await?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let message = if !stderr.is_empty() {
|
||||
stderr
|
||||
} else if !stdout.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{} exited with status {}", binary, output.status)
|
||||
};
|
||||
return Err(ManagedMysqlError::CommandFailed(message));
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
missing_binary = true;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(ManagedMysqlError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
if missing_binary {
|
||||
return Err(ManagedMysqlError::ClientMissing);
|
||||
}
|
||||
|
||||
Err(ManagedMysqlError::ClientMissing)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_runtime_config(
|
||||
config: ManagedMysqlConfig,
|
||||
) -> Result<ManagedMysqlRuntimeConfig, ManagedMysqlError> {
|
||||
let parsed = Url::parse(&config.url)
|
||||
.map_err(|error| ManagedMysqlError::InvalidConfig(error.to_string()))?;
|
||||
|
||||
if parsed.scheme() != "mysql" && parsed.scheme() != "mariadb" {
|
||||
return Err(ManagedMysqlError::InvalidConfig(
|
||||
"url must use mysql:// or mariadb://".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let admin_host = parsed.host_str().unwrap_or_default().trim().to_string();
|
||||
let admin_username = parsed.username().trim().to_string();
|
||||
|
||||
if admin_host.is_empty() || admin_username.is_empty() {
|
||||
return Err(ManagedMysqlError::InvalidConfig(
|
||||
"url must include host and username".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let admin_database = {
|
||||
let trimmed = parsed.path().trim_start_matches('/').trim();
|
||||
if trimmed.is_empty() {
|
||||
"mysql".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ManagedMysqlRuntimeConfig {
|
||||
admin_database,
|
||||
admin_host: admin_host.clone(),
|
||||
admin_password: parsed.password().unwrap_or_default().to_string(),
|
||||
admin_port: parsed.port().unwrap_or(3306),
|
||||
admin_username,
|
||||
client_bin: config.bin,
|
||||
connection_host: config.connection_host.unwrap_or(admin_host),
|
||||
connection_port: config.connection_port.unwrap_or(parsed.port().unwrap_or(3306)),
|
||||
phpmyadmin_url: config.phpmyadmin_url,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String {
|
||||
let mut normalized = String::with_capacity(value.len());
|
||||
|
||||
for ch in value.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
normalized.push(ch.to_ascii_lowercase());
|
||||
} else if !normalized.ends_with('_') {
|
||||
normalized.push('_');
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = normalized.trim_matches('_');
|
||||
if trimmed.is_empty() {
|
||||
return fallback.to_string();
|
||||
}
|
||||
|
||||
trimmed
|
||||
.chars()
|
||||
.take(max_len)
|
||||
.collect::<String>()
|
||||
.trim_end_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_database_name(server_uuid: &str, label: &str) -> String {
|
||||
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 12);
|
||||
let label_token = normalize_token(label, "db", 16);
|
||||
let suffix = Uuid::new_v4().simple().to_string();
|
||||
format!("srv_{}_{}_{}", server_token, label_token, &suffix[..8])
|
||||
.chars()
|
||||
.take(64)
|
||||
.collect::<String>()
|
||||
.trim_end_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_username(server_uuid: &str) -> String {
|
||||
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 8);
|
||||
let suffix = Uuid::new_v4().simple().to_string();
|
||||
format!("u_{}_{}", server_token, &suffix[..8])
|
||||
.chars()
|
||||
.take(32)
|
||||
.collect::<String>()
|
||||
.trim_end_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_password(password: Option<&str>) -> String {
|
||||
match password {
|
||||
Some(password) if !password.trim().is_empty() => password.trim().to_string(),
|
||||
_ => {
|
||||
let first = Uuid::new_v4().simple().to_string();
|
||||
let second = Uuid::new_v4().simple().to_string();
|
||||
format!("{}{}", first, second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_identifier(value: &str) -> String {
|
||||
format!("`{}`", value.replace('`', "``"))
|
||||
}
|
||||
|
||||
fn escape_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn build_phpmyadmin_url(base_url: Option<&str>, database_name: &str) -> Option<String> {
|
||||
let base_url = base_url?.trim();
|
||||
if base_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match Url::parse(base_url) {
|
||||
Ok(mut url) => {
|
||||
url.query_pairs_mut().append_pair("db", database_name);
|
||||
Some(url.to_string())
|
||||
}
|
||||
Err(_) => Some(base_url.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
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.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ScheduledTask {
|
||||
pub id: String,
|
||||
pub server_uuid: String,
|
||||
pub action: String, // "command", "power", "backup"
|
||||
pub payload: String, // command string, power action, or "backup"
|
||||
pub schedule_type: String,
|
||||
pub is_active: bool,
|
||||
pub next_run_at: Option<String>, // ISO 8601
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the scheduler loop. This should be spawned as a tokio task.
|
||||
pub async fn run(self: Arc<Self>) {
|
||||
info!("Scheduler started (poll interval: {}s)", self.poll_interval_secs);
|
||||
let mut tick = interval(Duration::from_secs(self.poll_interval_secs));
|
||||
|
||||
loop {
|
||||
tick.tick().await;
|
||||
if let Err(e) = self.poll_and_execute().await {
|
||||
error!(error = %e, "Scheduler poll failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the API for due tasks and execute them.
|
||||
async fn poll_and_execute(&self) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/internal/schedules/due", self.api_url);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(&self.node_token)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!(status = %resp.status(), "Failed to fetch due tasks");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DueResponse {
|
||||
tasks: Vec<ScheduledTask>,
|
||||
}
|
||||
|
||||
let due: DueResponse = resp.json().await?;
|
||||
if due.tasks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(count = due.tasks.len(), "Processing due scheduled tasks");
|
||||
|
||||
for task in &due.tasks {
|
||||
if let Err(e) = self.execute_task(task).await {
|
||||
error!(
|
||||
task_id = %task.id,
|
||||
server = %task.server_uuid,
|
||||
error = %e,
|
||||
"Failed to execute scheduled task"
|
||||
);
|
||||
}
|
||||
|
||||
// Notify API that task was executed
|
||||
let ack_url = format!(
|
||||
"{}/api/internal/schedules/{}/ack",
|
||||
self.api_url, task.id
|
||||
);
|
||||
let _ = client
|
||||
.post(&ack_url)
|
||||
.bearer_auth(&self.node_token)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a single scheduled task.
|
||||
async fn execute_task(&self, task: &ScheduledTask) -> Result<()> {
|
||||
info!(
|
||||
task_id = %task.id,
|
||||
action = %task.action,
|
||||
server = %task.server_uuid,
|
||||
"Executing scheduled task"
|
||||
);
|
||||
|
||||
match task.action.as_str() {
|
||||
"command" => {
|
||||
self.command_dispatcher
|
||||
.send_command(&task.server_uuid, &task.payload)
|
||||
.await?;
|
||||
}
|
||||
"power" => {
|
||||
match task.payload.as_str() {
|
||||
"start" => self.server_manager.start_server(&task.server_uuid).await?,
|
||||
"stop" => self.server_manager.stop_server(&task.server_uuid, None, 0).await?,
|
||||
"restart" => {
|
||||
let _ = self.server_manager.stop_server(&task.server_uuid, None, 0).await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
self.server_manager.start_server(&task.server_uuid).await?;
|
||||
}
|
||||
"kill" => self.server_manager.kill_server(&task.server_uuid).await?,
|
||||
_ => warn!(payload = %task.payload, "Unknown power action"),
|
||||
}
|
||||
}
|
||||
"backup" => {
|
||||
// Trigger backup via the backup module
|
||||
info!(
|
||||
server = %task.server_uuid,
|
||||
"Backup scheduled task — delegating to backup module"
|
||||
);
|
||||
// Backup is handled by sending callback to API
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}/api/internal/servers/{}/backup",
|
||||
self.api_url, task.server_uuid
|
||||
);
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.node_token)
|
||||
.json(&serde_json::json!({ "name": format!("auto-{}", task.id) }))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
_ => {
|
||||
warn!(action = %task.action, "Unknown scheduled action");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ 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;
|
||||
use crate::error::DaemonError;
|
||||
use super::state::{ServerState, ServerSpec, PortMap};
|
||||
use super::state::{ServerState, ServerSpec, ServerRuntime, PortMap};
|
||||
|
||||
/// Manages all game server instances on this node.
|
||||
pub struct ServerManager {
|
||||
@@ -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;
|
||||
@@ -52,6 +101,7 @@ impl ServerManager {
|
||||
startup_command: String,
|
||||
environment: HashMap<String, String>,
|
||||
ports: Vec<PortMap>,
|
||||
runtime: ServerRuntime,
|
||||
) -> Result<(), DaemonError> {
|
||||
let mut servers = self.servers.write().await;
|
||||
if servers.contains_key(&uuid) {
|
||||
@@ -59,11 +109,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(),
|
||||
@@ -77,6 +123,7 @@ impl ServerManager {
|
||||
data_path,
|
||||
state: ServerState::Installing,
|
||||
container_id: None,
|
||||
runtime,
|
||||
};
|
||||
|
||||
servers.insert(uuid.clone(), spec);
|
||||
@@ -98,6 +145,136 @@ 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>,
|
||||
runtime: ServerRuntime,
|
||||
) -> 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,
|
||||
runtime: runtime.clone(),
|
||||
};
|
||||
|
||||
if runtime_state
|
||||
.as_deref()
|
||||
.map(Self::is_running_state)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let previous_runtime = existing
|
||||
.as_ref()
|
||||
.map(|spec| spec.runtime.clone())
|
||||
.unwrap_or_else(|| runtime.clone());
|
||||
|
||||
if let Err(stop_error) = self
|
||||
.docker
|
||||
.stop_container_graceful(
|
||||
&uuid,
|
||||
previous_runtime.stop_command.as_deref(),
|
||||
previous_runtime.stop_timeout_seconds.unwrap_or(0),
|
||||
)
|
||||
.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,57 +307,117 @@ 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(())
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
});
|
||||
///
|
||||
/// `stop_command` / `stop_timeout_seconds` override whatever was captured
|
||||
/// when the container was created; pass `None` / `0` to use those defaults.
|
||||
pub async fn stop_server(
|
||||
&self,
|
||||
uuid: &str,
|
||||
stop_command: Option<&str>,
|
||||
stop_timeout_seconds: i64,
|
||||
) -> Result<(), DaemonError> {
|
||||
let mut managed = false;
|
||||
let mut previous_state: Option<ServerState> = None;
|
||||
let mut spec_runtime = ServerRuntime::default();
|
||||
{
|
||||
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_runtime = spec.runtime.clone();
|
||||
spec.state = ServerState::Stopping;
|
||||
managed = true;
|
||||
}
|
||||
}
|
||||
|
||||
spec.state = ServerState::Stopping;
|
||||
drop(servers);
|
||||
let effective_command = stop_command
|
||||
.map(str::trim)
|
||||
.filter(|command| !command.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| spec_runtime.stop_command.clone());
|
||||
|
||||
self.docker.stop_container(uuid, 30).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to stop container: {}", e))
|
||||
})?;
|
||||
let effective_timeout = if stop_timeout_seconds > 0 {
|
||||
stop_timeout_seconds
|
||||
} else {
|
||||
spec_runtime.stop_timeout_seconds.unwrap_or(0)
|
||||
};
|
||||
|
||||
let mut servers = self.servers.write().await;
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
spec.state = ServerState::Stopped;
|
||||
if let Err(e) = self
|
||||
.docker
|
||||
.stop_container_graceful(uuid, effective_command.as_deref(), effective_timeout)
|
||||
.await
|
||||
{
|
||||
if managed {
|
||||
let mut servers = self.servers.write().await;
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
spec.state = previous_state.unwrap_or(ServerState::Error);
|
||||
}
|
||||
}
|
||||
return Err(DaemonError::Internal(format!("Failed to stop container: {}", e)));
|
||||
}
|
||||
|
||||
if managed {
|
||||
let mut servers = self.servers.write().await;
|
||||
if let Some(spec) = servers.get_mut(uuid) {
|
||||
spec.state = ServerState::Stopped;
|
||||
}
|
||||
} else {
|
||||
info!(uuid = %uuid, "Stopped container without managed runtime state");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod state;
|
||||
pub mod manager;
|
||||
|
||||
pub use state::{ServerSpec, PortMap};
|
||||
pub use state::{ServerSpec, ServerRuntime, PortMap};
|
||||
pub use manager::ServerManager;
|
||||
|
||||
@@ -33,6 +33,46 @@ pub struct PortMap {
|
||||
pub protocol: String, // "tcp" or "udp"
|
||||
}
|
||||
|
||||
/// Per-game runtime knobs supplied by the panel. Mirrored into Docker labels so
|
||||
/// they survive a daemon restart (see `docker::container`).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ServerRuntime {
|
||||
/// Mount point of the data directory inside the container. `None` means
|
||||
/// "derive it from the image".
|
||||
pub data_mount_path: Option<String>,
|
||||
/// In-game command that shuts the server down cleanly (e.g. `stop`, `quit`).
|
||||
pub stop_command: Option<String>,
|
||||
/// Total budget for a graceful shutdown before the container gets killed.
|
||||
pub stop_timeout_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
impl ServerRuntime {
|
||||
pub fn from_request(
|
||||
data_mount_path: String,
|
||||
stop_command: String,
|
||||
stop_timeout_seconds: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
data_mount_path: non_empty(data_mount_path),
|
||||
stop_command: non_empty(stop_command),
|
||||
stop_timeout_seconds: if stop_timeout_seconds > 0 {
|
||||
Some(stop_timeout_seconds as i64)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty(value: String) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerSpec {
|
||||
pub uuid: String,
|
||||
@@ -46,6 +86,8 @@ pub struct ServerSpec {
|
||||
pub data_path: PathBuf,
|
||||
pub state: ServerState,
|
||||
pub container_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub runtime: ServerRuntime,
|
||||
}
|
||||
|
||||
impl ServerSpec {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# --- Build ---
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL=/api
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
RUN pnpm --filter @source/shared build && \
|
||||
pnpm --filter @source/ui build && \
|
||||
pnpm --filter @source/web build
|
||||
|
||||
# --- Production (nginx) ---
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost/health || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,67 @@
|
||||
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;
|
||||
|
||||
# A power action blocks until the game server has actually shut down.
|
||||
# ARK saves its world for minutes, so the default 60s would 504 on a
|
||||
# stop that is still progressing normally.
|
||||
proxy_read_timeout 400s;
|
||||
proxy_send_timeout 400s;
|
||||
|
||||
# File manager uploads.
|
||||
client_max_body_size 128m;
|
||||
}
|
||||
|
||||
# Socket.IO proxy (live console)
|
||||
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;
|
||||
|
||||
# An idle console must not be torn down every 60s.
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
}
|
||||
|
||||
# Static assets caching
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -10,13 +10,33 @@
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@source/shared": "workspace:*",
|
||||
"@source/ui": "workspace:*",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.1.0",
|
||||
"socket.io-client": "^4.8.0"
|
||||
"socket.io-client": "^4.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
+120
-16
@@ -1,5 +1,46 @@
|
||||
import { useEffect } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router';
|
||||
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';
|
||||
import { ServerLayout } from '@/components/layout/server-layout';
|
||||
|
||||
// Auth pages
|
||||
import { LoginPage } from '@/pages/auth/login';
|
||||
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';
|
||||
import { MembersPage } from '@/pages/settings/members';
|
||||
|
||||
// Server pages
|
||||
import { ConsolePage } from '@/pages/server/console';
|
||||
import { FilesPage } from '@/pages/server/files';
|
||||
import { BackupsPage } from '@/pages/server/backups';
|
||||
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: {
|
||||
@@ -10,24 +51,87 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
function AuthGuard() {
|
||||
const { isAuthenticated, isLoading, fetchUser } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">GamePanel</h1>
|
||||
<p className="mt-2 text-muted-foreground">Game Server Management Panel</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route element={<AuthGuard />}>
|
||||
<Route element={<AppLayout />}>
|
||||
{/* Organizations */}
|
||||
<Route path="/" element={<OrganizationsPage />} />
|
||||
|
||||
{/* 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 />} />
|
||||
<Route path="players" element={<PlayersPage />} />
|
||||
<Route path="settings" element={<ServerSettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/games" element={<AdminGamesPage />} />
|
||||
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
|
||||
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
||||
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from 'react-router';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { Header } from './header';
|
||||
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useNavigate } from 'react-router';
|
||||
import { LogOut, User, Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
export function Header() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
|
||||
<div />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme}>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
{user?.username}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>{user?.email}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/account/security')}>
|
||||
Account Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Outlet, useParams, Link, useLocation } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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';
|
||||
import { PowerControls } from '@/components/server/power-controls';
|
||||
import { statusBadgeVariant } from '@/lib/utils';
|
||||
|
||||
interface ServerDetail {
|
||||
id: string;
|
||||
uuid: string;
|
||||
name: string;
|
||||
status: string;
|
||||
nodeName: string;
|
||||
nodeFqdn: string;
|
||||
gameName: string;
|
||||
gameSlug: string;
|
||||
port: number;
|
||||
memoryLimit: number;
|
||||
diskLimit: number;
|
||||
cpuLimit: number;
|
||||
}
|
||||
|
||||
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 },
|
||||
{ label: 'Players', path: 'players', icon: Users },
|
||||
{ label: 'Settings', path: 'settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function ServerLayout() {
|
||||
const { orgId, serverId } = useParams();
|
||||
const location = useLocation();
|
||||
|
||||
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();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{server?.name ?? 'Loading...'}</h1>
|
||||
{server && (
|
||||
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{server && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{server.gameName} · {server.nodeFqdn}:{server.port} · {server.uuid}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{server && <PowerControls serverId={server.id} orgId={orgId!} status={server.status} />}
|
||||
</div>
|
||||
|
||||
<nav className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = currentTab === tab.path;
|
||||
return (
|
||||
<Link
|
||||
key={tab.path}
|
||||
to={`/org/${orgId}/servers/${serverId}/${tab.path}`}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Outlet context={{ server }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
import {
|
||||
Server,
|
||||
LayoutDashboard,
|
||||
Network,
|
||||
Settings,
|
||||
Users,
|
||||
Shield,
|
||||
Gamepad2,
|
||||
Puzzle,
|
||||
ScrollText,
|
||||
ChevronLeft,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@source/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const { orgId } = useParams();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const orgNav: NavItem[] = orgId
|
||||
? [
|
||||
{ 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: 'Settings', href: `/org/${orgId}/settings/members`, icon: Settings },
|
||||
]
|
||||
: [];
|
||||
|
||||
const adminNav: NavItem[] = user?.isSuperAdmin
|
||||
? [
|
||||
{ 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 },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-64 flex-col border-r bg-card">
|
||||
<div className="flex h-14 items-center gap-2 border-b px-4">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
<span className="text-lg font-bold">GamePanel</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 py-2">
|
||||
{orgId && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1 px-2">
|
||||
<Link to="/" className="text-xs text-muted-foreground hover:text-foreground">
|
||||
<ChevronLeft className="inline h-3 w-3" /> Organizations
|
||||
</Link>
|
||||
</div>
|
||||
<NavSection items={orgNav} currentPath={location.pathname} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!orgId && (
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">ORGANIZATIONS</p>
|
||||
<Link to="/">
|
||||
<Button variant="ghost" className="w-full justify-start gap-2">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
All Organizations
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adminNav.length > 0 && (
|
||||
<>
|
||||
<Separator className="mx-3 my-2" />
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">ADMIN</p>
|
||||
<NavSection items={adminNav} currentPath={location.pathname} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavSection({ items, currentPath }: { items: NavItem[]; currentPath: string }) {
|
||||
return (
|
||||
<nav className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
currentPath === item.href || currentPath.startsWith(item.href + '/');
|
||||
return (
|
||||
<Link key={item.href} to={item.href}>
|
||||
<Button
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start gap-2"
|
||||
size="sm"
|
||||
>
|
||||
<item.icon className={cn('h-4 w-4', isActive && 'text-primary')} />
|
||||
{item.label}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Play, Square, RotateCcw, Skull } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface PowerControlsProps {
|
||||
serverId: string;
|
||||
orgId: string;
|
||||
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: PowerAction) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/power`, { action }),
|
||||
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] });
|
||||
},
|
||||
});
|
||||
|
||||
const isRunning = status === 'running';
|
||||
const isStopped = status === 'stopped' || status === 'error';
|
||||
const isTransitioning = status === 'starting' || status === 'stopping' || status === 'installing';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => powerMutation.mutate('start')}
|
||||
disabled={!isStopped || powerMutation.isPending}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Start
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => powerMutation.mutate('restart')}
|
||||
disabled={!isRunning || powerMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Restart
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => powerMutation.mutate('stop')}
|
||||
disabled={!isRunning || powerMutation.isPending}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isTransitioning && !isRunning}
|
||||
>
|
||||
<Skull className="h-4 w-4" />
|
||||
Kill
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kill Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will forcefully terminate the server process. Any unsaved data may be lost.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button variant="destructive" onClick={() => powerMutation.mutate('kill')}>
|
||||
Kill Server
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-card p-1 text-card-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuGroup,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ComponentRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation="vertical"
|
||||
className="flex touch-none select-none transition-colors h-full w-2.5 border-l border-l-transparent p-[1px]"
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
export { ScrollArea };
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { ChevronDown, ChevronUp, Check } from 'lucide-react';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-card text-card-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '@source/ui';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
function getTheme(): 'dark' | 'light' {
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const theme = useSyncExternalStore(subscribe, getTheme);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = getTheme() === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.classList.toggle('dark', next === 'dark');
|
||||
localStorage.setItem('theme', next);
|
||||
listeners.forEach((l) => l());
|
||||
}, []);
|
||||
|
||||
return { theme, toggleTheme };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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,
|
||||
public data: unknown,
|
||||
) {
|
||||
super(`API Error ${status}`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, ...fetchOptions } = options;
|
||||
|
||||
let url = `${API_BASE}${path}`;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const headers: Record<string, string> = {
|
||||
...(fetchOptions.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (fetchOptions.body && typeof fetchOptions.body === 'string') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
credentials: fetchOptions.credentials ?? 'include',
|
||||
headers,
|
||||
});
|
||||
|
||||
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,
|
||||
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();
|
||||
}
|
||||
localStorage.removeItem('access_token');
|
||||
window.location.href = '/login';
|
||||
throw new ApiError(401, null);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
localStorage.setItem('access_token', data.accessToken);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, params?: Record<string, string>) =>
|
||||
request<T>(path, { params }),
|
||||
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
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: toRequestBody(body),
|
||||
}),
|
||||
|
||||
delete: <T>(path: string) =>
|
||||
request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io('/', {
|
||||
path: '/socket.io',
|
||||
auth: {
|
||||
token: localStorage.getItem('access_token'),
|
||||
},
|
||||
autoConnect: false,
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function connectSocket() {
|
||||
const s = getSocket();
|
||||
if (!s.connected) {
|
||||
s.auth = { token: localStorage.getItem('access_token') };
|
||||
s.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (socket?.connected) {
|
||||
socket.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export function formatBytes(bytes: number, decimals = 1): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
export function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'text-green-500';
|
||||
case 'stopped':
|
||||
return 'text-red-500';
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
case 'installing':
|
||||
return 'text-yellow-500';
|
||||
case 'suspended':
|
||||
return 'text-orange-500';
|
||||
case 'error':
|
||||
return 'text-destructive';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusBadgeVariant(
|
||||
status: string,
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'default';
|
||||
case 'stopped':
|
||||
return 'secondary';
|
||||
case 'error':
|
||||
case 'suspended':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
userName: string;
|
||||
ipAddress: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
export function AdminAuditLogsPage() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-audit-logs'],
|
||||
queryFn: () => api.get<PaginatedResponse<AuditLog>>('/admin/audit-logs'),
|
||||
});
|
||||
|
||||
const logs = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Audit Logs</h1>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline">{log.action}</Badge>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">{log.userName}</span>
|
||||
{log.ipAddress && (
|
||||
<span className="text-muted-foreground"> from {log.ipAddress}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<div className="py-12 text-center text-muted-foreground">No audit logs</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Gamepad2 } 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;
|
||||
dockerImage: string;
|
||||
defaultPort: number;
|
||||
startupCommand: string;
|
||||
automationRules: unknown[];
|
||||
}
|
||||
|
||||
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('');
|
||||
const [defaultPort, setDefaultPort] = useState(25565);
|
||||
const [startupCommand, setStartupCommand] = useState('');
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-games'],
|
||||
queryFn: () => api.get<GamesResponse>('/admin/games'),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/admin/games', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-games'] });
|
||||
setOpen(false);
|
||||
setName('');
|
||||
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">
|
||||
<h1 className="text-2xl font-bold">Games</h1>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" /> Add Game
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Game</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate({ name, slug, dockerImage, defaultPort, startupCommand });
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug</Label>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(e) =>
|
||||
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Docker Image</Label>
|
||||
<Input
|
||||
value={dockerImage}
|
||||
onChange={(e) => setDockerImage(e.target.value)}
|
||||
placeholder="itzg/minecraft-server:latest"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Default Port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaultPort}
|
||||
onChange={(e) => setDefaultPort(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Startup Command</Label>
|
||||
<Input
|
||||
value={startupCommand}
|
||||
onChange={(e) => setStartupCommand(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{games.map((game) => (
|
||||
<Card key={game.id}>
|
||||
<CardHeader className="flex flex-row items-center gap-3 pb-2">
|
||||
<Gamepad2 className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{game.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<Badge variant="outline">{game.slug}</Badge>
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
|
||||
<p>Port: {game.defaultPort}</p>
|
||||
<p>Automation: {Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => openAutomationDialog(game)}
|
||||
>
|
||||
Manage Automation
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={automationOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setAutomationOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setSelectedGame(null);
|
||||
setAutomationError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Automation Rules
|
||||
{selectedGame ? ` - ${selectedGame.name}` : ''}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>JSON</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported events: server.created, server.install.completed, server.power.started, server.power.stopped
|
||||
</p>
|
||||
<textarea
|
||||
value={automationJson}
|
||||
onChange={(event) => setAutomationJson(event.target.value)}
|
||||
onKeyDown={handleAutomationTabKey}
|
||||
spellCheck={false}
|
||||
className="min-h-[320px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{automationError && <p className="text-sm text-destructive">{automationError}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveAutomationRules}
|
||||
disabled={updateAutomationMutation.isPending || !selectedGame}
|
||||
>
|
||||
{updateAutomationMutation.isPending ? 'Saving...' : 'Save Automation'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Network, Wifi, WifiOff } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface NodeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
fqdn: string;
|
||||
daemonPort: number;
|
||||
grpcPort: number;
|
||||
memoryTotal: number;
|
||||
diskTotal: number;
|
||||
isOnline: boolean;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export function AdminNodesPage() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-nodes'],
|
||||
queryFn: () => api.get<{ data: NodeItem[] }>('/admin/nodes'),
|
||||
});
|
||||
|
||||
const nodes = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">All Nodes</h1>
|
||||
<p className="text-muted-foreground">{nodes.length} nodes across all organizations</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{nodes.map((node) => (
|
||||
<Card key={node.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{node.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||
{node.isOnline ? (
|
||||
<><Wifi className="mr-1 h-3 w-3" /> Online</>
|
||||
) : (
|
||||
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
|
||||
)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{node.fqdn}:{node.daemonPort}</p>
|
||||
<div className="mt-3 flex gap-4 text-sm">
|
||||
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
||||
<span>{formatBytes(node.diskTotal)} Disk</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{nodes.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
No nodes registered
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, UploadCloud, Puzzle, Rocket, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface Game {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GamesResponse {
|
||||
data: Game[];
|
||||
}
|
||||
|
||||
interface GlobalPlugin {
|
||||
id: string;
|
||||
gameId: string;
|
||||
gameName: string;
|
||||
gameSlug: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
source: 'manual' | 'spiget';
|
||||
isGlobal: boolean;
|
||||
}
|
||||
|
||||
interface GlobalPluginsResponse {
|
||||
data: GlobalPlugin[];
|
||||
}
|
||||
|
||||
interface PluginRelease {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
channel: 'stable' | 'beta' | 'alpha';
|
||||
artifactType: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination: string | null;
|
||||
fileName: string | null;
|
||||
changelog: string | null;
|
||||
installSchema: unknown[];
|
||||
configTemplates: unknown[];
|
||||
isPublished: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface PluginReleaseResponse {
|
||||
plugin: GlobalPlugin;
|
||||
releases: PluginRelease[];
|
||||
}
|
||||
|
||||
type ReleaseInputMode = 'url' | 'upload';
|
||||
|
||||
function extractApiMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||
return maybeMessage;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function prettyJson(input: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonArray(raw: string): unknown[] {
|
||||
if (raw.trim() === '') return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('JSON value must be an array');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function parseJsonArrayFile(file: File, label: string): Promise<unknown[]> {
|
||||
let raw = await file.text();
|
||||
if (raw.charCodeAt(0) === 0xfeff) {
|
||||
raw = raw.slice(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('JSON value must be an array');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
throw new Error(`${label}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminPluginsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedGameId, setSelectedGameId] = useState<string>('');
|
||||
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
|
||||
|
||||
const [createPluginOpen, setCreatePluginOpen] = useState(false);
|
||||
const [createPluginName, setCreatePluginName] = useState('');
|
||||
const [createPluginSlug, setCreatePluginSlug] = useState('');
|
||||
const [createPluginDescription, setCreatePluginDescription] = useState('');
|
||||
|
||||
const [createReleaseOpen, setCreateReleaseOpen] = useState(false);
|
||||
const [releaseInputMode, setReleaseInputMode] = useState<ReleaseInputMode>('upload');
|
||||
const [releaseVersion, setReleaseVersion] = useState('');
|
||||
const [releaseChannel, setReleaseChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
|
||||
const [releaseArtifactType, setReleaseArtifactType] = useState<'file' | 'zip'>('file');
|
||||
const [releaseArtifactUrl, setReleaseArtifactUrl] = useState('');
|
||||
const [releaseDestination, setReleaseDestination] = useState('');
|
||||
const [releaseFileName, setReleaseFileName] = useState('');
|
||||
const [releaseChangelog, setReleaseChangelog] = useState('');
|
||||
const [releaseInstallSchemaJson, setReleaseInstallSchemaJson] = useState('[]');
|
||||
const [releaseTemplatesJson, setReleaseTemplatesJson] = useState('[]');
|
||||
const [releaseInstallSchemaFile, setReleaseInstallSchemaFile] = useState<File | null>(null);
|
||||
const [releaseTemplatesFile, setReleaseTemplatesFile] = useState<File | null>(null);
|
||||
const [releaseInstallSchemaFileInputKey, setReleaseInstallSchemaFileInputKey] = useState(0);
|
||||
const [releaseTemplatesFileInputKey, setReleaseTemplatesFileInputKey] = useState(0);
|
||||
const [releaseArtifactFiles, setReleaseArtifactFiles] = useState<File[]>([]);
|
||||
|
||||
const { data: gamesData } = useQuery({
|
||||
queryKey: ['admin-games'],
|
||||
queryFn: () => api.get<GamesResponse>('/admin/games'),
|
||||
});
|
||||
|
||||
const games = gamesData?.data ?? [];
|
||||
|
||||
const { data: pluginsData } = useQuery({
|
||||
queryKey: ['admin-plugins', selectedGameId],
|
||||
queryFn: () =>
|
||||
api.get<GlobalPluginsResponse>(
|
||||
'/admin/plugins',
|
||||
selectedGameId ? { gameId: selectedGameId } : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
const plugins = pluginsData?.data ?? [];
|
||||
|
||||
const selectedPlugin = useMemo(
|
||||
() => plugins.find((plugin) => plugin.id === selectedPluginId) ?? null,
|
||||
[plugins, selectedPluginId],
|
||||
);
|
||||
|
||||
const { data: releaseData } = useQuery({
|
||||
queryKey: ['admin-plugin-releases', selectedPluginId],
|
||||
enabled: Boolean(selectedPluginId),
|
||||
queryFn: () => api.get<PluginReleaseResponse>(`/admin/plugins/${selectedPluginId}/releases`),
|
||||
});
|
||||
|
||||
const releases = releaseData?.releases ?? [];
|
||||
|
||||
const resetReleaseForm = () => {
|
||||
setCreateReleaseOpen(false);
|
||||
setReleaseInputMode('upload');
|
||||
setReleaseVersion('');
|
||||
setReleaseChannel('stable');
|
||||
setReleaseArtifactType('file');
|
||||
setReleaseArtifactUrl('');
|
||||
setReleaseDestination('');
|
||||
setReleaseFileName('');
|
||||
setReleaseChangelog('');
|
||||
setReleaseInstallSchemaJson('[]');
|
||||
setReleaseTemplatesJson('[]');
|
||||
setReleaseInstallSchemaFile(null);
|
||||
setReleaseTemplatesFile(null);
|
||||
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||
setReleaseArtifactFiles([]);
|
||||
};
|
||||
|
||||
const appendReleaseFiles = (incoming: FileList | null) => {
|
||||
if (!incoming || incoming.length === 0) return;
|
||||
|
||||
setReleaseArtifactFiles((prev) => {
|
||||
const map = new Map<string, File>();
|
||||
|
||||
for (const item of prev) {
|
||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||
}
|
||||
|
||||
for (const item of Array.from(incoming)) {
|
||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
};
|
||||
|
||||
const createPluginMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
gameId: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
}) => api.post('/admin/plugins', body),
|
||||
onSuccess: () => {
|
||||
toast.success('Global plugin created');
|
||||
setCreatePluginOpen(false);
|
||||
setCreatePluginName('');
|
||||
setCreatePluginSlug('');
|
||||
setCreatePluginDescription('');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to create plugin'));
|
||||
},
|
||||
});
|
||||
|
||||
const createReleaseMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
version: string;
|
||||
channel: 'stable' | 'beta' | 'alpha';
|
||||
artifactType: 'file' | 'zip';
|
||||
artifactUrl: string;
|
||||
destination?: string;
|
||||
fileName?: string;
|
||||
changelog?: string;
|
||||
installSchema?: unknown[];
|
||||
configTemplates?: unknown[];
|
||||
}) => {
|
||||
if (!selectedPluginId) {
|
||||
throw new Error('No plugin selected');
|
||||
}
|
||||
return api.post(`/admin/plugins/${selectedPluginId}/releases`, body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Release published');
|
||||
resetReleaseForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to publish release'));
|
||||
},
|
||||
});
|
||||
|
||||
const createUploadReleaseMutation = useMutation({
|
||||
mutationFn: (formData: FormData) => {
|
||||
if (!selectedPluginId) {
|
||||
throw new Error('No plugin selected');
|
||||
}
|
||||
return api.post(`/admin/plugins/${selectedPluginId}/releases/upload`, formData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Release uploaded and published');
|
||||
resetReleaseForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to upload release'));
|
||||
},
|
||||
});
|
||||
|
||||
const togglePublishedMutation = useMutation({
|
||||
mutationFn: ({ releaseId, isPublished }: { releaseId: string; isPublished: boolean }) => {
|
||||
if (!selectedPluginId) {
|
||||
throw new Error('No plugin selected');
|
||||
}
|
||||
return api.patch(`/admin/plugins/${selectedPluginId}/releases/${releaseId}`, { isPublished });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to update release'));
|
||||
},
|
||||
});
|
||||
|
||||
const openReleaseDialogFrom = (release?: PluginRelease) => {
|
||||
setReleaseVersion('');
|
||||
setReleaseChannel('stable');
|
||||
setReleaseArtifactType('file');
|
||||
setReleaseArtifactUrl('');
|
||||
setReleaseDestination('');
|
||||
setReleaseFileName('');
|
||||
setReleaseChangelog('');
|
||||
setReleaseInstallSchemaJson('[]');
|
||||
setReleaseTemplatesJson('[]');
|
||||
setReleaseInstallSchemaFile(null);
|
||||
setReleaseTemplatesFile(null);
|
||||
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||
setReleaseArtifactFiles([]);
|
||||
setReleaseInputMode(release ? 'url' : 'upload');
|
||||
|
||||
if (release) {
|
||||
setReleaseChannel(release.channel);
|
||||
setReleaseArtifactType(release.artifactType);
|
||||
setReleaseArtifactUrl(release.artifactUrl);
|
||||
setReleaseDestination(release.destination ?? '');
|
||||
setReleaseFileName(release.fileName ?? '');
|
||||
setReleaseChangelog(release.changelog ?? '');
|
||||
setReleaseInstallSchemaJson(prettyJson(release.installSchema));
|
||||
setReleaseTemplatesJson(prettyJson(release.configTemplates));
|
||||
}
|
||||
setCreateReleaseOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Global Plugins</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Oyun bazında global plugin tanımla, release yayınla, install ayar şemasını yönet.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createPluginOpen} onOpenChange={setCreatePluginOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" /> Add Global Plugin
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Global Plugin</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedGameId) {
|
||||
toast.error('Select a game first');
|
||||
return;
|
||||
}
|
||||
createPluginMutation.mutate({
|
||||
gameId: selectedGameId,
|
||||
name: createPluginName,
|
||||
slug: createPluginSlug || undefined,
|
||||
description: createPluginDescription || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Game</Label>
|
||||
<Input
|
||||
value={games.find((game) => game.id === selectedGameId)?.name ?? ''}
|
||||
readOnly
|
||||
placeholder="Select game from filter above"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={createPluginName} onChange={(e) => setCreatePluginName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug (optional)</Label>
|
||||
<Input value={createPluginSlug} onChange={(e) => setCreatePluginSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input value={createPluginDescription} onChange={(e) => setCreatePluginDescription(e.target.value)} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createPluginMutation.isPending}>
|
||||
{createPluginMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label className="min-w-20">Game Filter</Label>
|
||||
<select
|
||||
className="h-10 rounded-md border bg-background px-3 text-sm"
|
||||
value={selectedGameId}
|
||||
onChange={(e) => {
|
||||
setSelectedGameId(e.target.value);
|
||||
setSelectedPluginId(null);
|
||||
}}
|
||||
>
|
||||
<option value="">All Games</option>
|
||||
{games.map((game) => (
|
||||
<option key={game.id} value={game.id}>
|
||||
{game.name} ({game.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plugins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{plugins.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No plugins found for this filter.</p>
|
||||
)}
|
||||
{plugins.map((plugin) => (
|
||||
<button
|
||||
key={plugin.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPluginId(plugin.id)}
|
||||
className={`w-full rounded-md border px-3 py-2 text-left transition ${
|
||||
selectedPluginId === plugin.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Puzzle className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">{plugin.name}</span>
|
||||
<Badge variant="outline">{plugin.gameSlug}</Badge>
|
||||
<Badge variant="secondary">{plugin.source}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{plugin.slug}</p>
|
||||
{plugin.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Releases</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openReleaseDialogFrom(releases[0])}
|
||||
disabled={!selectedPlugin}
|
||||
>
|
||||
<Copy className="h-4 w-4" /> Clone Latest
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openReleaseDialogFrom()}
|
||||
disabled={!selectedPlugin}
|
||||
>
|
||||
<UploadCloud className="h-4 w-4" /> New Release
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{!selectedPlugin && (
|
||||
<p className="text-sm text-muted-foreground">Select a plugin to manage releases.</p>
|
||||
)}
|
||||
{selectedPlugin && releases.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No releases published yet.</p>
|
||||
)}
|
||||
{releases.map((release) => (
|
||||
<div key={release.id} className="rounded-md border px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">v{release.version}</span>
|
||||
<Badge variant="outline">{release.channel}</Badge>
|
||||
<Badge variant="secondary">{release.artifactType}</Badge>
|
||||
{!release.isPublished && <Badge variant="destructive">Unpublished</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{release.artifactUrl}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0} fields • Templates:{' '}
|
||||
{Array.isArray(release.configTemplates) ? release.configTemplates.length : 0}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
togglePublishedMutation.mutate({
|
||||
releaseId: release.id,
|
||||
isPublished: !release.isPublished,
|
||||
})
|
||||
}
|
||||
disabled={togglePublishedMutation.isPending}
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
{release.isPublished ? 'Unpublish' : 'Publish'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={createReleaseOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setCreateReleaseOpen(true);
|
||||
return;
|
||||
}
|
||||
resetReleaseForm();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const installSchema = releaseInstallSchemaFile
|
||||
? await parseJsonArrayFile(releaseInstallSchemaFile, 'Install schema file')
|
||||
: parseJsonArray(releaseInstallSchemaJson);
|
||||
const configTemplates = releaseTemplatesFile
|
||||
? await parseJsonArrayFile(releaseTemplatesFile, 'Config templates file')
|
||||
: parseJsonArray(releaseTemplatesJson);
|
||||
|
||||
if (releaseInputMode === 'upload') {
|
||||
if (releaseArtifactFiles.length === 0) {
|
||||
toast.error('Select at least one file or folder');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('version', releaseVersion);
|
||||
formData.append('channel', releaseChannel);
|
||||
if (releaseDestination.trim()) formData.append('destination', releaseDestination.trim());
|
||||
if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim());
|
||||
if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog);
|
||||
if (releaseInstallSchemaFile) {
|
||||
formData.append(
|
||||
'installSchemaFile',
|
||||
releaseInstallSchemaFile,
|
||||
releaseInstallSchemaFile.name,
|
||||
);
|
||||
} else {
|
||||
formData.append('installSchema', JSON.stringify(installSchema));
|
||||
}
|
||||
if (releaseTemplatesFile) {
|
||||
formData.append(
|
||||
'configTemplatesFile',
|
||||
releaseTemplatesFile,
|
||||
releaseTemplatesFile.name,
|
||||
);
|
||||
} else {
|
||||
formData.append('configTemplates', JSON.stringify(configTemplates));
|
||||
}
|
||||
|
||||
for (const file of releaseArtifactFiles) {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
formData.append('relativePath', relativePath && relativePath.length > 0 ? relativePath : file.name);
|
||||
formData.append('files', file, file.name);
|
||||
}
|
||||
|
||||
createUploadReleaseMutation.mutate(formData);
|
||||
return;
|
||||
}
|
||||
|
||||
createReleaseMutation.mutate({
|
||||
version: releaseVersion,
|
||||
channel: releaseChannel,
|
||||
artifactType: releaseArtifactType,
|
||||
artifactUrl: releaseArtifactUrl,
|
||||
destination: releaseDestination || undefined,
|
||||
fileName: releaseFileName || undefined,
|
||||
changelog: releaseChangelog || undefined,
|
||||
installSchema,
|
||||
configTemplates,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
toast.error(`Release JSON error: ${message}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Version</Label>
|
||||
<Input value={releaseVersion} onChange={(e) => setReleaseVersion(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Channel</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
value={releaseChannel}
|
||||
onChange={(e) => setReleaseChannel(e.target.value as 'stable' | 'beta' | 'alpha')}
|
||||
>
|
||||
<option value="stable">stable</option>
|
||||
<option value="beta">beta</option>
|
||||
<option value="alpha">alpha</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Release Source</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={releaseInputMode === 'upload' ? 'default' : 'outline'}
|
||||
onClick={() => setReleaseInputMode('upload')}
|
||||
>
|
||||
CDN Upload
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={releaseInputMode === 'url' ? 'default' : 'outline'}
|
||||
onClick={() => setReleaseInputMode('url')}
|
||||
>
|
||||
URL
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{releaseInputMode === 'url' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Artifact Type</Label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
value={releaseArtifactType}
|
||||
onChange={(e) => setReleaseArtifactType(e.target.value as 'file' | 'zip')}
|
||||
>
|
||||
<option value="file">file</option>
|
||||
<option value="zip">zip</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Artifact URL</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={releaseArtifactUrl}
|
||||
onChange={(e) => setReleaseArtifactUrl(e.target.value)}
|
||||
required={releaseInputMode === 'url'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{releaseInputMode === 'upload' && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen otomatik zip
|
||||
yapilip CDN'e yuklenir.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Files</Label>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="block w-full text-sm"
|
||||
onChange={(e) => appendReleaseFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Folder</Label>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
{...({ webkitdirectory: '', directory: '' } as Record<string, string>)}
|
||||
className="block w-full text-sm"
|
||||
onChange={(e) => appendReleaseFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Selected: {releaseArtifactFiles.length} file(s)</p>
|
||||
{releaseArtifactFiles.length > 0 && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setReleaseArtifactFiles([])}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{releaseArtifactFiles.length > 0 && (
|
||||
<div className="max-h-28 space-y-1 overflow-auto rounded bg-muted/40 p-2 text-xs">
|
||||
{releaseArtifactFiles.map((file, index) => {
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||
return (
|
||||
<p key={`${relativePath || file.name}-${index}`} className="truncate">
|
||||
{relativePath || file.name}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Destination (optional)</Label>
|
||||
<Input
|
||||
value={releaseDestination}
|
||||
onChange={(e) => setReleaseDestination(e.target.value)}
|
||||
placeholder="/game/csgo/addons"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>File Name (optional)</Label>
|
||||
<Input
|
||||
value={releaseFileName}
|
||||
onChange={(e) => setReleaseFileName(e.target.value)}
|
||||
placeholder="plugin.dll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Changelog (optional)</Label>
|
||||
<textarea
|
||||
value={releaseChangelog}
|
||||
onChange={(e) => setReleaseChangelog(e.target.value)}
|
||||
className="min-h-[90px] w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Install Schema JSON (array)</Label>
|
||||
<input
|
||||
key={releaseInstallSchemaFileInputKey}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="block w-full text-xs"
|
||||
onChange={(e) => setReleaseInstallSchemaFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{releaseInstallSchemaFile && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>File secili: {releaseInstallSchemaFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReleaseInstallSchemaFile(null);
|
||||
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={releaseInstallSchemaJson}
|
||||
onChange={(e) => setReleaseInstallSchemaJson(e.target.value)}
|
||||
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Config Templates JSON (array)</Label>
|
||||
<input
|
||||
key={releaseTemplatesFileInputKey}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="block w-full text-xs"
|
||||
onChange={(e) => setReleaseTemplatesFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{releaseTemplatesFile && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<p>File secili: {releaseTemplatesFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReleaseTemplatesFile(null);
|
||||
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={releaseTemplatesJson}
|
||||
onChange={(e) => setReleaseTemplatesJson(e.target.value)}
|
||||
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
createReleaseMutation.isPending ||
|
||||
createUploadReleaseMutation.isPending ||
|
||||
!selectedPlugin
|
||||
}
|
||||
>
|
||||
{(createReleaseMutation.isPending || createUploadReleaseMutation.isPending)
|
||||
? 'Publishing...'
|
||||
: 'Publish Release'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
isSuperAdmin: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: () => api.get<PaginatedResponse<User>>('/admin/users'),
|
||||
});
|
||||
|
||||
const users = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Users</h1>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||
<th className="p-4 font-medium">Username</th>
|
||||
<th className="p-4 font-medium">Email</th>
|
||||
<th className="p-4 font-medium">Role</th>
|
||||
<th className="p-4 font-medium">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b last:border-0">
|
||||
<td className="p-4 font-medium">{user.username}</td>
|
||||
<td className="p-4 text-muted-foreground">{user.email}</td>
|
||||
<td className="p-4">
|
||||
{user.isSuperAdmin ? (
|
||||
<Badge>Admin</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">User</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ApiError } from '@/lib/api';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.status === 401 ? 'Invalid email or password' : 'An error occurred');
|
||||
} else {
|
||||
setError('An error occurred');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Shield className="h-6 w-6 text-primary-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||
<CardDescription>Sign in to your GamePanel account</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ApiError } from '@/lib/api';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const register = useAuthStore((s) => s.register);
|
||||
const [email, setEmail] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(email, username, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.status === 409 ? 'Email or username already taken' : 'An error occurred');
|
||||
} else {
|
||||
setError('An error occurred');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Shield className="h-6 w-6 text-primary-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Create an account</CardTitle>
|
||||
<CardDescription>Get started with GamePanel</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Min 8 characters"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Server, Network, Activity, Plus } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } 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 DashboardPage() {
|
||||
const { orgId } = useParams();
|
||||
|
||||
const { data: serversData } = useQuery({
|
||||
queryKey: ['servers', orgId],
|
||||
queryFn: () => api.get<PaginatedResponse<ServerSummary>>(`/organizations/${orgId}/servers`),
|
||||
});
|
||||
|
||||
const { data: nodesData } = useQuery({
|
||||
queryKey: ['nodes', orgId],
|
||||
queryFn: () => api.get<PaginatedResponse<{ id: string }>>(`/organizations/${orgId}/nodes`),
|
||||
});
|
||||
|
||||
const servers = serversData?.data ?? [];
|
||||
const running = servers.filter((s) => s.status === 'running').length;
|
||||
const totalNodes = nodesData?.meta?.total ?? nodesData?.data?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<Link to={`/org/${orgId}/servers/new`}>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Server
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Servers</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{servers.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Running</CardTitle>
|
||||
<Activity className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-500">{running}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Nodes</CardTitle>
|
||||
<Network className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalNodes}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-semibold">Servers</h2>
|
||||
{servers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Server className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">No servers yet</p>
|
||||
<Link to={`/org/${orgId}/servers/new`}>
|
||||
<Button variant="outline" className="mt-4">
|
||||
Create your first server
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{servers.map((server) => (
|
||||
<Link key={server.id} to={`/org/${orgId}/servers/${server.id}/console`}>
|
||||
<Card className="transition-colors hover:border-primary/50">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{server.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{server.gameName} · {server.nodeName} · :{server.port}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Network,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Server,
|
||||
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;
|
||||
name: string;
|
||||
fqdn: string;
|
||||
daemonPort: number;
|
||||
grpcPort: number;
|
||||
memoryTotal: number;
|
||||
diskTotal: number;
|
||||
isOnline: boolean;
|
||||
daemonVersion: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface NodeStats {
|
||||
cpuPercent: number;
|
||||
memoryUsed: number;
|
||||
memoryTotal: number;
|
||||
diskUsed: number;
|
||||
diskTotal: number;
|
||||
activeServers: number;
|
||||
totalServers: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
interface ServerSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
memoryLimit: number;
|
||||
cpuLimit: number;
|
||||
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],
|
||||
queryFn: () => api.get<NodeDetail>(`/organizations/${orgId}/nodes/${nodeId}`),
|
||||
});
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['node-stats', orgId, nodeId],
|
||||
queryFn: () => api.get<NodeStats>(`/organizations/${orgId}/nodes/${nodeId}/stats`),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const { data: serversData } = useQuery({
|
||||
queryKey: ['node-servers', orgId, nodeId],
|
||||
queryFn: () =>
|
||||
api.get<{ data: ServerSummary[] }>(
|
||||
`/organizations/${orgId}/nodes/${nodeId}/servers`,
|
||||
),
|
||||
});
|
||||
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const memPercent = stats && stats.memoryTotal > 0
|
||||
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
|
||||
: 0;
|
||||
const diskPercent = stats && stats.diskTotal > 0
|
||||
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to={`/org/${orgId}/nodes`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{node.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{node.fqdn}:{node.daemonPort}
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats ? `${stats.cpuPercent.toFixed(1)}%` : '—'}
|
||||
</div>
|
||||
<Progress value={stats?.cpuPercent ?? 0} className="mt-2 h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Memory</CardTitle>
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats
|
||||
? `${formatBytes(stats.memoryUsed)} / ${formatBytes(stats.memoryTotal)}`
|
||||
: '—'}
|
||||
</div>
|
||||
<Progress value={memPercent} className="mt-2 h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Disk</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats
|
||||
? `${formatBytes(stats.diskUsed)} / ${formatBytes(stats.diskTotal)}`
|
||||
: '—'}
|
||||
</div>
|
||||
<Progress value={diskPercent} className="mt-2 h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Servers</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats ? `${stats.activeServers} / ${stats.totalServers}` : servers.length.toString()}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{stats ? 'active / total' : 'total servers'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Node Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Node Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<InfoRow label="FQDN" value={node.fqdn} />
|
||||
<InfoRow label="Daemon Port" value={String(node.daemonPort)} />
|
||||
<InfoRow label="gRPC Port" value={String(node.grpcPort)} />
|
||||
<InfoRow label="Total Memory" value={formatBytes(node.memoryTotal)} />
|
||||
<InfoRow label="Total Disk" value={formatBytes(node.diskTotal)} />
|
||||
{node.daemonVersion && (
|
||||
<InfoRow label="Daemon Version" value={node.daemonVersion} />
|
||||
)}
|
||||
<InfoRow label="Created" value={new Date(node.createdAt).toLocaleDateString()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Servers on this Node</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{servers.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No servers on this node
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{servers.map((srv) => (
|
||||
<Link
|
||||
key={srv.id}
|
||||
to={`/org/${orgId}/servers/${srv.id}/console`}
|
||||
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{srv.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{srv.gameName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={srv.status === 'running' ? 'default' : 'outline'}
|
||||
>
|
||||
{srv.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(srv.memoryLimit)} RAM
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
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';
|
||||
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,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface NodeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
fqdn: string;
|
||||
daemonPort: number;
|
||||
grpcPort: number;
|
||||
memoryTotal: number;
|
||||
diskTotal: number;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
interface CreatedNode extends NodeItem {
|
||||
daemonToken: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
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);
|
||||
const [grpcPort, setGrpcPort] = useState(50051);
|
||||
const [memoryTotal, setMemoryTotal] = useState(8192);
|
||||
const [diskTotal, setDiskTotal] = useState(51200);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['nodes', orgId],
|
||||
queryFn: () => api.get<PaginatedResponse<NodeItem>>(`/organizations/${orgId}/nodes`),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Nodes</h1>
|
||||
<p className="text-muted-foreground">Manage your daemon nodes</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" /> Add Node
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Node</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate({
|
||||
name,
|
||||
fqdn,
|
||||
daemonPort,
|
||||
grpcPort,
|
||||
memoryTotal: memoryTotal * 1024 * 1024,
|
||||
diskTotal: diskTotal * 1024 * 1024,
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>FQDN</Label>
|
||||
<Input
|
||||
value={fqdn}
|
||||
onChange={(e) => setFqdn(e.target.value)}
|
||||
placeholder="node1.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Daemon Port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={daemonPort}
|
||||
onChange={(e) => setDaemonPort(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>gRPC Port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={grpcPort}
|
||||
onChange={(e) => setGrpcPort(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Memory (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={memoryTotal}
|
||||
onChange={(e) => setMemoryTotal(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Disk (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={diskTotal}
|
||||
onChange={(e) => setDiskTotal(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Add Node'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</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}`}>
|
||||
<Card className="transition-colors hover:bg-muted/50 cursor-pointer">
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Building2 } from 'lucide-react';
|
||||
import { 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, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
maxServers: number;
|
||||
maxNodes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number; page: number; perPage: number; totalPages: number };
|
||||
}
|
||||
|
||||
export function OrganizationsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: () => api.get<PaginatedResponse<Organization>>('/organizations'),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: { name: string; slug: string }) => api.post('/organizations', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setSlug('');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Organizations</h1>
|
||||
<p className="text-muted-foreground">Manage your organizations</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Organization
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Organization</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate({ name, slug });
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug</Label>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
pattern="^[a-z0-9-]+$"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data?.data.map((org) => (
|
||||
<Link key={org.id} to={`/org/${org.id}/dashboard`}>
|
||||
<Card className="transition-colors hover:border-primary/50">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{org.name}</CardTitle>
|
||||
<CardDescription>{org.slug}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<span>Max {org.maxServers} servers</span>
|
||||
<span>Max {org.maxNodes} nodes</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user