15 Commits

Author SHA1 Message Date
hibna 276150a769 Install protoc without sudo on self-hosted runners
CI / Docker Build (push) Has been skipped
CI / Lint & Type Check (push) Successful in 4m35s
CI / Daemon Build & Test (push) Failing after 2m27s
CI / Publish images (push) Has been skipped
The daemon job assumed a GitHub-hosted runner, where the build user is
unprivileged and sudo exists. Our act runner is a container that runs
as root and ships no sudo, so the step died with "sudo: command not
found" before the toolchain was ever installed — which also blocked the
publish job that waits on it.

Use sudo only when we are not already root, so the step works on both.
2026-08-02 21:08:24 +03:00
hibna c1adb94abb Format the repository with Prettier
CI has been running `prettier --check` against a tree that was never
formatted, so the check reported 63 files and failed every run. Nothing
here is a behaviour change: `pnpm lint` and the four typecheck builds
pass exactly as before.

conduit-bringup-artifacts is added to .prettierignore instead. Those
files are captured bring-up reports, not maintained sources; reflowing
them would only churn a record of what happened.
2026-08-02 21:08:12 +03:00
hibna d0d3a58907 Fix API lint errors blocking CI
CI / Lint & Type Check (push) Failing after 2m59s
CI / Daemon Build & Test (push) Failing after 13s
CI / Docker Build (push) Has been skipped
CI / Publish images (push) Has been skipped
eslint has been failing on 11 no-explicit-any errors, which kept the
whole pipeline red — including the new publish job that waits on lint.

The casts all worked around missing types rather than unknown shapes:

- jwt: @fastify/jwt decorates the instance at runtime, so the namespace
  is not in FastifyInstance's type. Described the parts we call and cast
  through unknown once, in one place, instead of `as any` at five sites.
- permissions: FastifyInstance.db is declared by the db plugin; the cast
  was stale and hid the real type.
- paginate: the querystring schema already validates page/perPage, so
  the call sites now name that shape via PaginationQuery.

Also dropped an unused import and an unused binding whose call is kept
for its validation side effect.

No behaviour change: eslint and tsc are both clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:56:55 +03:00
hibna 7ca55bc94d Add image-based deployment for control panels
CI / Lint & Type Check (push) Failing after 3m5s
CI / Daemon Build & Test (push) Failing after 13s
CI / Docker Build (push) Has been skipped
CI / Publish images (push) Has been skipped
docker-compose.panel.yml deploys every service from a published image.
A control panel writes only a compose file and an .env into its project
directory, so the build: stanzas of docker-compose.yml cannot resolve
their context there.

CI pushes api, migrate, web and daemon images to the Gitea container
registry on v* tags. The migrate stage ships as its own image because
the panel compose runs it as a one-shot service before the API starts.

The web port is named HOST_PORT: panels reverse-proxy "the" port of an
installation and need to know which one that is when a stack publishes
more than one.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:07:00 +03:00
hibna d7d8fd5339 Fix auth flows and add daemon heartbeat endpoint 2026-02-22 09:41:17 +00:00
hibna c926613ee0 chore: initial commit for main 2026-02-22 09:52:38 +03:00
130 changed files with 22649 additions and 1105 deletions
View File
+12
View File
@@ -0,0 +1,12 @@
node_modules
**/node_modules
**/dist
**/target
**/.turbo
.git
.env
.env.*
!.env.example
*.md
.vscode
.idea
+47 -6
View File
@@ -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=
+155
View File
@@ -0,0 +1,155 @@
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
# Self-hosted act runners run as root in a container that has no sudo,
# while GitHub-hosted runners need it. Pick whichever exists.
- name: Install protoc
run: |
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
$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"
+5 -1
View File
@@ -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/
+4
View File
@@ -3,3 +3,7 @@ dist
.turbo
pnpm-lock.yaml
apps/daemon/target
# Captured bring-up reports, not maintained sources — reflowing them would
# only churn a record of what happened.
conduit-bringup-artifacts
+751
View File
@@ -0,0 +1,751 @@
# 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 |
+316
View File
@@ -0,0 +1,316 @@
# 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.
+64
View File
@@ -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
View File
@@ -12,19 +12,32 @@
"dependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^9.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.0.0",
"@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0",
"@sinclair/typebox": "^0.34.0",
"@source/cdn": "1.4.0",
"@source/database": "workspace:*",
"@source/proto": "workspace:*",
"@source/shared": "workspace:*",
"argon2": "^0.41.0",
"drizzle-orm": "^0.38.0",
"fastify": "^5.2.0",
"fastify-plugin": "^5.0.0",
"pino-pretty": "^13.0.0",
"socket.io": "^4.8.0"
"socket.io": "^4.8.0",
"tar-stream": "^3.1.7",
"unzipper": "^0.12.3",
"yazl": "^3.3.1"
},
"devDependencies": {
"@types/tar-stream": "^3.1.4",
"@types/unzipper": "^0.10.11",
"@types/yazl": "^3.3.0",
"dotenv-cli": "^8.0.0",
"tsx": "^4.19.0"
}
+42 -11
View File
@@ -1,36 +1,54 @@
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({
logger: {
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,
},
});
// 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) => {
app.setErrorHandler(
(
error: Error & { validation?: unknown; statusCode?: number; code?: string },
_request,
reply,
) => {
if (error instanceof AppError) {
return reply.code(error.statusCode).send({
error: error.name,
@@ -47,12 +65,22 @@ app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number;
});
}
app.log.error(error);
return reply.code(500).send({
error: 'Internal Server Error',
message: 'An unexpected error occurred',
// 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(error.statusCode ?? 500).send({
error: 'Internal Server Error',
message:
process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message,
});
},
);
// Routes
app.get('/api/health', async () => {
@@ -62,6 +90,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(
+195
View File
@@ -0,0 +1,195 @@
import { CdnClient, CdnError, type FileInfo } from '@source/cdn';
import { AppError } from './errors.js';
const DEFAULT_PLUGIN_BUCKET = 'gamepanel-plugin-artifacts';
const DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS = 900;
const ARTIFACT_POINTER_PREFIX = 'cdn://file/';
let cachedClient: CdnClient | null = null;
let cachedFingerprint: string | null = null;
function envValue(name: string): string | null {
const value = process.env[name];
if (!value) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
const baseUrl = envValue('CDN_BASE_URL');
const apiKey = envValue('CDN_API_KEY');
if (!baseUrl || !apiKey) return null;
return { baseUrl, apiKey };
}
function getArtifactAccessTtlSeconds(): number {
const raw = Number(
process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS,
);
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
return Math.floor(raw);
}
function getOrCreateClient(): CdnClient | null {
const config = getCdnConfig();
if (!config) return null;
const fingerprint = `${config.baseUrl}::${config.apiKey}`;
if (cachedClient && cachedFingerprint === fingerprint) return cachedClient;
cachedClient = new CdnClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
timeoutMs: 45_000,
retry: {
retries: 2,
retryDelayMs: 250,
maxRetryDelayMs: 2_000,
},
});
cachedFingerprint = fingerprint;
return cachedClient;
}
function requireClient(): CdnClient {
const client = getOrCreateClient();
if (!client) {
throw new AppError(
500,
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
'CDN_NOT_CONFIGURED',
);
}
return client;
}
function toCdnAppError(error: unknown, fallbackMessage: string, fallbackCode: string): AppError {
if (error instanceof AppError) return error;
if (error instanceof CdnError) {
return new AppError(502, `CDN error: ${error.message}`, fallbackCode);
}
return new AppError(502, fallbackMessage, fallbackCode);
}
export function getPluginBucketName(): string {
return envValue('CDN_PLUGIN_BUCKET') ?? DEFAULT_PLUGIN_BUCKET;
}
export async function ensurePrivatePluginBucket(): Promise<string> {
const client = requireClient();
const bucketName = getPluginBucketName();
try {
const bucket = await client.getBucket(bucketName);
if (bucket.isPublic) {
await client.updateBucket(bucketName, { isPublic: false });
}
return bucketName;
} catch (error) {
if (error instanceof CdnError && error.statusCode === 404) {
try {
await client.createBucket(bucketName, {
description: 'GamePanel plugin artifacts',
isPublic: false,
});
return bucketName;
} catch (createError) {
throw toCdnAppError(
createError,
'Failed to create CDN plugin bucket',
'CDN_BUCKET_CREATE_FAILED',
);
}
}
throw toCdnAppError(error, 'Failed to fetch CDN plugin bucket', 'CDN_BUCKET_READ_FAILED');
}
}
export function buildCdnArtifactPointer(fileId: string): string {
return `${ARTIFACT_POINTER_PREFIX}${fileId}`;
}
export function parseCdnArtifactPointer(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith(ARTIFACT_POINTER_PREFIX)) {
const id = trimmed.slice(ARTIFACT_POINTER_PREFIX.length).trim();
return id.length > 0 ? id : null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol === 'cdn:' && parsed.hostname === 'file') {
const candidate = parsed.pathname.replace(/^\/+/, '').trim();
return candidate.length > 0 ? candidate : null;
}
} catch {
return null;
}
return null;
}
export async function uploadPluginArtifact(
content: Uint8Array,
filename: string,
metadata: Record<string, unknown> = {},
): Promise<{ bucket: string; file: FileInfo; artifactPointer: string }> {
const client = requireClient();
const bucket = await ensurePrivatePluginBucket();
try {
const file = await client.upload(content, {
bucket,
filename,
metadata,
});
return {
bucket,
file,
artifactPointer: buildCdnArtifactPointer(file.id),
};
} catch (error) {
throw toCdnAppError(error, 'Failed to upload artifact to CDN', 'CDN_UPLOAD_FAILED');
}
}
export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<string> {
const fileId = parseCdnArtifactPointer(artifactUrl);
if (!fileId) return artifactUrl;
const client = requireClient();
const config = getCdnConfig();
const ttl = getArtifactAccessTtlSeconds();
try {
const access = await client.getFileAccessUrl(fileId, ttl);
if (!access.url || typeof access.url !== 'string') {
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
}
const resolvedUrl = access.url.trim();
if (!resolvedUrl) {
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
}
if (/^https?:\/\//i.test(resolvedUrl)) {
return resolvedUrl;
}
if (!config) {
throw new AppError(
500,
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
'CDN_NOT_CONFIGURED',
);
}
return new URL(resolvedUrl, config.baseUrl).toString();
} catch (error) {
throw toCdnAppError(error, 'Failed to get temporary CDN access URL', 'CDN_ACCESS_URL_FAILED');
}
}
+2 -2
View File
@@ -187,7 +187,7 @@ function parseKeyValue(content: string): ConfigEntry[] {
const entries: ConfigEntry[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
// Match: key "value" or key value
const match = trimmed.match(/^(\S+)\s+"([^"]*)"/) || trimmed.match(/^(\S+)\s+(.*)/);
@@ -210,7 +210,7 @@ function serializeKeyValue(entries: ConfigEntry[], originalContent?: string): st
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) {
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) {
result.push(line);
continue;
}
+961
View File
@@ -0,0 +1,961 @@
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();
}
}
+740
View File
@@ -0,0 +1,740 @@
import { randomBytes } from 'node:crypto';
import { gunzipSync } from 'node:zlib';
import type { FastifyInstance } from 'fastify';
import { and, asc, eq } from 'drizzle-orm';
import * as tar from 'tar-stream';
import type { Headers } from 'tar-stream';
import * as unzipper from 'unzipper';
import { serverDatabases, servers } from '@source/database';
import {
daemonCreateDatabase,
daemonDeleteDatabase,
daemonDeleteFiles,
daemonImportDatabaseSql,
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
const GITHUB_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
const URL_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
const QBCORE_DATABASE_NAME = 'qbcore';
const FIVE_M_QBCORE_MARKER_PATH = '/.gamepanel/fivem-qbcore.json';
const FIVE_M_INTERNAL_PORT = 30120;
const QBCORE_SQL_URL =
'https://raw.githubusercontent.com/qbcore-framework/txAdminRecipe/main/qbcore.sql';
const OXMYSQL_ZIP_URL =
'https://github.com/overextended/oxmysql/releases/download/v2.12.0/oxmysql.zip';
const MENUV_ZIP_URL = 'https://github.com/ThymonA/menuv/releases/download/v1.4.1/menuv_v1.4.1.zip';
interface ExtractedFile {
path: string;
data: Buffer;
}
interface ManagedServerDatabaseRecord {
id: string;
name: string;
databaseName: string;
username: string;
password: string;
host: string;
port: number;
phpMyAdminUrl: string | null;
}
interface FivemProvisionContext {
node: DaemonNodeConnection;
serverDescription?: string | null;
serverId: string;
serverName: string;
serverUuid: string;
}
interface GitHubArchiveResource {
destination: string;
owner: string;
ref: string;
repo: string;
subpath?: string;
}
interface RemoteArchiveResource {
collapseTopLevelDirectory?: boolean;
destination: string;
url: string;
}
const FIVEM_GITHUB_RESOURCES: GitHubArchiveResource[] = [
{
owner: 'citizenfx',
repo: 'cfx-server-data',
ref: 'master',
destination: '/resources/[cfx-default]',
subpath: 'resources',
},
{
owner: 'qbcore-framework',
repo: 'bob74_ipl',
ref: 'master',
destination: '/resources/[standalone]/bob74_ipl',
},
{
owner: 'qbcore-framework',
repo: 'safecracker',
ref: 'main',
destination: '/resources/[standalone]/safecracker',
},
{
owner: 'citizenfx',
repo: 'screenshot-basic',
ref: 'master',
destination: '/resources/[standalone]/screenshot-basic',
},
{
owner: 'qbcore-framework',
repo: 'progressbar',
ref: 'main',
destination: '/resources/[standalone]/progressbar',
},
{
owner: 'qbcore-framework',
repo: 'interact-sound',
ref: 'master',
destination: '/resources/[standalone]/interact-sound',
},
{
owner: 'qbcore-framework',
repo: 'connectqueue',
ref: 'master',
destination: '/resources/[standalone]/connectqueue',
},
{
owner: 'qbcore-framework',
repo: 'PolyZone',
ref: 'master',
destination: '/resources/[standalone]/PolyZone',
},
{
owner: 'AvarianKnight',
repo: 'pma-voice',
ref: 'main',
destination: '/resources/[voice]/pma-voice',
},
{
owner: 'qbcore-framework',
repo: 'qb-radio',
ref: 'main',
destination: '/resources/[voice]/qb-radio',
},
{
owner: 'qbcore-framework',
repo: 'hospital_map',
ref: 'main',
destination: '/resources/[defaultmaps]/hospital_map',
},
{
owner: 'qbcore-framework',
repo: 'dealer_map',
ref: 'main',
destination: '/resources/[defaultmaps]/dealer_map',
},
{
owner: 'qbcore-framework',
repo: 'prison_map',
ref: 'main',
destination: '/resources/[defaultmaps]/prison_map',
},
...[
'qb-core',
'qb-scoreboard',
'qb-adminmenu',
'qb-multicharacter',
'qb-target',
'qb-vehiclesales',
'qb-vehicleshop',
'qb-houserobbery',
'qb-prison',
'qb-hud',
'qb-management',
'qb-weed',
'qb-lapraces',
'qb-inventory',
'qb-houses',
'qb-garages',
'qb-ambulancejob',
'qb-radialmenu',
'qb-crypto',
'qb-weathersync',
'qb-policejob',
'qb-apartments',
'qb-vehiclekeys',
'qb-mechanicjob',
'qb-phone',
'qb-vineyard',
'qb-weapons',
'qb-scrapyard',
'qb-towjob',
'qb-streetraces',
'qb-storerobbery',
'qb-spawn',
'qb-smallresources',
'qb-recyclejob',
'qb-crafting',
'qb-diving',
'qb-cityhall',
'qb-truckrobbery',
'qb-pawnshop',
'qb-minigames',
'qb-taxijob',
'qb-busjob',
'qb-newsjob',
'qb-fuel',
'qb-jewelery',
'qb-bankrobbery',
'qb-banking',
'qb-clothing',
'qb-hotdogjob',
'qb-doorlock',
'qb-garbagejob',
'qb-drugs',
'qb-shops',
'qb-interior',
'qb-menu',
'qb-input',
'qb-loading',
].map((repo) => ({
owner: 'qbcore-framework',
repo,
ref: 'main',
destination: `/resources/[qb]/${repo}`,
})),
];
const FIVEM_REMOTE_ARCHIVES: RemoteArchiveResource[] = [
{
url: OXMYSQL_ZIP_URL,
destination: '/resources/[standalone]/oxmysql',
collapseTopLevelDirectory: true,
},
{
url: MENUV_ZIP_URL,
destination: '/resources/[standalone]/menuv',
collapseTopLevelDirectory: true,
},
];
function normalizePathSegments(path: string): string[] {
return path
.replace(/\\/g, '/')
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
function normalizeArchivePath(path: string): string | null {
const segments = normalizePathSegments(path);
if (segments.length === 0) return null;
return segments.join('/');
}
function joinServerPath(base: string, relative: string): string {
const baseSegments = normalizePathSegments(base);
const relativeSegments = normalizePathSegments(relative);
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
}
function stripSharedTopLevelDirectory(files: ExtractedFile[]): ExtractedFile[] {
if (files.length === 0) return files;
const firstSegments = new Set<string>();
for (const file of files) {
const [first] = normalizePathSegments(file.path);
if (!first) return files;
firstSegments.add(first);
if (firstSegments.size > 1) {
return files;
}
}
return files
.map((file) => {
const segments = normalizePathSegments(file.path).slice(1);
if (segments.length === 0) return null;
return {
path: segments.join('/'),
data: file.data,
};
})
.filter((file): file is ExtractedFile => file !== null);
}
function filterFilesBySubpath(files: ExtractedFile[], subpath: string): ExtractedFile[] {
const prefix = normalizePathSegments(subpath).join('/');
if (!prefix) return files;
const normalizedPrefix = `${prefix}/`;
return files
.map((file) => {
if (file.path === prefix) return null;
if (!file.path.startsWith(normalizedPrefix)) return null;
return {
path: file.path.slice(normalizedPrefix.length),
data: file.data,
};
})
.filter((file): file is ExtractedFile => file !== null && file.path.length > 0);
}
async function downloadBinary(
url: string,
maxBytes: number,
headers: Record<string, string> = {},
): Promise<Buffer> {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
...headers,
},
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Download failed (${response.status}): ${url}`);
}
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (contentLength > maxBytes) {
throw new Error(`Download exceeds size limit (${contentLength} > ${maxBytes})`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error(`Downloaded archive is empty: ${url}`);
}
if (buffer.length > maxBytes) {
throw new Error(`Download exceeds size limit (${buffer.length} > ${maxBytes})`);
}
return buffer;
}
async function downloadText(url: string, headers: Record<string, string> = {}): Promise<string> {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
...headers,
},
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Text download failed (${response.status}): ${url}`);
}
const text = await response.text();
if (!text.trim()) {
throw new Error(`Downloaded text is empty: ${url}`);
}
return text;
}
async function extractZipFiles(buffer: Buffer): Promise<ExtractedFile[]> {
const archive = await unzipper.Open.buffer(buffer);
const files: ExtractedFile[] = [];
for (const entry of archive.files) {
if (entry.type !== 'File') continue;
const normalized = normalizeArchivePath(entry.path);
if (!normalized) continue;
files.push({
path: normalized,
data: await entry.buffer(),
});
}
return files;
}
function extractTarFiles(buffer: Buffer): Promise<ExtractedFile[]> {
return new Promise((resolve, reject) => {
const extract = tar.extract();
const files: ExtractedFile[] = [];
extract.on('entry', (header: Headers, stream, next) => {
const type = header.type ?? 'file';
const normalized = normalizeArchivePath(header.name);
const isFileType = type === 'file' || type === 'contiguous-file';
if (!isFileType || !normalized) {
stream.resume();
stream.on('end', next);
stream.on('error', reject);
return;
}
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('end', () => {
files.push({ path: normalized, data: Buffer.concat(chunks) });
next();
});
stream.on('error', reject);
});
extract.on('finish', () => resolve(files));
extract.on('error', reject);
extract.end(buffer);
});
}
async function extractArchive(buffer: Buffer, url: string): Promise<ExtractedFile[]> {
const normalizedUrl = url.toLowerCase();
if (normalizedUrl.endsWith('.zip')) {
return extractZipFiles(buffer);
}
if (normalizedUrl.endsWith('.tar.gz') || normalizedUrl.endsWith('.tgz')) {
return extractTarFiles(gunzipSync(buffer));
}
if (normalizedUrl.endsWith('.tar')) {
return extractTarFiles(buffer);
}
throw new Error(`Unsupported archive type: ${url}`);
}
async function writeFilesToServer(
node: DaemonNodeConnection,
serverUuid: string,
destination: string,
files: ExtractedFile[],
): Promise<void> {
for (const file of files) {
await daemonWriteFile(node, serverUuid, joinServerPath(destination, file.path), file.data);
}
}
function escapeCfgValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function buildMysqlConnectionString(database: ManagedServerDatabaseRecord): string {
return `mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}?charset=utf8mb4`;
}
function renderFivemServerConfig(
serverName: string,
description: string | null | undefined,
database: ManagedServerDatabaseRecord,
): string {
const safeServerName = escapeCfgValue(serverName.trim() || 'QBCore Server');
const safeProjectDescription = escapeCfgValue(
description?.trim() || 'QBCore server provisioned by Source GamePanel.',
);
const rconPassword = randomBytes(16).toString('hex');
const mysqlConnectionString = escapeCfgValue(buildMysqlConnectionString(database));
return `# Generated by Source GamePanel
# QBCore resources and base dependencies are installed automatically.
endpoint_add_tcp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
endpoint_add_udp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
sv_maxclients "32"
sv_hostname "${safeServerName}"
sets sv_projectName "[QBCore] ${safeServerName}"
sets sv_projectDesc "${safeProjectDescription}"
sets locale "en-US"
sets tags "qbcore, qb-core, roleplay, source-gamepanel"
set steam_webApiKey "none"
set resources_useSystemChat "true"
set mysql_connection_string "${mysqlConnectionString}"
setr qb_locale "en"
setr UseTarget "false"
setr voice_useNativeAudio "true"
setr voice_useSendingRangeOnly "true"
setr voice_defaultCycle "GRAVE"
setr voice_defaultVolume "0.3"
setr voice_enableRadioAnim "1"
setr voice_syncData "1"
sv_scriptHookAllowed "0"
sv_endpointprivacy "true"
rcon_password "${rconPassword}"
ensure mapmanager
ensure chat
ensure spawnmanager
ensure sessionmanager
ensure basic-gamemode
ensure hardcap
ensure baseevents
ensure qb-core
ensure [qb]
ensure [standalone]
ensure [voice]
ensure [defaultmaps]
add_ace group.admin command allow
add_ace group.admin command.quit deny
add_ace resource.qb-core command allow
add_ace qbcore.god command allow
add_principal qbcore.god group.admin
add_principal qbcore.god qbcore.admin
add_principal qbcore.admin qbcore.mod
`;
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
export function isFivemQbCoreGame(gameSlug: string): boolean {
return gameSlug.trim().toLowerCase() === 'fivem';
}
export async function ensureFivemQbCoreDatabase(
app: FastifyInstance,
context: Pick<FivemProvisionContext, 'node' | 'serverId' | 'serverUuid'>,
): Promise<ManagedServerDatabaseRecord> {
const existing = await app.db
.select({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
})
.from(serverDatabases)
.where(eq(serverDatabases.serverId, context.serverId))
.orderBy(asc(serverDatabases.createdAt));
const preferred =
existing.find((database) => database.name.trim().toLowerCase() === QBCORE_DATABASE_NAME) ??
existing[0];
if (preferred) {
return preferred;
}
const managedDatabase = await daemonCreateDatabase(context.node, {
serverUuid: context.serverUuid,
name: QBCORE_DATABASE_NAME,
});
try {
const [created] = await app.db
.insert(serverDatabases)
.values({
serverId: context.serverId,
name: QBCORE_DATABASE_NAME,
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
password: managedDatabase.password,
host: managedDatabase.host,
port: managedDatabase.port,
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
})
.returning({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
});
if (!created) {
throw new Error('Failed to persist managed database metadata');
}
return created;
} catch (error) {
try {
await daemonDeleteDatabase(context.node, {
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
});
} catch (cleanupError) {
app.log.error(
{
cleanupError,
databaseName: managedDatabase.databaseName,
serverId: context.serverId,
serverUuid: context.serverUuid,
},
'Failed to roll back managed MySQL database after metadata save failure',
);
}
throw error;
}
}
export async function deleteFivemQbCoreDatabase(
app: FastifyInstance,
context: Pick<FivemProvisionContext, 'node' | 'serverId'>,
): Promise<void> {
const [database] = await app.db
.select({
id: serverDatabases.id,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
})
.from(serverDatabases)
.where(
and(
eq(serverDatabases.serverId, context.serverId),
eq(serverDatabases.name, QBCORE_DATABASE_NAME),
),
);
if (!database) return;
await daemonDeleteDatabase(context.node, {
databaseName: database.databaseName,
username: database.username,
});
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, database.id));
}
async function installGitHubResource(
app: FastifyInstance,
context: FivemProvisionContext,
resource: GitHubArchiveResource,
): Promise<void> {
const archiveUrl = `https://codeload.github.com/${resource.owner}/${resource.repo}/tar.gz/refs/heads/${encodeURIComponent(resource.ref)}`;
const archive = await downloadBinary(archiveUrl, GITHUB_ARCHIVE_MAX_BYTES);
let files = await extractTarFiles(gunzipSync(archive));
files = stripSharedTopLevelDirectory(files);
if (resource.subpath) {
files = filterFilesBySubpath(files, resource.subpath);
}
if (files.length === 0) {
throw new Error(
`GitHub archive had no files: ${resource.owner}/${resource.repo}@${resource.ref}`,
);
}
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
app.log.info(
{
destination: resource.destination,
filesWritten: files.length,
repo: `${resource.owner}/${resource.repo}`,
serverId: context.serverId,
serverUuid: context.serverUuid,
},
'Installed FiveM GitHub resource',
);
}
async function installRemoteArchive(
app: FastifyInstance,
context: FivemProvisionContext,
resource: RemoteArchiveResource,
): Promise<void> {
const archive = await downloadBinary(resource.url, URL_ARCHIVE_MAX_BYTES);
let files = await extractArchive(archive, resource.url);
if (resource.collapseTopLevelDirectory) {
files = stripSharedTopLevelDirectory(files);
}
if (files.length === 0) {
throw new Error(`Remote archive had no files: ${resource.url}`);
}
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
app.log.info(
{
destination: resource.destination,
filesWritten: files.length,
serverId: context.serverId,
serverUuid: context.serverUuid,
url: resource.url,
},
'Installed FiveM remote archive',
);
}
export async function provisionFivemQbCoreServer(
app: FastifyInstance,
context: FivemProvisionContext,
): Promise<void> {
try {
await daemonReadFile(context.node, context.serverUuid, FIVE_M_QBCORE_MARKER_PATH);
return;
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}
const database = await ensureFivemQbCoreDatabase(app, context);
const qbCoreSql = await downloadText(QBCORE_SQL_URL);
await daemonImportDatabaseSql(context.node, {
databaseName: database.databaseName,
sql: qbCoreSql,
});
for (const resource of FIVEM_GITHUB_RESOURCES) {
await installGitHubResource(app, context, resource);
}
for (const resource of FIVEM_REMOTE_ARCHIVES) {
await installRemoteArchive(app, context, resource);
}
try {
await daemonDeleteFiles(context.node, context.serverUuid, [
'/resources/[cfx-default]/[gameplay]/chat',
]);
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}
await daemonWriteFile(
context.node,
context.serverUuid,
'/server.cfg',
renderFivemServerConfig(context.serverName, context.serverDescription, database),
);
await daemonWriteFile(
context.node,
context.serverUuid,
FIVE_M_QBCORE_MARKER_PATH,
JSON.stringify(
{
installedAt: new Date().toISOString(),
manifestVersion: 1,
resourceCount: FIVEM_GITHUB_RESOURCES.length + FIVEM_REMOTE_ARCHIVES.length,
},
null,
2,
),
);
await app.db
.update(servers)
.set({ updatedAt: new Date() })
.where(eq(servers.id, context.serverId));
}
+42 -3
View File
@@ -14,14 +14,53 @@ export interface RefreshTokenPayload {
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
type JwtSign = (payload: object, options?: { expiresIn?: string }) => string;
type JwtVerify = (token: string) => unknown;
/**
* The parts of the JWT decoration we actually call.
*
* @fastify/jwt decorates the instance at runtime and the refresh namespace is
* registered by our own auth plugin, so neither appears in FastifyInstance's
* type. Describing the shape here keeps the call sites type-checked instead of
* casting the instance to `any`, which switches checking off entirely.
*/
interface JwtDecoratedInstance {
jwt?: {
sign?: JwtSign;
verify?: JwtVerify;
refresh?: { sign?: JwtSign; verify?: JwtVerify };
jwtRefresh?: { sign?: JwtSign; verify?: JwtVerify };
};
}
/** The decorated JWT namespace, or undefined when the plugin is not loaded. */
export function getJwt(app: FastifyInstance): JwtDecoratedInstance['jwt'] {
return (app as unknown as JwtDecoratedInstance).jwt;
}
export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string {
return app.jwt.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY });
const signer = getJwt(app)?.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 jwt = getJwt(app);
const signer = jwt?.refresh?.sign ?? 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 jwt = getJwt(app);
const verifier = jwt?.refresh?.verify ?? jwt?.jwtRefresh?.verify;
if (typeof verifier !== 'function') {
throw new Error('Refresh JWT verifier is not configured');
}
return verifier(token) as RefreshTokenPayload;
}
+373
View File
@@ -0,0 +1,373 @@
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);
}
}
})();
}
+10 -1
View File
@@ -5,7 +5,16 @@ export const PaginationQuerySchema = Type.Object({
perPage: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })),
});
export function paginate(query: { page?: number; perPage?: number }) {
/**
* The querystring shape PaginationQuerySchema validates.
*
* Route handlers receive `request.query` as `unknown`; the schema has already
* checked the values by then, so the cast at the call site is what tells
* TypeScript what Fastify handed over.
*/
export type PaginationQuery = { page?: number; perPage?: number };
export function paginate(query: PaginationQuery) {
const page = query.page ?? 1;
const perPage = query.perPage ?? 20;
const offset = (page - 1) * perPage;
+5 -2
View File
@@ -24,7 +24,7 @@ export async function getOrgMembership(
return 'super_admin';
}
const member = await (request.server as any).db.query.organizationMembers.findFirst({
const member = await request.server.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.organizationId, orgId),
eq(organizationMembers.userId, user.sub),
@@ -45,7 +45,10 @@ export async function getOrgMembership(
* Check if the user has a specific permission in the organization.
* Super admins always have all permissions.
*/
export function hasPermission(membership: OrgMember | 'super_admin', permission: Permission): boolean {
export function hasPermission(
membership: OrgMember | 'super_admin',
permission: Permission,
): boolean {
if (membership === 'super_admin') return true;
// Check custom permission overrides first
+1 -4
View File
@@ -1,10 +1,7 @@
/**
* Compute the next run time for a scheduled task.
*/
export function computeNextRun(
scheduleType: string,
scheduleData: Record<string, unknown>,
): Date {
export function computeNextRun(scheduleType: string, scheduleData: Record<string, unknown>): Date {
const now = new Date();
switch (scheduleType) {
+938
View File
@@ -0,0 +1,938 @@
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;
}
+2 -3
View File
@@ -6,7 +6,6 @@ import type { AccessTokenPayload } from '../lib/jwt.js';
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
jwtRefresh: FastifyInstance['jwt'];
}
}
@@ -28,13 +27,13 @@ export default fp(async (app: FastifyInstance) => {
// Access token JWT
await app.register(jwt, {
secret: jwtSecret,
namespace: 'jwt',
});
// Refresh token JWT (separate namespace)
await app.register(jwt, {
secret: jwtRefreshSecret,
namespace: 'jwtRefresh',
namespace: 'refresh',
decoratorName: 'jwtRefresh',
});
// Auth decorator
+24
View File
@@ -1,6 +1,7 @@
import fp from 'fastify-plugin';
import type { FastifyInstance } from 'fastify';
import { createDb, type Database } from '@source/database';
import { sql } from 'drizzle-orm';
declare module 'fastify' {
interface FastifyInstance {
@@ -17,5 +18,28 @@ 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');
});
+358
View File
@@ -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 { getJwt } from '../lib/jwt.js';
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 = getJwt(app)?.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,
},
};
}
+836 -15
View File
@@ -1,12 +1,198 @@
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 type { PaginationQuery } from '../../lib/pagination.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) => {
@@ -17,7 +203,7 @@ export default async function adminRoutes(app: FastifyInstance) {
// GET /api/admin/users
app.get('/users', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
const { page, perPage, offset, limit } = paginate(request.query as any);
const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery);
const [totalResult] = await app.db.select({ count: count() }).from(users);
@@ -42,10 +228,7 @@ export default async function adminRoutes(app: FastifyInstance) {
// GET /api/admin/games
app.get('/games', async () => {
const gameList = await app.db
.select()
.from(games)
.orderBy(games.name);
const gameList = await app.db.select().from(games).orderBy(games.name);
return { data: gameList };
});
@@ -59,8 +242,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 +260,7 @@ export default async function adminRoutes(app: FastifyInstance) {
...body,
configFiles: body.configFiles ?? [],
environmentVars: body.environmentVars ?? [],
automationRules: body.automationRules ?? [],
})
.returning();
@@ -81,7 +268,10 @@ export default async function adminRoutes(app: FastifyInstance) {
});
// PATCH /api/admin/games/:gameId
app.patch('/games/:gameId', { schema: { ...GameIdParamSchema, ...UpdateGameSchema } }, async (request) => {
app.patch(
'/games/:gameId',
{ schema: { ...GameIdParamSchema, ...UpdateGameSchema } },
async (request) => {
const { gameId } = request.params as { gameId: string };
const body = request.body as Record<string, unknown>;
@@ -94,16 +284,647 @@ export default async function adminRoutes(app: FastifyInstance) {
if (!updated) throw AppError.notFound('Game not found');
return updated;
});
},
);
// === 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
.select()
.from(nodes)
.orderBy(nodes.createdAt);
const nodeList = await app.db.select().from(nodes).orderBy(nodes.createdAt);
return { data: nodeList };
});
@@ -112,7 +933,7 @@ export default async function adminRoutes(app: FastifyInstance) {
// GET /api/admin/audit-logs
app.get('/audit-logs', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
const { page, perPage, offset, limit } = paginate(request.query as any);
const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery);
const [totalResult] = await app.db.select({ count: count() }).from(auditLogs);
+145
View File
@@ -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,142 @@ 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()),
}),
};
+34 -1
View File
@@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm';
import { users } from '@source/database';
import { hashPassword, verifyPassword } from '../../lib/password.js';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.js';
import type { AccessTokenPayload, RefreshTokenPayload } from '../../lib/jwt.js';
import type { RefreshTokenPayload } from '../../lib/jwt.js';
import { AppError } from '../../lib/errors.js';
import { RegisterSchema, LoginSchema } from './schemas.js';
@@ -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;
+13
View File
@@ -0,0 +1,13 @@
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 };
});
}
+180
View File
@@ -0,0 +1,180 @@
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 };
},
);
}
+66
View File
@@ -0,0 +1,66 @@
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,
};
});
}
+117 -6
View File
@@ -1,10 +1,15 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { randomBytes } from 'crypto';
import { nodes, allocations } from '@source/database';
import { nodes, allocations, servers, games } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { createAuditLog } from '../../lib/audit.js';
import {
daemonGetNodeStats,
daemonGetNodeStatus,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
import {
NodeParamSchema,
CreateNodeSchema,
@@ -26,7 +31,16 @@ export default async function nodeRoutes(app: FastifyInstance) {
.where(eq(nodes.organizationId, orgId))
.orderBy(nodes.createdAt);
return { data: nodeList };
const total = nodeList.length;
return {
data: nodeList,
meta: {
total,
page: 1,
perPage: total,
totalPages: total === 0 ? 0 : 1,
},
};
});
// POST /api/organizations/:orgId/nodes
@@ -80,7 +94,10 @@ export default async function nodeRoutes(app: FastifyInstance) {
});
// PATCH /api/organizations/:orgId/nodes/:nodeId
app.patch('/:nodeId', { schema: { ...NodeParamSchema, ...UpdateNodeSchema } }, async (request) => {
app.patch(
'/:nodeId',
{ schema: { ...NodeParamSchema, ...UpdateNodeSchema } },
async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
@@ -101,7 +118,8 @@ export default async function nodeRoutes(app: FastifyInstance) {
});
return updated;
});
},
);
// DELETE /api/organizations/:orgId/nodes/:nodeId
app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => {
@@ -124,6 +142,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
@@ -141,7 +248,10 @@ export default async function nodeRoutes(app: FastifyInstance) {
});
// POST /api/organizations/:orgId/nodes/:nodeId/allocations
app.post('/:nodeId/allocations', { schema: { ...NodeParamSchema, ...CreateAllocationSchema } }, async (request, reply) => {
app.post(
'/:nodeId/allocations',
{ schema: { ...NodeParamSchema, ...CreateAllocationSchema } },
async (request, reply) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
@@ -166,5 +276,6 @@ export default async function nodeRoutes(app: FastifyInstance) {
});
return reply.code(201).send({ data: created });
});
},
);
}
+30 -16
View File
@@ -4,6 +4,7 @@ import { organizations, organizationMembers, users } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission, getOrgMembership } from '../../lib/permissions.js';
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
import type { PaginationQuery } from '../../lib/pagination.js';
import { createAuditLog } from '../../lib/audit.js';
import {
CreateOrgSchema,
@@ -20,7 +21,7 @@ export default async function organizationRoutes(app: FastifyInstance) {
// GET /api/organizations — list user's organizations
app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
const { page, perPage, offset, limit } = paginate(request.query as any);
const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery);
const userId = request.user.sub;
if (request.user.isSuperAdmin) {
@@ -173,7 +174,10 @@ export default async function organizationRoutes(app: FastifyInstance) {
});
// POST /api/organizations/:orgId/members — invite by email
app.post('/:orgId/members', { schema: { ...OrgIdParamSchema, ...AddMemberSchema } }, async (request, reply) => {
app.post(
'/:orgId/members',
{ schema: { ...OrgIdParamSchema, ...AddMemberSchema } },
async (request, reply) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'org.members');
@@ -208,22 +212,28 @@ export default async function organizationRoutes(app: FastifyInstance) {
});
return reply.code(201).send(member);
});
},
);
// PATCH /api/organizations/:orgId/members/:memberId
app.patch('/:orgId/members/:memberId', { schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } }, async (request) => {
app.patch(
'/:orgId/members/:memberId',
{ schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } },
async (request) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members');
const body = request.body as { role?: 'admin' | 'user'; customPermissions?: Record<string, boolean> };
const body = request.body as {
role?: 'admin' | 'user';
customPermissions?: Record<string, boolean>;
};
const [updated] = await app.db
.update(organizationMembers)
.set(body)
.where(and(
eq(organizationMembers.id, memberId),
eq(organizationMembers.organizationId, orgId),
))
.where(
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
)
.returning();
if (!updated) throw AppError.notFound('Member not found');
@@ -235,10 +245,14 @@ export default async function organizationRoutes(app: FastifyInstance) {
});
return updated;
});
},
);
// DELETE /api/organizations/:orgId/members/:memberId
app.delete('/:orgId/members/:memberId', { schema: MemberIdParamSchema }, async (request, reply) => {
app.delete(
'/:orgId/members/:memberId',
{ schema: MemberIdParamSchema },
async (request, reply) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members');
@@ -260,10 +274,9 @@ export default async function organizationRoutes(app: FastifyInstance) {
await app.db
.delete(organizationMembers)
.where(and(
eq(organizationMembers.id, memberId),
eq(organizationMembers.organizationId, orgId),
));
.where(
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
);
await createAuditLog(app.db, request, {
organizationId: orgId,
@@ -272,5 +285,6 @@ export default async function organizationRoutes(app: FastifyInstance) {
});
return reply.code(204).send();
});
},
);
}
+94 -23
View File
@@ -1,10 +1,16 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { Type } from '@sinclair/typebox';
import { servers, backups } from '@source/database';
import { servers, backups, nodes } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { createAuditLog } from '../../lib/audit.js';
import {
daemonCreateBackup,
daemonDeleteBackup,
daemonRestoreBackup,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
const ParamSchema = {
params: Type.Object({
@@ -54,10 +60,7 @@ export default async function backupRoutes(app: FastifyInstance) {
const body = request.body as { name: string; isLocked?: boolean };
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
const serverContext = await getServerBackupContext(app, orgId, serverId);
// Create backup record (pending — daemon will update when complete)
const [backup] = await app.db
@@ -69,12 +72,41 @@ export default async function backupRoutes(app: FastifyInstance) {
})
.returning();
// TODO: Send gRPC CreateBackup to daemon
// Daemon will:
// 1. tar+gz the server directory
// 2. Upload to @source/cdn
// 3. Callback to API with cdnPath, sizeBytes, checksum
// 4. API updates backup record with completedAt
if (!backup) {
throw new AppError(500, 'Failed to create backup record', 'BACKUP_CREATE_FAILED');
}
let completedBackup = backup;
try {
const daemonResult = await daemonCreateBackup(
serverContext.node,
serverContext.serverUuid,
backup.id,
);
if (!daemonResult.success) {
throw new Error('Daemon returned unsuccessful backup response');
}
const [updated] = await app.db
.update(backups)
.set({
sizeBytes: daemonResult.sizeBytes,
checksum: daemonResult.checksum || null,
completedAt: new Date(),
})
.where(eq(backups.id, backup.id))
.returning();
completedBackup = updated ?? completedBackup;
} catch (error) {
request.log.error(
{ error, serverId, backupId: backup.id },
'Failed to create backup on daemon',
);
await app.db.delete(backups).where(eq(backups.id, backup.id));
throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED');
}
await createAuditLog(app.db, request, {
organizationId: orgId,
@@ -83,7 +115,7 @@ export default async function backupRoutes(app: FastifyInstance) {
metadata: { name: body.name },
});
return reply.code(201).send(backup);
return reply.code(201).send(completedBackup);
});
// POST /backups/:backupId/restore — restore a backup
@@ -95,10 +127,7 @@ export default async function backupRoutes(app: FastifyInstance) {
};
await requirePermission(request, orgId, 'backup.restore');
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
const serverContext = await getServerBackupContext(app, orgId, serverId);
const backup = await app.db.query.backups.findFirst({
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
@@ -106,12 +135,17 @@ export default async function backupRoutes(app: FastifyInstance) {
if (!backup) throw AppError.notFound('Backup not found');
if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed');
// TODO: Send gRPC RestoreBackup to daemon
// Daemon will:
// 1. Stop the server
// 2. Download backup from @source/cdn
// 3. Extract tar.gz over server directory
// 4. Start the server
try {
await daemonRestoreBackup(
serverContext.node,
serverContext.serverUuid,
backup.id,
backup.cdnPath,
);
} catch (error) {
request.log.error({ error, serverId, backupId }, 'Failed to restore backup on daemon');
throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED');
}
await createAuditLog(app.db, request, {
organizationId: orgId,
@@ -161,7 +195,14 @@ export default async function backupRoutes(app: FastifyInstance) {
if (!backup) throw AppError.notFound('Backup not found');
if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup');
// TODO: Send gRPC DeleteBackup to daemon to remove from CDN
const serverContext = await getServerBackupContext(app, orgId, serverId);
try {
await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id);
} catch (error) {
request.log.error({ error, serverId, backupId }, 'Failed to delete backup on daemon');
throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED');
}
await app.db.delete(backups).where(eq(backups.id, backupId));
@@ -175,3 +216,33 @@ export default async function backupRoutes(app: FastifyInstance) {
return reply.code(204).send();
});
}
async function getServerBackupContext(
app: FastifyInstance,
orgId: string,
serverId: string,
): Promise<{ serverUuid: string; node: DaemonNodeConnection }> {
const [server] = await app.db
.select({
serverUuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
throw AppError.notFound('Server not found');
}
return {
serverUuid: server.serverUuid,
node: {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
};
}
+113 -18
View File
@@ -1,11 +1,17 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { Type } from '@sinclair/typebox';
import { servers, games } from '@source/database';
import { servers, games, nodes } from '@source/database';
import type { GameConfigFile, ConfigParser } from '@source/shared';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
import {
managedConfigFileFor,
readManagedConfig,
writeManagedConfig,
} from '../../lib/managed-config.js';
const ParamSchema = {
params: Type.Object({
@@ -60,16 +66,43 @@ export default async function configRoutes(app: FastifyInstance) {
};
await requirePermission(request, orgId, 'config.read');
const { game, server, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
const { game, server, node, configFile } = await getServerConfig(
app,
orgId,
serverId,
configIndex,
);
// TODO: Read file from daemon via gRPC
// For now, return empty parsed result (will be connected in Phase 4 integration)
let raw = '';
try {
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: '',
entries,
raw,
};
});
@@ -98,12 +131,48 @@ export default async function configRoutes(app: FastifyInstance) {
const { entries } = request.body as { entries: { key: string; value: string }[] };
await requirePermission(request, orgId, 'config.write');
const { configFile } = await getServerConfig(app, orgId, serverId, configIndex);
const { game, server, node, configFile } = await getServerConfig(
app,
orgId,
serverId,
configIndex,
);
// If editableKeys is set, only allow those keys
const 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 invalidKeys = entries.filter((e) => !allowedKeys.has(e.key));
const existingKeys = new Set(originalEntries.map((entry) => entry.key));
const invalidKeys = entries.filter(
(entry) => !allowedKeys.has(entry.key) && !existingKeys.has(entry.key),
);
if (invalidKeys.length > 0) {
throw AppError.badRequest(
`Keys not allowed: ${invalidKeys.map((k) => k.key).join(', ')}`,
@@ -111,11 +180,13 @@ export default async function configRoutes(app: FastifyInstance) {
}
}
// Serialize the entries
const content = serializeConfig(entries, configFile.parser as ConfigParser);
const content = serializeConfig(entries, configFile.parser as ConfigParser, originalContent);
// TODO: Write file to daemon via gRPC
// For now, just return success
if (managedFile) {
await writeManagedConfig(node, server.uuid, managedFile, content);
} else {
await daemonWriteFile(node, server.uuid, configFile.path, content);
}
return { success: true, path: configFile.path, content };
},
);
@@ -127,13 +198,22 @@ async function getServerConfig(
serverId: string,
configIndex: number,
) {
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
const [server] = await app.db
.select({
id: servers.id,
uuid: servers.uuid,
gameId: servers.gameId,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) throw AppError.notFound('Server not found');
const game = await app.db.query.games.findFirst({
where: eq(games.id, server.gameId),
where: eq(games.id, server.gameId as string),
});
if (!game) throw AppError.notFound('Game not found');
@@ -141,5 +221,20 @@ async function getServerConfig(
const configFile = configFiles[configIndex];
if (!configFile) throw AppError.notFound('Config file not found');
return { game, server, configFile };
const node: DaemonNodeConnection = {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
};
return { game, server, node, configFile };
}
function isMissingConfigFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
+351
View File
@@ -0,0 +1,351 @@
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();
});
}
+242
View File
@@ -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
+67
View File
@@ -0,0 +1,67 @@
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
+62 -11
View File
@@ -1,11 +1,17 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { Type } from '@sinclair/typebox';
import { servers, scheduledTasks } from '@source/database';
import { nodes, servers, scheduledTasks } from '@source/database';
import type { PowerAction } from '@source/shared';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { createAuditLog } from '../../lib/audit.js';
import { computeNextRun } from '../../lib/schedule-utils.js';
import {
daemonSendCommand,
daemonSetPowerState,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
const ParamSchema = {
params: Type.Object({
@@ -24,11 +30,7 @@ const TaskParamSchema = {
const CreateScheduleBody = Type.Object({
name: Type.String({ minLength: 1, maxLength: 255 }),
action: Type.Union([
Type.Literal('command'),
Type.Literal('power'),
Type.Literal('backup'),
]),
action: Type.Union([Type.Literal('command'), Type.Literal('power'), Type.Literal('backup')]),
payload: Type.String({ minLength: 1 }),
scheduleType: Type.Union([
Type.Literal('interval'),
@@ -125,7 +127,10 @@ export default async function scheduleRoutes(app: FastifyInstance) {
});
// PATCH /schedules/:taskId — update a scheduled task
app.patch('/:taskId', { schema: { ...TaskParamSchema, body: UpdateScheduleBody } }, async (request) => {
app.patch(
'/:taskId',
{ schema: { ...TaskParamSchema, body: UpdateScheduleBody } },
async (request) => {
const { orgId, serverId, taskId } = request.params as {
orgId: string;
serverId: string;
@@ -142,7 +147,9 @@ export default async function scheduleRoutes(app: FastifyInstance) {
// 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 scheduleData =
(body.scheduleData as Record<string, unknown>) ||
(existing.scheduleData as Record<string, unknown>);
const nextRun = computeNextRun(scheduleType, scheduleData);
const [updated] = await app.db
@@ -152,7 +159,8 @@ export default async function scheduleRoutes(app: FastifyInstance) {
.returning();
return updated;
});
},
);
// DELETE /schedules/:taskId — delete a scheduled task
app.delete('/:taskId', { schema: TaskParamSchema }, async (request, reply) => {
@@ -194,8 +202,18 @@ export default async function scheduleRoutes(app: FastifyInstance) {
});
if (!task) throw AppError.notFound('Scheduled task not found');
// TODO: Execute task action (send to daemon via gRPC)
// For now, just update lastRunAt and nextRunAt
if (task.action === 'command') {
const serverContext = await getServerContext(app, orgId, serverId);
await daemonSendCommand(serverContext.node, serverContext.serverUuid, task.payload);
} else if (task.action === 'power') {
const action = task.payload as PowerAction;
if (!['start', 'stop', 'restart', 'kill'].includes(action)) {
throw AppError.badRequest('Invalid power action in schedule payload');
}
const serverContext = await getServerContext(app, orgId, serverId);
await daemonSetPowerState(serverContext.node, serverContext.serverUuid, action);
}
const nextRun = computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>);
await app.db
@@ -206,3 +224,36 @@ export default async function scheduleRoutes(app: FastifyInstance) {
return { success: true, triggered: task.name };
});
}
async function getServerContext(
app: FastifyInstance,
orgId: string,
serverId: string,
): Promise<{
serverUuid: string;
node: DaemonNodeConnection;
}> {
const [server] = await app.db
.select({
uuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
throw AppError.notFound('Server not found');
}
return {
serverUuid: server.uuid,
node: {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
};
}
+1
View File
@@ -17,6 +17,7 @@ export const CreateServerSchema = {
diskLimit: Type.Number({ minimum: 256 * 1024 * 1024 }), // min 256MB
cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000, default: 100 })),
allocationId: Type.String({ format: 'uuid' }),
additionalAllocationIds: Type.Optional(Type.Array(Type.String({ format: 'uuid' }))),
environment: Type.Optional(Type.Record(Type.String(), Type.String())),
startupOverride: Type.Optional(Type.String()),
}),
+1
View File
@@ -484,6 +484,7 @@ dependencies = [
"bollard",
"flate2",
"futures",
"libc",
"prost",
"prost-types",
"reqwest",
+1
View File
@@ -32,6 +32,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Error handling
anyhow = "1"
thiserror = "2"
libc = "0.2"
# UUID
uuid = { version = "1", features = ["v4"] }
+33
View File
@@ -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"]
+152
View File
@@ -0,0 +1,152 @@
use std::collections::HashMap;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use anyhow::{anyhow, Result};
use tokio::sync::{mpsc, oneshot, RwLock};
use tracing::{debug, warn};
use crate::server::ServerManager;
const DEFAULT_QUEUE_CAPACITY: usize = 256;
#[derive(Debug)]
struct CommandJob {
command: String,
response_tx: oneshot::Sender<Result<()>>,
}
#[derive(Clone)]
struct WorkerHandle {
id: u64,
sender: mpsc::Sender<CommandJob>,
}
pub struct CommandDispatcher {
server_manager: Arc<ServerManager>,
workers: Arc<RwLock<HashMap<String, WorkerHandle>>>,
next_worker_id: Arc<AtomicU64>,
queue_capacity: usize,
}
impl CommandDispatcher {
pub fn new(server_manager: Arc<ServerManager>) -> Self {
Self {
server_manager,
workers: Arc::new(RwLock::new(HashMap::new())),
next_worker_id: Arc::new(AtomicU64::new(1)),
queue_capacity: DEFAULT_QUEUE_CAPACITY,
}
}
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
let cmd = command.trim();
if cmd.is_empty() {
return Err(anyhow!("Command cannot be empty"));
}
// Retry once if the current worker channel is unexpectedly closed.
for _ in 0..2 {
let worker = self.get_or_create_worker(server_uuid).await;
let (response_tx, response_rx) = oneshot::channel();
let job = CommandJob {
command: cmd.to_string(),
response_tx,
};
match worker.sender.send(job).await {
Ok(_) => {
return response_rx
.await
.unwrap_or_else(|_| Err(anyhow!("Command worker dropped response channel")));
}
Err(send_err) => {
warn!(
server_uuid = %server_uuid,
worker_id = worker.id,
error = %send_err,
"Command worker queue send failed, rotating worker",
);
self.remove_worker_if_matches(server_uuid, worker.id).await;
}
}
}
Err(anyhow!("Failed to dispatch command after retry"))
}
async fn get_or_create_worker(&self, server_uuid: &str) -> WorkerHandle {
if let Some(existing) = self.workers.read().await.get(server_uuid).cloned() {
return existing;
}
let worker_id = self.next_worker_id.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = mpsc::channel::<CommandJob>(self.queue_capacity);
let handle = WorkerHandle {
id: worker_id,
sender: sender.clone(),
};
{
let mut workers = self.workers.write().await;
if let Some(existing) = workers.get(server_uuid).cloned() {
return existing;
}
workers.insert(server_uuid.to_string(), handle.clone());
}
self.spawn_worker(server_uuid.to_string(), worker_id, receiver);
handle
}
fn spawn_worker(
&self,
server_uuid: String,
worker_id: u64,
mut receiver: mpsc::Receiver<CommandJob>,
) {
let server_manager = self.server_manager.clone();
let workers = self.workers.clone();
tokio::spawn(async move {
debug!(server_uuid = %server_uuid, worker_id, "Command worker started");
while let Some(job) = receiver.recv().await {
let result = execute_command(server_manager.clone(), &server_uuid, &job.command).await;
let _ = job.response_tx.send(result);
}
let mut map = workers.write().await;
if let Some(current) = map.get(&server_uuid) {
if current.id == worker_id {
map.remove(&server_uuid);
}
}
debug!(server_uuid = %server_uuid, worker_id, "Command worker stopped");
});
}
async fn remove_worker_if_matches(&self, server_uuid: &str, worker_id: u64) {
let mut workers = self.workers.write().await;
if let Some(current) = workers.get(server_uuid) {
if current.id == worker_id {
workers.remove(server_uuid);
}
}
}
}
async fn execute_command(
server_manager: Arc<ServerManager>,
server_uuid: &str,
command: &str,
) -> Result<()> {
server_manager
.docker()
.send_command(server_uuid, command)
.await?;
Ok(())
}
+39 -1
View File
@@ -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())
}
}
+780 -26
View File
@@ -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()
},
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();
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})"
)
.await?;
});
}
}
}
self.client()
.start_exec(&exec.id, None::<bollard::exec::StartExecOptions>)
.await?;
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})")
}),
}
}
}
+100 -5
View File
@@ -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
+106 -2
View File
@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::debug;
@@ -94,14 +94,24 @@ impl FileSystem {
/// Write file contents.
pub async fn write_file(&self, path: &str, data: &[u8]) -> Result<(), DaemonError> {
let resolved = self.resolve(path)?;
let owner = resolved
.parent()
.and_then(resolve_target_ownership);
// Ensure parent directory exists
if let Some(parent) = resolved.parent() {
fs::create_dir_all(parent).await.map_err(DaemonError::Io)?;
if let Some(ref owner) = owner {
apply_ownership_to_path_chain(parent, &owner)?;
}
}
debug!(path = %resolved.display(), "Writing file");
fs::write(&resolved, data).await.map_err(DaemonError::Io)
fs::write(&resolved, data).await.map_err(DaemonError::Io)?;
if let Some(ref owner) = owner {
apply_ownership(&resolved, owner.uid, owner.gid)?;
}
Ok(())
}
/// Delete files or directories.
@@ -119,6 +129,100 @@ impl FileSystem {
}
}
#[derive(Clone, Debug)]
struct OwnershipTarget {
anchor: PathBuf,
uid: u32,
gid: u32,
}
#[cfg(unix)]
fn resolve_target_ownership(start: &Path) -> Option<OwnershipTarget> {
use std::os::unix::fs::MetadataExt;
let mut cursor = Some(start);
let mut fallback: Option<OwnershipTarget> = None;
while let Some(path) = cursor {
if let Ok(metadata) = std::fs::metadata(path) {
let candidate = OwnershipTarget {
anchor: path.to_path_buf(),
uid: metadata.uid(),
gid: metadata.gid(),
};
if fallback.is_none() {
fallback = Some(candidate.clone());
}
if candidate.uid != 0 || candidate.gid != 0 {
return Some(candidate);
}
}
cursor = path.parent();
}
fallback
}
#[cfg(not(unix))]
fn resolve_target_ownership(_start: &Path) -> Option<OwnershipTarget> {
None
}
#[cfg(unix)]
fn apply_ownership_to_path_chain(target: &Path, owner: &OwnershipTarget) -> Result<(), DaemonError> {
if !target.starts_with(&owner.anchor) {
return Ok(());
}
let mut current = owner.anchor.clone();
apply_ownership(&current, owner.uid, owner.gid)?;
let remainder = match target.strip_prefix(&owner.anchor) {
Ok(path) => path,
Err(_) => return Ok(()),
};
for component in remainder.components() {
current.push(component.as_os_str());
apply_ownership(&current, owner.uid, owner.gid)?;
}
Ok(())
}
#[cfg(not(unix))]
fn apply_ownership_to_path_chain(_target: &Path, _owner: &OwnershipTarget) -> Result<(), DaemonError> {
Ok(())
}
#[cfg(unix)]
fn apply_ownership(path: &Path, uid: u32, gid: u32) -> Result<(), DaemonError> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let bytes = path.as_os_str().as_bytes();
let c_path = CString::new(bytes).map_err(|err| {
DaemonError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid path for chown: {err}"),
))
})?;
let result = unsafe { libc::chown(c_path.as_ptr(), uid, gid) };
if result != 0 {
return Err(DaemonError::Io(std::io::Error::last_os_error()));
}
Ok(())
}
#[cfg(not(unix))]
fn apply_ownership(_path: &Path, _uid: u32, _gid: u32) -> Result<(), DaemonError> {
Ok(())
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub name: String,
+80
View File
@@ -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());
}
}
+89 -20
View File
@@ -34,36 +34,28 @@ fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
for line in response.lines() {
let trimmed = line.trim();
// Parse max players from "players : X humans, Y bots (Z/M max)"
if trimmed.starts_with("players") && trimmed.contains("max") {
if let Some(max_str) = trimmed.split('/').last() {
if let Some(num) = max_str.split_whitespace().next() {
max_players = num.parse().unwrap_or(0);
}
// Parse max players from status line variants:
// "players : X humans, Y bots (Z/M max)"
// "players : X humans, Y bots (Z max)"
if trimmed.starts_with("players") {
if let Some(parsed_max) = parse_max_players_from_line(trimmed) {
max_players = parsed_max;
}
}
// Player table header: starts with #
if trimmed.starts_with("# userid") {
if trimmed.contains("---------players--------") || trimmed.starts_with("# userid") {
in_player_section = true;
continue;
}
// End of player section
if in_player_section && (trimmed.is_empty() || trimmed.starts_with('#')) {
if trimmed.is_empty() {
if in_player_section && (trimmed == "#end" || trimmed.starts_with("---------")) {
in_player_section = false;
continue;
}
}
// Parse player lines: "# userid name steamid ..."
if in_player_section && trimmed.starts_with('#') {
let parts: Vec<&str> = trimmed.splitn(6, char::is_whitespace).collect();
if parts.len() >= 4 {
let name = parts.get(2).unwrap_or(&"").trim_matches('"').to_string();
let steamid = parts.get(3).unwrap_or(&"").to_string();
// Parse player lines for both old and current CS2 status formats.
if in_player_section {
if let Some((name, steamid)) = parse_player_line(trimmed) {
players.push(Cs2Player {
name,
steamid,
@@ -77,6 +69,62 @@ fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
(players, max_players)
}
fn parse_max_players_from_line(line: &str) -> Option<u32> {
let start = line.find('(')?;
let end = line[start + 1..].find(')')? + start + 1;
let inside = &line[start + 1..end];
inside
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<u32>().ok())
.max()
}
fn parse_player_line(line: &str) -> Option<(String, String)> {
// Skip table/header rows.
if line.is_empty()
|| line.starts_with("id ")
|| line.contains("userid")
|| line.contains("steamid")
|| line.contains("adr name")
{
return None;
}
// Legacy format: # 2 "Player" STEAM_...
if let Some(quote_start) = line.find('"') {
let quote_end = line[quote_start + 1..].find('"')? + quote_start + 1;
let name = line[quote_start + 1..quote_end].trim().to_string();
if name.is_empty() {
return None;
}
let rest = line[quote_end + 1..].trim();
let steamid = rest.split_whitespace().next()?.to_string();
if steamid.is_empty() {
return None;
}
return Some((name, steamid));
}
// Current CS2 format: ... 'PlayerName'
let quote_end = line.rfind('\'')?;
let before_end = &line[..quote_end];
let quote_start = before_end.rfind('\'')?;
if quote_start >= quote_end {
return None;
}
let name = line[quote_start + 1..quote_end].trim().to_string();
if name.is_empty() {
return None;
}
// New status output does not include steamid in player rows.
Some((name, String::new()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -91,7 +139,28 @@ players : 2 humans, 0 bots (16/0 max) (not hibernating)
# 3 "Player2" STEAM_1:0:67890 00:10 30 0 active 128000
"#;
let (players, max) = parse_status_response(response);
assert_eq!(max, 0); // simplified parser
assert_eq!(max, 16);
assert_eq!(players.len(), 2);
}
#[test]
fn test_parse_status_current_cs2_format() {
let response = r#"Server: Running [0.0.0.0:27015]
players : 1 humans, 2 bots (0 max) (not hibernating) (unreserved)
---------players--------
id time ping loss state rate adr name
65535 [NoChan] 0 0 challenging 0unknown ''
1 BOT 0 0 active 0 'Rezan'
2 00:21 11 0 active 786432 212.154.6.153:57008 'hibna'
3 BOT 0 0 active 0 'Squad'
#end
"#;
let (players, max) = parse_status_response(response);
assert_eq!(max, 0);
assert_eq!(players.len(), 3);
assert_eq!(players[0].name, "Rezan");
assert_eq!(players[1].name, "hibna");
assert_eq!(players[2].name, "Squad");
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod rcon;
pub mod minecraft;
pub mod cs2;
pub mod ark;
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
set -e
NUMCHECK='^[0-9]+$'
MSGWARNING="\033[0;33mWARNING:\033[0m"
if ! [[ "$SERVERGAMEPORT" =~ $NUMCHECK ]]; then
printf "Invalid server port given: %s\n" "$SERVERGAMEPORT"
SERVERGAMEPORT="7777"
fi
printf "Setting server port to %s\n" "$SERVERGAMEPORT"
if ! [[ "$SERVERMESSAGINGPORT" =~ $NUMCHECK ]]; then
printf "Invalid messaging port given: %s\n" "$SERVERMESSAGINGPORT"
SERVERMESSAGINGPORT="8888"
fi
printf "Setting messaging port to %s\n" "$SERVERMESSAGINGPORT"
if ! [[ "$AUTOSAVENUM" =~ $NUMCHECK ]]; then
printf "Invalid autosave number given: %s\n" "$AUTOSAVENUM"
AUTOSAVENUM="5"
fi
printf "Setting autosave number to %s\n" "$AUTOSAVENUM"
if ! [[ "$MAXOBJECTS" =~ $NUMCHECK ]]; then
printf "Invalid max objects number given: %s\n" "$MAXOBJECTS"
MAXOBJECTS="2162688"
fi
printf "Setting max objects to %s\n" "$MAXOBJECTS"
if ! [[ "$MAXTICKRATE" =~ $NUMCHECK ]]; then
printf "Invalid max tick rate number given: %s\n" "$MAXTICKRATE"
MAXTICKRATE="30"
fi
printf "Setting max tick rate to %s\n" "$MAXTICKRATE"
[[ "${SERVERSTREAMING,,}" == "true" ]] && SERVERSTREAMING="1" || SERVERSTREAMING="0"
printf "Setting server streaming to %s\n" "$SERVERSTREAMING"
if ! [[ "$TIMEOUT" =~ $NUMCHECK ]]; then
printf "Invalid timeout number given: %s\n" "$TIMEOUT"
TIMEOUT="30"
fi
printf "Setting timeout to %s\n" "$TIMEOUT"
if ! [[ "$MAXPLAYERS" =~ $NUMCHECK ]]; then
printf "Invalid max players given: %s\n" "$MAXPLAYERS"
MAXPLAYERS="4"
fi
printf "Setting max players to %s\n" "$MAXPLAYERS"
if [[ "${DISABLESEASONALEVENTS,,}" == "true" ]]; then
printf "Disabling seasonal events\n"
DISABLESEASONALEVENTS="-DisableSeasonalEvents"
else
DISABLESEASONALEVENTS=""
fi
if [[ "$MULTIHOME" != "" ]]; then
if [[ "$MULTIHOME" == "::" ]]; then
printf "Multihome will accept IPv4 and IPv6 connections\n"
fi
printf "Setting multihome to %s\n" "$MULTIHOME"
MULTIHOME="-multihome=$MULTIHOME"
fi
ini_args=(
"-ini:Engine:[/Script/FactoryGame.FGSaveSession]:mNumRotatingAutosaves=$AUTOSAVENUM"
"-ini:Engine:[/Script/Engine.GarbageCollectionSettings]:gc.MaxObjectsInEditor=$MAXOBJECTS"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:LanServerMaxTickRate=$MAXTICKRATE"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:NetServerMaxTickRate=$MAXTICKRATE"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:ConnectionTimeout=$TIMEOUT"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:InitialConnectTimeout=$TIMEOUT"
"-ini:Engine:[ConsoleVariables]:wp.Runtime.EnableServerStreaming=$SERVERSTREAMING"
"-ini:Game:[/Script/Engine.GameSession]:ConnectionTimeout=$TIMEOUT"
"-ini:Game:[/Script/Engine.GameSession]:InitialConnectTimeout=$TIMEOUT"
"-ini:Game:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
"-ini:GameUserSettings:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
"$DISABLESEASONALEVENTS"
"$MULTIHOME"
)
if [[ "${SKIPUPDATE,,}" != "false" ]] && [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
printf "%s Skip update is set, but no game files exist. Updating anyway\n" "$MSGWARNING"
SKIPUPDATE="false"
fi
if [[ "${SKIPUPDATE,,}" != "true" ]]; then
STEAMBETAPASSWORD=""
if [[ -n "${STEAMBETAID}" ]]; then
printf "STEAMBETAID is set. Using beta ID: %s\n" "$STEAMBETAID"
STEAMBETAFLAG="$STEAMBETAID"
if [[ -n "${STEAMBETAKEY}" ]]; then
STEAMBETAPASSWORD="-betapassword $STEAMBETAKEY"
printf "Beta password provided\n"
fi
elif [[ "${STEAMBETA,,}" == "true" ]]; then
printf "Experimental flag is set. Experimental will be downloaded instead of Early Access.\n"
STEAMBETAFLAG="experimental"
else
STEAMBETAFLAG=""
fi
STORAGEAVAILABLE=$(stat -f -c "%a*%S" .)
STORAGEAVAILABLE=$((STORAGEAVAILABLE/1024/1024/1024))
printf "Checking available storage: %sGB detected\n" "$STORAGEAVAILABLE"
if [[ "$STORAGEAVAILABLE" -lt 8 ]]; then
printf "You have less than 8GB (%sGB detected) of available storage to download the game.\nIf this is a fresh install, it will probably fail.\n" "$STORAGEAVAILABLE"
fi
printf "\nDownloading the latest version of the game...\n"
if [ -f "/config/gamefiles/steamapps/appmanifest_1690800.acf" ]; then
printf "\nRemoving the app manifest to force Steam to check for an update...\n"
rm "/config/gamefiles/steamapps/appmanifest_1690800.acf" || true
fi
if [[ -n "$STEAMBETAFLAG" ]]; then
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" -beta "$STEAMBETAFLAG" $STEAMBETAPASSWORD validate +quit
else
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" validate +quit
fi
cp -r /home/steam/.steam/steam/logs/* "/config/logs/steam" || printf "Failed to store Steam logs\n"
else
printf "Skipping update as flag is set\n"
fi
printf "Launching game server\n\n"
cp -r "/config/saved/server/." "/config/backups/" 2>/dev/null || true
cp -r "${GAMESAVESDIR}/server/." "/config/backups" 2>/dev/null || true
rm -rf "$GAMESAVESDIR"
ln -sf "/config/saved" "$GAMESAVESDIR"
if [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
printf "FactoryServer launch script is missing.\n"
exit 1
fi
cd /config/gamefiles || exit 1
chmod +x FactoryServer.sh || true
./FactoryServer.sh -Port="$SERVERGAMEPORT" -ReliablePort="$SERVERMESSAGINGPORT" -ExternalReliablePort="$SERVERMESSAGINGPORT" "${ini_args[@]}" "$@" &
sleep 2
satisfactory_pid=$(ps --ppid ${!} o pid=)
shutdown() {
printf "\nReceived SIGINT. Shutting down.\n"
kill -INT $satisfactory_pid 2>/dev/null
}
trap shutdown SIGINT SIGTERM
wait
+635 -41
View File
@@ -1,14 +1,23 @@
use std::pin::Pin;
use std::sync::Arc;
use std::time::Instant;
use std::collections::HashMap;
#[cfg(unix)]
use std::ffi::CString;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use futures::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status};
use tracing::{info, error};
use tracing::{info, error, warn};
use crate::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)
}
+41 -2
View File
@@ -6,22 +6,42 @@ 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(
@@ -36,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
@@ -64,6 +98,7 @@ async fn main() -> Result<()> {
// Scheduler task
let sched = Arc::new(scheduler::Scheduler::new(
server_manager.clone(),
command_dispatcher.clone(),
config.api_url.clone(),
config.node_token.clone(),
));
@@ -73,8 +108,12 @@ async fn main() -> Result<()> {
info!("Scheduler initialized");
// Start serving
let daemon_service = DaemonServiceServer::new(daemon_service)
.max_decoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES)
.max_encoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES);
Server::builder()
.add_service(DaemonServiceServer::new(daemon_service))
.add_service(daemon_service)
.serve_with_shutdown(addr, async {
tokio::signal::ctrl_c().await.ok();
info!("Shutdown signal received");
+463
View File
@@ -0,0 +1,463 @@
use std::io::ErrorKind;
use std::process::Stdio;
use reqwest::Url;
use thiserror::Error;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tonic::Status;
use uuid::Uuid;
use crate::config::ManagedMysqlConfig;
#[derive(Debug, Clone)]
struct ManagedMysqlRuntimeConfig {
admin_database: String,
admin_host: String,
admin_password: String,
admin_port: u16,
admin_username: String,
client_bin: Option<String>,
connection_host: String,
connection_port: u16,
phpmyadmin_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ManagedMysqlDatabase {
pub database_name: String,
pub username: String,
pub password: String,
pub host: String,
pub port: u16,
pub phpmyadmin_url: Option<String>,
}
#[derive(Debug, Error)]
pub enum ManagedMysqlError {
#[error("Managed MySQL is not configured on this node")]
NotConfigured,
#[error("Managed MySQL configuration is invalid: {0}")]
InvalidConfig(String),
#[error("Managed MySQL client binary is not installed on this node")]
ClientMissing,
#[error("Managed MySQL command failed: {0}")]
CommandFailed(String),
#[error("Managed MySQL I/O error: {0}")]
Io(#[from] std::io::Error),
}
impl From<ManagedMysqlError> for Status {
fn from(error: ManagedMysqlError) -> Self {
match error {
ManagedMysqlError::NotConfigured | ManagedMysqlError::ClientMissing => {
Status::failed_precondition(error.to_string())
}
ManagedMysqlError::InvalidConfig(_) => Status::internal(error.to_string()),
ManagedMysqlError::CommandFailed(_) => Status::internal(error.to_string()),
ManagedMysqlError::Io(_) => Status::internal(error.to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct ManagedMysqlManager {
config: Option<ManagedMysqlRuntimeConfig>,
}
impl ManagedMysqlManager {
pub fn new(config: Option<ManagedMysqlConfig>) -> Result<Self, ManagedMysqlError> {
let runtime = match config {
Some(config) => Some(resolve_runtime_config(config)?),
None => None,
};
Ok(Self { config: runtime })
}
pub fn is_enabled(&self) -> bool {
self.config.is_some()
}
pub async fn create_database(
&self,
server_uuid: &str,
label: &str,
password: Option<&str>,
) -> Result<ManagedMysqlDatabase, ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
let label = label.trim();
if label.is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"Database name is required".to_string(),
));
}
let database_name = build_database_name(server_uuid, label);
let username = build_username(server_uuid);
let password = build_password(password);
self.run_sql(
config,
&format!(
"CREATE DATABASE {} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
escape_identifier(&database_name)
),
)
.await?;
if let Err(error) = self
.run_sql(
config,
&format!(
"CREATE USER {}@'%' IDENTIFIED BY {};GRANT ALL PRIVILEGES ON {}.* TO {}@'%'",
escape_string(&username),
escape_string(&password),
escape_identifier(&database_name),
escape_string(&username),
),
)
.await
{
let _ = self
.run_sql(
config,
&format!("DROP DATABASE IF EXISTS {}", escape_identifier(&database_name)),
)
.await;
return Err(error);
}
Ok(ManagedMysqlDatabase {
database_name: database_name.clone(),
username,
password,
host: config.connection_host.clone(),
port: config.connection_port,
phpmyadmin_url: build_phpmyadmin_url(config.phpmyadmin_url.as_deref(), &database_name),
})
}
pub async fn update_password(
&self,
username: &str,
password: &str,
) -> Result<(), ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
let password = password.trim();
if password.is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"Database password is required".to_string(),
));
}
self.run_sql(
config,
&format!(
"ALTER USER {}@'%' IDENTIFIED BY {}",
escape_string(username),
escape_string(password),
),
)
.await
}
pub async fn import_sql(
&self,
database_name: &str,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
let database_name = database_name.trim();
if database_name.is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"Database name is required".to_string(),
));
}
if sql.trim().is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"SQL payload is required".to_string(),
));
}
self.run_sql_script(config, database_name, sql).await
}
pub async fn delete_database(
&self,
database_name: &str,
username: &str,
) -> Result<(), ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
self.run_sql(
config,
&format!(
"DROP DATABASE IF EXISTS {};DROP USER IF EXISTS {}@'%'",
escape_identifier(database_name),
escape_string(username),
),
)
.await
}
async fn run_sql(
&self,
config: &ManagedMysqlRuntimeConfig,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let binaries = match config.client_bin.as_deref() {
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
_ => vec!["mariadb".to_string(), "mysql".to_string()],
};
let mut missing_binary = false;
for binary in binaries {
let output = Command::new(&binary)
.args([
"--protocol=TCP",
"--batch",
"--skip-column-names",
"-h",
&config.admin_host,
"-P",
&config.admin_port.to_string(),
"-u",
&config.admin_username,
&config.admin_database,
"-e",
sql,
])
.env("MYSQL_PWD", &config.admin_password)
.output()
.await;
match output {
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("{} exited with status {}", binary, output.status)
};
return Err(ManagedMysqlError::CommandFailed(message));
}
Err(error) if error.kind() == ErrorKind::NotFound => {
missing_binary = true;
continue;
}
Err(error) => return Err(ManagedMysqlError::Io(error)),
}
}
if missing_binary {
return Err(ManagedMysqlError::ClientMissing);
}
Err(ManagedMysqlError::ClientMissing)
}
async fn run_sql_script(
&self,
config: &ManagedMysqlRuntimeConfig,
database_name: &str,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let binaries = match config.client_bin.as_deref() {
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
_ => vec!["mariadb".to_string(), "mysql".to_string()],
};
let mut missing_binary = false;
for binary in binaries {
let child = Command::new(&binary)
.args([
"--protocol=TCP",
"--batch",
"--skip-column-names",
"-h",
&config.admin_host,
"-P",
&config.admin_port.to_string(),
"-u",
&config.admin_username,
database_name,
])
.env("MYSQL_PWD", &config.admin_password)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
match child {
Ok(mut child) => {
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(sql.as_bytes()).await?;
}
let output = child.wait_with_output().await?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("{} exited with status {}", binary, output.status)
};
return Err(ManagedMysqlError::CommandFailed(message));
}
Err(error) if error.kind() == ErrorKind::NotFound => {
missing_binary = true;
continue;
}
Err(error) => return Err(ManagedMysqlError::Io(error)),
}
}
if missing_binary {
return Err(ManagedMysqlError::ClientMissing);
}
Err(ManagedMysqlError::ClientMissing)
}
}
fn resolve_runtime_config(
config: ManagedMysqlConfig,
) -> Result<ManagedMysqlRuntimeConfig, ManagedMysqlError> {
let parsed = Url::parse(&config.url)
.map_err(|error| ManagedMysqlError::InvalidConfig(error.to_string()))?;
if parsed.scheme() != "mysql" && parsed.scheme() != "mariadb" {
return Err(ManagedMysqlError::InvalidConfig(
"url must use mysql:// or mariadb://".to_string(),
));
}
let admin_host = parsed.host_str().unwrap_or_default().trim().to_string();
let admin_username = parsed.username().trim().to_string();
if admin_host.is_empty() || admin_username.is_empty() {
return Err(ManagedMysqlError::InvalidConfig(
"url must include host and username".to_string(),
));
}
let admin_database = {
let trimmed = parsed.path().trim_start_matches('/').trim();
if trimmed.is_empty() {
"mysql".to_string()
} else {
trimmed.to_string()
}
};
Ok(ManagedMysqlRuntimeConfig {
admin_database,
admin_host: admin_host.clone(),
admin_password: parsed.password().unwrap_or_default().to_string(),
admin_port: parsed.port().unwrap_or(3306),
admin_username,
client_bin: config.bin,
connection_host: config.connection_host.unwrap_or(admin_host),
connection_port: config.connection_port.unwrap_or(parsed.port().unwrap_or(3306)),
phpmyadmin_url: config.phpmyadmin_url,
})
}
fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String {
let mut normalized = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
normalized.push(ch.to_ascii_lowercase());
} else if !normalized.ends_with('_') {
normalized.push('_');
}
}
let trimmed = normalized.trim_matches('_');
if trimmed.is_empty() {
return fallback.to_string();
}
trimmed
.chars()
.take(max_len)
.collect::<String>()
.trim_end_matches('_')
.to_string()
}
fn build_database_name(server_uuid: &str, label: &str) -> String {
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 12);
let label_token = normalize_token(label, "db", 16);
let suffix = Uuid::new_v4().simple().to_string();
format!("srv_{}_{}_{}", server_token, label_token, &suffix[..8])
.chars()
.take(64)
.collect::<String>()
.trim_end_matches('_')
.to_string()
}
fn build_username(server_uuid: &str) -> String {
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 8);
let suffix = Uuid::new_v4().simple().to_string();
format!("u_{}_{}", server_token, &suffix[..8])
.chars()
.take(32)
.collect::<String>()
.trim_end_matches('_')
.to_string()
}
fn build_password(password: Option<&str>) -> String {
match password {
Some(password) if !password.trim().is_empty() => password.trim().to_string(),
_ => {
let first = Uuid::new_v4().simple().to_string();
let second = Uuid::new_v4().simple().to_string();
format!("{}{}", first, second)
}
}
}
fn escape_identifier(value: &str) -> String {
format!("`{}`", value.replace('`', "``"))
}
fn escape_string(value: &str) -> String {
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''"))
}
fn build_phpmyadmin_url(base_url: Option<&str>, database_name: &str) -> Option<String> {
let base_url = base_url?.trim();
if base_url.is_empty() {
return None;
}
match Url::parse(base_url) {
Ok(mut url) => {
url.query_pairs_mut().append_pair("db", database_name);
Some(url.to_string())
}
Err(_) => Some(base_url.to_string()),
}
}
+7 -5
View File
@@ -4,6 +4,7 @@ use tokio::time::{interval, Duration};
use tracing::{info, error, warn};
use serde::Deserialize;
use crate::command::CommandDispatcher;
use crate::server::ServerManager;
/// A scheduled task received from the panel API.
@@ -21,6 +22,7 @@ pub struct ScheduledTask {
/// Scheduler that polls the panel API for due tasks and executes them.
pub struct Scheduler {
server_manager: Arc<ServerManager>,
command_dispatcher: Arc<CommandDispatcher>,
api_url: String,
node_token: String,
poll_interval_secs: u64,
@@ -29,11 +31,13 @@ pub struct Scheduler {
impl Scheduler {
pub fn new(
server_manager: Arc<ServerManager>,
command_dispatcher: Arc<CommandDispatcher>,
api_url: String,
node_token: String,
) -> Self {
Self {
server_manager,
command_dispatcher,
api_url,
node_token,
poll_interval_secs: 15,
@@ -117,18 +121,16 @@ impl Scheduler {
match task.action.as_str() {
"command" => {
// Send command to server's stdin via Docker exec
let docker = self.server_manager.docker();
docker
self.command_dispatcher
.send_command(&task.server_uuid, &task.payload)
.await?;
}
"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).await?,
"stop" => self.server_manager.stop_server(&task.server_uuid, None, 0).await?,
"restart" => {
let _ = self.server_manager.stop_server(&task.server_uuid).await;
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?;
}
+262 -25
View File
@@ -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,58 +307,118 @@ impl ServerManager {
/// Start a server.
pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> {
let mut managed = false;
let mut previous_state: Option<ServerState> = None;
{
let mut servers = self.servers.write().await;
let spec = servers
.get_mut(uuid)
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
if let Some(spec) = servers.get_mut(uuid) {
// Recover from stale transitional state left by a previous failed start attempt.
if spec.state == ServerState::Starting {
warn!(uuid = %uuid, "Recovering stale starting state");
spec.state = ServerState::Stopped;
}
if !spec.can_transition_to(&ServerState::Starting) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "starting".to_string(),
});
}
previous_state = Some(spec.state.clone());
spec.state = ServerState::Starting;
drop(servers);
managed = true;
}
}
self.docker.start_container(uuid).await.map_err(|e| {
DaemonError::Internal(format!("Failed to start container: {}", e))
})?;
if let Err(e) = self.docker.start_container(uuid).await {
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = previous_state.unwrap_or(ServerState::Error);
}
}
return Err(DaemonError::Internal(format!("Failed to start container: {}", e)));
}
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Running;
}
} else {
info!(uuid = %uuid, "Started container without managed runtime state");
}
Ok(())
}
/// Stop a server.
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> {
///
/// `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;
let spec = servers
.get_mut(uuid)
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?;
if let Some(spec) = servers.get_mut(uuid) {
// Recover from stale transitional state left by a previous failed stop attempt.
if spec.state == ServerState::Stopping {
warn!(uuid = %uuid, "Recovering stale stopping state");
spec.state = ServerState::Running;
}
if !spec.can_transition_to(&ServerState::Stopping) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "stopping".to_string(),
});
}
previous_state = Some(spec.state.clone());
spec_runtime = spec.runtime.clone();
spec.state = ServerState::Stopping;
drop(servers);
managed = true;
}
}
self.docker.stop_container(uuid, 30).await.map_err(|e| {
DaemonError::Internal(format!("Failed to stop container: {}", e))
})?;
let effective_command = stop_command
.map(str::trim)
.filter(|command| !command.is_empty())
.map(str::to_string)
.or_else(|| spec_runtime.stop_command.clone());
let effective_timeout = if stop_timeout_seconds > 0 {
stop_timeout_seconds
} else {
spec_runtime.stop_timeout_seconds.unwrap_or(0)
};
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 -1
View File
@@ -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;
+42
View File
@@ -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 {
+37
View File
@@ -0,0 +1,37 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# --- Dependencies ---
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
COPY packages/ui/package.json packages/ui/
RUN pnpm install --frozen-lockfile --prod=false
# --- Build ---
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
COPY . .
ARG VITE_API_URL=/api
ENV VITE_API_URL=${VITE_API_URL}
RUN pnpm --filter @source/shared build && \
pnpm --filter @source/ui build && \
pnpm --filter @source/web build
# --- Production (nginx) ---
FROM nginx:alpine AS production
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost/health || exit 1
CMD ["nginx", "-g", "daemon off;"]
+67
View File
@@ -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;
}
}
+16 -1
View File
@@ -4,6 +4,7 @@ import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router';
import { Toaster } from 'sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { useAuthStore } from '@/stores/auth';
import { ErrorBoundary } from '@/components/error-boundary';
// Layouts
import { AppLayout } from '@/components/layout/app-layout';
@@ -16,6 +17,7 @@ import { RegisterPage } from '@/pages/auth/register';
// App pages
import { OrganizationsPage } from '@/pages/organizations/index';
import { DashboardPage } from '@/pages/dashboard/index';
import { ServersPage } from '@/pages/servers/index';
import { CreateServerPage } from '@/pages/servers/create';
import { NodesPage } from '@/pages/nodes/index';
import { NodeDetailPage } from '@/pages/nodes/detail';
@@ -29,12 +31,16 @@ import { SchedulesPage } from '@/pages/server/schedules';
import { ConfigPage } from '@/pages/server/config';
import { PluginsPage } from '@/pages/server/plugins';
import { PlayersPage } from '@/pages/server/players';
import { DatabasesPage } from '@/pages/server/databases';
import { ServerSettingsPage } from '@/pages/server/settings';
// Admin pages
import { AdminUsersPage } from '@/pages/admin/users';
import { AdminGamesPage } from '@/pages/admin/games';
import { AdminPluginsPage } from '@/pages/admin/plugins';
import { AdminNodesPage } from '@/pages/admin/nodes';
import { AdminAuditLogsPage } from '@/pages/admin/audit-logs';
import { AccountSecurityPage } from '@/pages/account/security';
const queryClient = new QueryClient({
defaultOptions: {
@@ -69,6 +75,7 @@ function AuthGuard() {
export function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<BrowserRouter>
@@ -85,17 +92,23 @@ export function App() {
{/* Org-scoped routes */}
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
<Route path="/org/:orgId/servers" element={<ServersPage />} />
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
<Route path="/org/:orgId/settings" element={<Navigate to="members" replace />} />
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
{/* Account */}
<Route path="/account/security" element={<AccountSecurityPage />} />
{/* Server detail */}
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
<Route index element={<Navigate to="console" replace />} />
<Route path="console" element={<ConsolePage />} />
<Route path="files" element={<FilesPage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="databases" element={<DatabasesPage />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="backups" element={<BackupsPage />} />
<Route path="schedules" element={<SchedulesPage />} />
@@ -106,7 +119,8 @@ export function App() {
{/* Admin */}
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/games" element={<AdminGamesPage />} />
<Route path="/admin/nodes" element={<NodesPage />} />
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
<Route path="/admin/nodes" element={<AdminNodesPage />} />
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
</Route>
</Route>
@@ -118,5 +132,6 @@ export function App() {
<Toaster position="bottom-right" richColors />
</TooltipProvider>
</QueryClientProvider>
</ErrorBoundary>
);
}
@@ -0,0 +1,70 @@
import { Component, type ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4 p-8">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-8 w-8 text-destructive" />
</div>
<div className="text-center">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
</div>
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<RefreshCw className="h-4 w-4" />
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium hover:bg-muted"
>
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
@@ -1,6 +1,16 @@
import { Outlet, useParams, Link, useLocation } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2 } from 'lucide-react';
import {
Terminal,
FolderOpen,
Settings,
Calendar,
HardDrive,
Users,
Puzzle,
Settings2,
Database as DatabaseIcon,
} from 'lucide-react';
import { cn } from '@source/ui';
import { api } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
@@ -26,6 +36,7 @@ const tabs = [
{ label: 'Console', path: 'console', icon: Terminal },
{ label: 'Files', path: 'files', icon: FolderOpen },
{ label: 'Config', path: 'config', icon: Settings2 },
{ label: 'Databases', path: 'databases', icon: DatabaseIcon },
{ label: 'Plugins', path: 'plugins', icon: Puzzle },
{ label: 'Backups', path: 'backups', icon: HardDrive },
{ label: 'Schedules', path: 'schedules', icon: Calendar },
@@ -40,6 +51,7 @@ export function ServerLayout() {
const { data: server } = useQuery({
queryKey: ['server', orgId, serverId],
queryFn: () => api.get<ServerDetail>(`/organizations/${orgId}/servers/${serverId}`),
refetchInterval: 3_000,
});
const currentTab = location.pathname.split('/').pop();
@@ -50,9 +62,7 @@ export function ServerLayout() {
<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>
)}
{server && <Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>}
</div>
{server && (
<p className="mt-1 text-sm text-muted-foreground">
+4 -4
View File
@@ -7,6 +7,7 @@ import {
Users,
Shield,
Gamepad2,
Puzzle,
ScrollText,
ChevronLeft,
} from 'lucide-react';
@@ -32,8 +33,7 @@ export function Sidebar() {
{ label: 'Dashboard', href: `/org/${orgId}/dashboard`, icon: LayoutDashboard },
{ label: 'Servers', href: `/org/${orgId}/servers`, icon: Server },
{ label: 'Nodes', href: `/org/${orgId}/nodes`, icon: Network },
{ label: 'Members', href: `/org/${orgId}/settings/members`, icon: Users },
{ label: 'Settings', href: `/org/${orgId}/settings`, icon: Settings },
{ label: 'Settings', href: `/org/${orgId}/settings/members`, icon: Settings },
]
: [];
@@ -41,6 +41,7 @@ export function Sidebar() {
? [
{ label: 'Users', href: '/admin/users', icon: Users },
{ label: 'Games', href: '/admin/games', icon: Gamepad2 },
{ label: 'Plugins', href: '/admin/plugins', icon: Puzzle },
{ label: 'Nodes', href: '/admin/nodes', icon: Network },
{ label: 'Audit Logs', href: '/admin/audit-logs', icon: ScrollText },
]
@@ -95,8 +96,7 @@ function NavSection({ items, currentPath }: { items: NavItem[]; currentPath: str
return (
<nav className="flex flex-col gap-1">
{items.map((item) => {
const isActive =
currentPath === item.href || currentPath.startsWith(item.href + '/');
const isActive = currentPath === item.href || currentPath.startsWith(item.href + '/');
return (
<Link key={item.href} to={item.href}>
<Button
@@ -19,14 +19,38 @@ interface PowerControlsProps {
status: string;
}
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
interface CachedServerDetail {
status: string;
[key: string]: unknown;
}
export function PowerControls({ serverId, orgId, status }: PowerControlsProps) {
const queryClient = useQueryClient();
const serverQueryKey = ['server', orgId, serverId] as const;
const powerMutation = useMutation({
mutationFn: (action: string) =>
mutationFn: (action: PowerAction) =>
api.post(`/organizations/${orgId}/servers/${serverId}/power`, { action }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] });
onMutate: (action) => {
const nextStatusByAction: Record<PowerAction, string> = {
start: 'starting',
stop: 'stopping',
restart: 'stopping',
kill: 'stopped',
};
queryClient.setQueryData<CachedServerDetail | undefined>(serverQueryKey, (current) => {
if (!current) return current;
return {
...current,
status: nextStatusByAction[action],
};
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: serverQueryKey });
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
},
});
@@ -69,11 +93,7 @@ export function PowerControls({ serverId, orgId, status }: PowerControlsProps) {
<Dialog>
<DialogTrigger asChild>
<Button
size="sm"
variant="destructive"
disabled={isTransitioning && !isRunning}
>
<Button size="sm" variant="destructive" disabled={isTransitioning && !isRunning}>
<Skull className="h-4 w-4" />
Kill
</Button>
+1 -2
View File
@@ -18,8 +18,7 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
+6 -4
View File
@@ -10,7 +10,8 @@ const buttonVariants = cva(
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',
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',
@@ -30,15 +31,16 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
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} />;
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
+13 -3
View File
@@ -3,7 +3,11 @@ 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} />
<div
ref={ref}
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
@@ -17,7 +21,11 @@ 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} />
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
@@ -30,7 +38,9 @@ const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
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} />,
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
+9 -2
View File
@@ -53,7 +53,10 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
);
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} />
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
const DialogTitle = React.forwardRef<
@@ -72,7 +75,11 @@ 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} />
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
+5 -1
View File
@@ -44,7 +44,11 @@ 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} />
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+5 -1
View File
@@ -6,7 +6,11 @@ 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.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
+16
View File
@@ -49,7 +49,23 @@
* {
@apply border-border;
}
html {
min-height: 100%;
}
body {
@apply bg-background text-foreground;
min-height: 100vh;
background-image:
radial-gradient(circle at 0% 0%, hsl(var(--primary) / 0.18), transparent 34%),
radial-gradient(circle at 88% 10%, hsl(var(--ring) / 0.12), transparent 28%),
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.72) 100%);
background-attachment: fixed;
background-repeat: no-repeat;
}
#root {
min-height: 100vh;
}
}
+48 -10
View File
@@ -1,9 +1,29 @@
const API_BASE = '/api';
const RAW_API_BASE = (
(import.meta.env.VITE_API_URL as string | undefined) ??
(import.meta.env.VITE_API_BASE_URL as string | undefined) ??
'/api'
).trim();
const API_BASE = (RAW_API_BASE || '/api').replace(/\/+$/, '');
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
}
function toRequestBody(body: unknown): BodyInit | undefined {
if (body === undefined || body === null) return undefined;
if (
body instanceof FormData ||
body instanceof Blob ||
body instanceof URLSearchParams ||
body instanceof ArrayBuffer
) {
return body;
}
return JSON.stringify(body);
}
class ApiError extends Error {
constructor(
public status: number,
@@ -36,14 +56,28 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
headers['Content-Type'] = 'application/json';
}
const res = await fetch(url, { ...fetchOptions, headers });
const res = await fetch(url, {
...fetchOptions,
credentials: fetchOptions.credentials ?? 'include',
headers,
});
if (res.status === 401) {
const shouldHandle401WithRefresh =
res.status === 401 &&
path !== '/auth/login' &&
path !== '/auth/register' &&
path !== '/auth/refresh';
if (shouldHandle401WithRefresh) {
// Try refresh
const refreshed = await refreshToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`;
const retry = await fetch(url, { ...fetchOptions, headers });
const retry = await fetch(url, {
...fetchOptions,
credentials: fetchOptions.credentials ?? 'include',
headers,
});
if (!retry.ok) throw new ApiError(retry.status, await retry.json().catch(() => null));
if (retry.status === 204) return undefined as T;
return retry.json();
@@ -77,23 +111,27 @@ async function refreshToken(): Promise<boolean> {
}
export const api = {
get: <T>(path: string, params?: Record<string, string>) =>
request<T>(path, { params }),
get: <T>(path: string, params?: Record<string, string>) => request<T>(path, { params }),
post: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
body: toRequestBody(body),
}),
put: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PUT',
body: toRequestBody(body),
}),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined,
body: toRequestBody(body),
}),
delete: <T>(path: string) =>
request<T>(path, { method: 'DELETE' }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};
export { ApiError };
+131
View File
@@ -0,0 +1,131 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Shield, Key } from 'lucide-react';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
export function AccountSecurityPage() {
const user = useAuthStore((s) => s.user);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const changePasswordMutation = useMutation({
mutationFn: (body: { currentPassword: string; newPassword: string }) =>
api.post('/auth/change-password', body),
onSuccess: () => {
toast.success('Password changed successfully');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
},
onError: () => {
toast.error('Failed to change password. Check your current password.');
},
});
const handleChangePassword = (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error('New passwords do not match');
return;
}
if (newPassword.length < 8) {
toast.error('Password must be at least 8 characters');
return;
}
changePasswordMutation.mutate({ currentPassword, newPassword });
};
return (
<div className="mx-auto max-w-2xl space-y-6">
<div className="flex items-center gap-3">
<Shield className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-bold">Account Settings</h1>
<p className="text-muted-foreground">Manage your account security</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Your account information</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Username</span>
<span className="font-medium">{user?.username}</span>
</div>
<Separator />
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Email</span>
<span className="font-medium">{user?.email}</span>
</div>
<Separator />
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Role</span>
<span className="font-medium">{user?.isSuperAdmin ? 'Super Admin' : 'User'}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<div>
<CardTitle>Change Password</CardTitle>
<CardDescription>Update your account password</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">Current Password</Label>
<Input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New Password</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</div>
<Button type="submit" disabled={changePasswordMutation.isPending}>
{changePasswordMutation.isPending ? 'Changing...' : 'Change Password'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
interface AuditLog {
id: string;
action: string;
username: string;
userName: string;
ipAddress: string | null;
metadata: Record<string, unknown>;
createdAt: string;
@@ -36,7 +36,7 @@ export function AdminAuditLogsPage() {
<div className="flex items-center gap-3">
<Badge variant="outline">{log.action}</Badge>
<span className="text-sm">
<span className="font-medium">{log.username}</span>
<span className="font-medium">{log.userName}</span>
{log.ipAddress && (
<span className="text-muted-foreground"> from {log.ipAddress}</span>
)}
+164 -9
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Gamepad2 } from 'lucide-react';
import { api } from '@/lib/api';
import { toast } from 'sonner';
import { api, ApiError } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -23,16 +24,55 @@ interface Game {
dockerImage: string;
defaultPort: number;
startupCommand: string;
automationRules: unknown[];
}
interface PaginatedResponse<T> {
data: T[];
meta: { total: number };
interface GamesResponse {
data: Game[];
}
function extractApiMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
const maybeMessage = (error.data as { message?: unknown }).message;
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
return maybeMessage;
}
}
return fallback;
}
function formatAutomationRules(value: unknown): string {
if (!Array.isArray(value)) {
return '[]';
}
try {
return JSON.stringify(value, null, 2);
} catch {
return '[]';
}
}
function parseAutomationRules(raw: string): { rules: unknown[]; error: string | null } {
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return { rules: [], error: 'Automation JSON must be an array.' };
}
return { rules: parsed, error: null };
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid JSON';
return { rules: [], error: message };
}
}
export function AdminGamesPage() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [automationOpen, setAutomationOpen] = useState(false);
const [selectedGame, setSelectedGame] = useState<Game | null>(null);
const [automationJson, setAutomationJson] = useState('[]');
const [automationError, setAutomationError] = useState<string | null>(null);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [dockerImage, setDockerImage] = useState('');
@@ -41,7 +81,7 @@ export function AdminGamesPage() {
const { data } = useQuery({
queryKey: ['admin-games'],
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
queryFn: () => api.get<GamesResponse>('/admin/games'),
});
const createMutation = useMutation({
@@ -53,11 +93,70 @@ export function AdminGamesPage() {
setSlug('');
setDockerImage('');
setStartupCommand('');
toast.success('Game created');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to create game'));
},
});
const updateAutomationMutation = useMutation({
mutationFn: ({ gameId, rules }: { gameId: string; rules: unknown[] }) =>
api.patch(`/admin/games/${gameId}`, { automationRules: rules }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-games'] });
setAutomationOpen(false);
setSelectedGame(null);
setAutomationError(null);
toast.success('Automation rules updated');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to save automation rules'));
},
});
const games = data?.data ?? [];
const openAutomationDialog = (game: Game) => {
setSelectedGame(game);
setAutomationJson(formatAutomationRules(game.automationRules));
setAutomationError(null);
setAutomationOpen(true);
};
const saveAutomationRules = () => {
if (!selectedGame) return;
const parsed = parseAutomationRules(automationJson);
if (parsed.error) {
setAutomationError(parsed.error);
return;
}
setAutomationError(null);
updateAutomationMutation.mutate({
gameId: selectedGame.id,
rules: parsed.rules,
});
};
const handleAutomationTabKey = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (event.key !== 'Tab') return;
event.preventDefault();
const textarea = event.currentTarget;
const selectionStart = textarea.selectionStart;
const selectionEnd = textarea.selectionEnd;
const nextValue = `${automationJson.slice(0, selectionStart)} ${automationJson.slice(selectionEnd)}`;
const nextCursor = selectionStart + 2;
setAutomationJson(nextValue);
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(nextCursor, nextCursor);
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -87,9 +186,7 @@ export function AdminGamesPage() {
<Label>Slug</Label>
<Input
value={slug}
onChange={(e) =>
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))
}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
required
/>
</div>
@@ -142,11 +239,69 @@ export function AdminGamesPage() {
</p>
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
<p>Port: {game.defaultPort}</p>
<p>
Automation:{' '}
{Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow
</p>
</div>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => openAutomationDialog(game)}
>
Manage Automation
</Button>
</CardContent>
</Card>
))}
</div>
<Dialog
open={automationOpen}
onOpenChange={(nextOpen) => {
setAutomationOpen(nextOpen);
if (!nextOpen) {
setSelectedGame(null);
setAutomationError(null);
}
}}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
Automation Rules
{selectedGame ? ` - ${selectedGame.name}` : ''}
</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>JSON</Label>
<p className="text-xs text-muted-foreground">
Supported events: server.created, server.install.completed, server.power.started,
server.power.stopped
</p>
<textarea
value={automationJson}
onChange={(event) => setAutomationJson(event.target.value)}
onKeyDown={handleAutomationTabKey}
spellCheck={false}
className="min-h-[320px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
{automationError && <p className="text-sm text-destructive">{automationError}</p>}
</div>
<DialogFooter>
<Button
type="button"
onClick={saveAutomationRules}
disabled={updateAutomationMutation.isPending || !selectedGame}
>
{updateAutomationMutation.isPending ? 'Saving...' : 'Save Automation'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
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>
);
}
+854
View File
@@ -0,0 +1,854 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, UploadCloud, Puzzle, Rocket, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { api, ApiError } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface Game {
id: string;
slug: string;
name: string;
}
interface GamesResponse {
data: Game[];
}
interface GlobalPlugin {
id: string;
gameId: string;
gameName: string;
gameSlug: string;
name: string;
slug: string;
description: string | null;
source: 'manual' | 'spiget';
isGlobal: boolean;
}
interface GlobalPluginsResponse {
data: GlobalPlugin[];
}
interface PluginRelease {
id: string;
pluginId: string;
version: string;
channel: 'stable' | 'beta' | 'alpha';
artifactType: 'file' | 'zip';
artifactUrl: string;
destination: string | null;
fileName: string | null;
changelog: string | null;
installSchema: unknown[];
configTemplates: unknown[];
isPublished: boolean;
createdAt: string;
updatedAt: string;
}
interface PluginReleaseResponse {
plugin: GlobalPlugin;
releases: PluginRelease[];
}
type ReleaseInputMode = 'url' | 'upload';
function extractApiMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
const maybeMessage = (error.data as { message?: unknown }).message;
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
return maybeMessage;
}
}
return fallback;
}
function prettyJson(input: unknown): string {
try {
return JSON.stringify(input, null, 2);
} catch {
return '[]';
}
}
function parseJsonArray(raw: string): unknown[] {
if (raw.trim() === '') return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
throw new Error('JSON value must be an array');
}
return parsed;
}
async function parseJsonArrayFile(file: File, label: string): Promise<unknown[]> {
let raw = await file.text();
if (raw.charCodeAt(0) === 0xfeff) {
raw = raw.slice(1);
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
throw new Error('JSON value must be an array');
}
return parsed;
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid JSON';
throw new Error(`${label}: ${message}`);
}
}
export function AdminPluginsPage() {
const queryClient = useQueryClient();
const [selectedGameId, setSelectedGameId] = useState<string>('');
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
const [createPluginOpen, setCreatePluginOpen] = useState(false);
const [createPluginName, setCreatePluginName] = useState('');
const [createPluginSlug, setCreatePluginSlug] = useState('');
const [createPluginDescription, setCreatePluginDescription] = useState('');
const [createReleaseOpen, setCreateReleaseOpen] = useState(false);
const [releaseInputMode, setReleaseInputMode] = useState<ReleaseInputMode>('upload');
const [releaseVersion, setReleaseVersion] = useState('');
const [releaseChannel, setReleaseChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
const [releaseArtifactType, setReleaseArtifactType] = useState<'file' | 'zip'>('file');
const [releaseArtifactUrl, setReleaseArtifactUrl] = useState('');
const [releaseDestination, setReleaseDestination] = useState('');
const [releaseFileName, setReleaseFileName] = useState('');
const [releaseChangelog, setReleaseChangelog] = useState('');
const [releaseInstallSchemaJson, setReleaseInstallSchemaJson] = useState('[]');
const [releaseTemplatesJson, setReleaseTemplatesJson] = useState('[]');
const [releaseInstallSchemaFile, setReleaseInstallSchemaFile] = useState<File | null>(null);
const [releaseTemplatesFile, setReleaseTemplatesFile] = useState<File | null>(null);
const [releaseInstallSchemaFileInputKey, setReleaseInstallSchemaFileInputKey] = useState(0);
const [releaseTemplatesFileInputKey, setReleaseTemplatesFileInputKey] = useState(0);
const [releaseArtifactFiles, setReleaseArtifactFiles] = useState<File[]>([]);
const { data: gamesData } = useQuery({
queryKey: ['admin-games'],
queryFn: () => api.get<GamesResponse>('/admin/games'),
});
const games = gamesData?.data ?? [];
const { data: pluginsData } = useQuery({
queryKey: ['admin-plugins', selectedGameId],
queryFn: () =>
api.get<GlobalPluginsResponse>(
'/admin/plugins',
selectedGameId ? { gameId: selectedGameId } : undefined,
),
});
const plugins = pluginsData?.data ?? [];
const selectedPlugin = useMemo(
() => plugins.find((plugin) => plugin.id === selectedPluginId) ?? null,
[plugins, selectedPluginId],
);
const { data: releaseData } = useQuery({
queryKey: ['admin-plugin-releases', selectedPluginId],
enabled: Boolean(selectedPluginId),
queryFn: () => api.get<PluginReleaseResponse>(`/admin/plugins/${selectedPluginId}/releases`),
});
const releases = releaseData?.releases ?? [];
const resetReleaseForm = () => {
setCreateReleaseOpen(false);
setReleaseInputMode('upload');
setReleaseVersion('');
setReleaseChannel('stable');
setReleaseArtifactType('file');
setReleaseArtifactUrl('');
setReleaseDestination('');
setReleaseFileName('');
setReleaseChangelog('');
setReleaseInstallSchemaJson('[]');
setReleaseTemplatesJson('[]');
setReleaseInstallSchemaFile(null);
setReleaseTemplatesFile(null);
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
setReleaseTemplatesFileInputKey((prev) => prev + 1);
setReleaseArtifactFiles([]);
};
const appendReleaseFiles = (incoming: FileList | null) => {
if (!incoming || incoming.length === 0) return;
setReleaseArtifactFiles((prev) => {
const map = new Map<string, File>();
for (const item of prev) {
const relative =
(item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
}
for (const item of Array.from(incoming)) {
const relative =
(item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
}
return Array.from(map.values());
});
};
const createPluginMutation = useMutation({
mutationFn: (body: { gameId: string; name: string; slug?: string; description?: string }) =>
api.post('/admin/plugins', body),
onSuccess: () => {
toast.success('Global plugin created');
setCreatePluginOpen(false);
setCreatePluginName('');
setCreatePluginSlug('');
setCreatePluginDescription('');
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to create plugin'));
},
});
const createReleaseMutation = useMutation({
mutationFn: (body: {
version: string;
channel: 'stable' | 'beta' | 'alpha';
artifactType: 'file' | 'zip';
artifactUrl: string;
destination?: string;
fileName?: string;
changelog?: string;
installSchema?: unknown[];
configTemplates?: unknown[];
}) => {
if (!selectedPluginId) {
throw new Error('No plugin selected');
}
return api.post(`/admin/plugins/${selectedPluginId}/releases`, body);
},
onSuccess: () => {
toast.success('Release published');
resetReleaseForm();
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to publish release'));
},
});
const createUploadReleaseMutation = useMutation({
mutationFn: (formData: FormData) => {
if (!selectedPluginId) {
throw new Error('No plugin selected');
}
return api.post(`/admin/plugins/${selectedPluginId}/releases/upload`, formData);
},
onSuccess: () => {
toast.success('Release uploaded and published');
resetReleaseForm();
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to upload release'));
},
});
const togglePublishedMutation = useMutation({
mutationFn: ({ releaseId, isPublished }: { releaseId: string; isPublished: boolean }) => {
if (!selectedPluginId) {
throw new Error('No plugin selected');
}
return api.patch(`/admin/plugins/${selectedPluginId}/releases/${releaseId}`, { isPublished });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to update release'));
},
});
const openReleaseDialogFrom = (release?: PluginRelease) => {
setReleaseVersion('');
setReleaseChannel('stable');
setReleaseArtifactType('file');
setReleaseArtifactUrl('');
setReleaseDestination('');
setReleaseFileName('');
setReleaseChangelog('');
setReleaseInstallSchemaJson('[]');
setReleaseTemplatesJson('[]');
setReleaseInstallSchemaFile(null);
setReleaseTemplatesFile(null);
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
setReleaseTemplatesFileInputKey((prev) => prev + 1);
setReleaseArtifactFiles([]);
setReleaseInputMode(release ? 'url' : 'upload');
if (release) {
setReleaseChannel(release.channel);
setReleaseArtifactType(release.artifactType);
setReleaseArtifactUrl(release.artifactUrl);
setReleaseDestination(release.destination ?? '');
setReleaseFileName(release.fileName ?? '');
setReleaseChangelog(release.changelog ?? '');
setReleaseInstallSchemaJson(prettyJson(release.installSchema));
setReleaseTemplatesJson(prettyJson(release.configTemplates));
}
setCreateReleaseOpen(true);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">Global Plugins</h1>
<p className="text-sm text-muted-foreground">
Oyun bazında global plugin tanımla, release yayınla, install ayar şemasını yönet.
</p>
</div>
<Dialog open={createPluginOpen} onOpenChange={setCreatePluginOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" /> Add Global Plugin
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Global Plugin</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!selectedGameId) {
toast.error('Select a game first');
return;
}
createPluginMutation.mutate({
gameId: selectedGameId,
name: createPluginName,
slug: createPluginSlug || undefined,
description: createPluginDescription || undefined,
});
}}
>
<div className="space-y-2">
<Label>Game</Label>
<Input
value={games.find((game) => game.id === selectedGameId)?.name ?? ''}
readOnly
placeholder="Select game from filter above"
/>
</div>
<div className="space-y-2">
<Label>Name</Label>
<Input
value={createPluginName}
onChange={(e) => setCreatePluginName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Slug (optional)</Label>
<Input
value={createPluginSlug}
onChange={(e) => setCreatePluginSlug(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input
value={createPluginDescription}
onChange={(e) => setCreatePluginDescription(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={createPluginMutation.isPending}>
{createPluginMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-2">
<Label className="min-w-20">Game Filter</Label>
<select
className="h-10 rounded-md border bg-background px-3 text-sm"
value={selectedGameId}
onChange={(e) => {
setSelectedGameId(e.target.value);
setSelectedPluginId(null);
}}
>
<option value="">All Games</option>
{games.map((game) => (
<option key={game.id} value={game.id}>
{game.name} ({game.slug})
</option>
))}
</select>
</div>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<Card>
<CardHeader>
<CardTitle>Plugins</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{plugins.length === 0 && (
<p className="text-sm text-muted-foreground">No plugins found for this filter.</p>
)}
{plugins.map((plugin) => (
<button
key={plugin.id}
type="button"
onClick={() => setSelectedPluginId(plugin.id)}
className={`w-full rounded-md border px-3 py-2 text-left transition ${
selectedPluginId === plugin.id
? 'border-primary bg-primary/5'
: 'hover:bg-muted/40'
}`}
>
<div className="flex items-center gap-2">
<Puzzle className="h-4 w-4 text-primary" />
<span className="font-medium">{plugin.name}</span>
<Badge variant="outline">{plugin.gameSlug}</Badge>
<Badge variant="secondary">{plugin.source}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">{plugin.slug}</p>
{plugin.description && (
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p>
)}
</button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Releases</CardTitle>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => openReleaseDialogFrom(releases[0])}
disabled={!selectedPlugin}
>
<Copy className="h-4 w-4" /> Clone Latest
</Button>
<Button size="sm" onClick={() => openReleaseDialogFrom()} disabled={!selectedPlugin}>
<UploadCloud className="h-4 w-4" /> New Release
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2">
{!selectedPlugin && (
<p className="text-sm text-muted-foreground">Select a plugin to manage releases.</p>
)}
{selectedPlugin && releases.length === 0 && (
<p className="text-sm text-muted-foreground">No releases published yet.</p>
)}
{releases.map((release) => (
<div key={release.id} className="rounded-md border px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">v{release.version}</span>
<Badge variant="outline">{release.channel}</Badge>
<Badge variant="secondary">{release.artifactType}</Badge>
{!release.isPublished && <Badge variant="destructive">Unpublished</Badge>}
</div>
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
{release.artifactUrl}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0}{' '}
fields Templates:{' '}
{Array.isArray(release.configTemplates) ? release.configTemplates.length : 0}
</p>
<div className="mt-2">
<Button
size="sm"
variant="outline"
onClick={() =>
togglePublishedMutation.mutate({
releaseId: release.id,
isPublished: !release.isPublished,
})
}
disabled={togglePublishedMutation.isPending}
>
<Rocket className="h-4 w-4" />
{release.isPublished ? 'Unpublish' : 'Publish'}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
<Dialog
open={createReleaseOpen}
onOpenChange={(open) => {
if (open) {
setCreateReleaseOpen(true);
return;
}
resetReleaseForm();
}}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}
</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={async (event) => {
event.preventDefault();
try {
const installSchema = releaseInstallSchemaFile
? await parseJsonArrayFile(releaseInstallSchemaFile, 'Install schema file')
: parseJsonArray(releaseInstallSchemaJson);
const configTemplates = releaseTemplatesFile
? await parseJsonArrayFile(releaseTemplatesFile, 'Config templates file')
: parseJsonArray(releaseTemplatesJson);
if (releaseInputMode === 'upload') {
if (releaseArtifactFiles.length === 0) {
toast.error('Select at least one file or folder');
return;
}
const formData = new FormData();
formData.append('version', releaseVersion);
formData.append('channel', releaseChannel);
if (releaseDestination.trim())
formData.append('destination', releaseDestination.trim());
if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim());
if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog);
if (releaseInstallSchemaFile) {
formData.append(
'installSchemaFile',
releaseInstallSchemaFile,
releaseInstallSchemaFile.name,
);
} else {
formData.append('installSchema', JSON.stringify(installSchema));
}
if (releaseTemplatesFile) {
formData.append(
'configTemplatesFile',
releaseTemplatesFile,
releaseTemplatesFile.name,
);
} else {
formData.append('configTemplates', JSON.stringify(configTemplates));
}
for (const file of releaseArtifactFiles) {
const relativePath = (file as File & { webkitRelativePath?: string })
.webkitRelativePath;
formData.append(
'relativePath',
relativePath && relativePath.length > 0 ? relativePath : file.name,
);
formData.append('files', file, file.name);
}
createUploadReleaseMutation.mutate(formData);
return;
}
createReleaseMutation.mutate({
version: releaseVersion,
channel: releaseChannel,
artifactType: releaseArtifactType,
artifactUrl: releaseArtifactUrl,
destination: releaseDestination || undefined,
fileName: releaseFileName || undefined,
changelog: releaseChangelog || undefined,
installSchema,
configTemplates,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid JSON';
toast.error(`Release JSON error: ${message}`);
}
}}
>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Version</Label>
<Input
value={releaseVersion}
onChange={(e) => setReleaseVersion(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Channel</Label>
<select
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={releaseChannel}
onChange={(e) => setReleaseChannel(e.target.value as 'stable' | 'beta' | 'alpha')}
>
<option value="stable">stable</option>
<option value="beta">beta</option>
<option value="alpha">alpha</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label>Release Source</Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={releaseInputMode === 'upload' ? 'default' : 'outline'}
onClick={() => setReleaseInputMode('upload')}
>
CDN Upload
</Button>
<Button
type="button"
size="sm"
variant={releaseInputMode === 'url' ? 'default' : 'outline'}
onClick={() => setReleaseInputMode('url')}
>
URL
</Button>
</div>
</div>
{releaseInputMode === 'url' && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Artifact Type</Label>
<select
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
value={releaseArtifactType}
onChange={(e) => setReleaseArtifactType(e.target.value as 'file' | 'zip')}
>
<option value="file">file</option>
<option value="zip">zip</option>
</select>
</div>
<div className="space-y-2">
<Label>Artifact URL</Label>
<Input
type="url"
value={releaseArtifactUrl}
onChange={(e) => setReleaseArtifactUrl(e.target.value)}
required={releaseInputMode === 'url'}
/>
</div>
</div>
)}
{releaseInputMode === 'upload' && (
<div className="space-y-3 rounded-md border p-3">
<p className="text-xs text-muted-foreground">
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen
otomatik zip yapilip CDN&apos;e yuklenir.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Files</Label>
<input
type="file"
multiple
className="block w-full text-sm"
onChange={(e) => appendReleaseFiles(e.target.files)}
/>
</div>
<div className="space-y-2">
<Label>Folder</Label>
<input
type="file"
multiple
{...({ webkitdirectory: '', directory: '' } as Record<string, string>)}
className="block w-full text-sm"
onChange={(e) => appendReleaseFiles(e.target.files)}
/>
</div>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Selected: {releaseArtifactFiles.length} file(s)
</p>
{releaseArtifactFiles.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setReleaseArtifactFiles([])}
>
Clear
</Button>
)}
</div>
{releaseArtifactFiles.length > 0 && (
<div className="max-h-28 space-y-1 overflow-auto rounded bg-muted/40 p-2 text-xs">
{releaseArtifactFiles.map((file, index) => {
const relativePath = (file as File & { webkitRelativePath?: string })
.webkitRelativePath;
return (
<p key={`${relativePath || file.name}-${index}`} className="truncate">
{relativePath || file.name}
</p>
);
})}
</div>
)}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Destination (optional)</Label>
<Input
value={releaseDestination}
onChange={(e) => setReleaseDestination(e.target.value)}
placeholder="/game/csgo/addons"
/>
</div>
<div className="space-y-2">
<Label>File Name (optional)</Label>
<Input
value={releaseFileName}
onChange={(e) => setReleaseFileName(e.target.value)}
placeholder="plugin.dll"
/>
</div>
</div>
<div className="space-y-2">
<Label>Changelog (optional)</Label>
<textarea
value={releaseChangelog}
onChange={(e) => setReleaseChangelog(e.target.value)}
className="min-h-[90px] w-full rounded-md border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Install Schema JSON (array)</Label>
<input
key={releaseInstallSchemaFileInputKey}
type="file"
accept="application/json,.json"
className="block w-full text-xs"
onChange={(e) => setReleaseInstallSchemaFile(e.target.files?.[0] ?? null)}
/>
{releaseInstallSchemaFile && (
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<p>
File secili: {releaseInstallSchemaFile.name}. Bu dosya, alttaki metni override
eder.
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setReleaseInstallSchemaFile(null);
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
}}
>
Clear
</Button>
</div>
)}
<textarea
value={releaseInstallSchemaJson}
onChange={(e) => setReleaseInstallSchemaJson(e.target.value)}
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>Config Templates JSON (array)</Label>
<input
key={releaseTemplatesFileInputKey}
type="file"
accept="application/json,.json"
className="block w-full text-xs"
onChange={(e) => setReleaseTemplatesFile(e.target.files?.[0] ?? null)}
/>
{releaseTemplatesFile && (
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<p>
File secili: {releaseTemplatesFile.name}. Bu dosya, alttaki metni override
eder.
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setReleaseTemplatesFile(null);
setReleaseTemplatesFileInputKey((prev) => prev + 1);
}}
>
Clear
</Button>
</div>
)}
<textarea
value={releaseTemplatesJson}
onChange={(e) => setReleaseTemplatesJson(e.target.value)}
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
/>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={
createReleaseMutation.isPending ||
createUploadReleaseMutation.isPending ||
!selectedPlugin
}
>
{createReleaseMutation.isPending || createUploadReleaseMutation.isPending
? 'Publishing...'
: 'Publish Release'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+12 -3
View File
@@ -4,7 +4,14 @@ 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 {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth';
import { ApiError } from '@/lib/api';
@@ -35,7 +42,7 @@ export function LoginPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-screen items-center justify-center bg-transparent p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
@@ -47,7 +54,9 @@ export function LoginPage() {
<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="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
+12 -3
View File
@@ -4,7 +4,14 @@ 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 {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth';
import { ApiError } from '@/lib/api';
@@ -36,7 +43,7 @@ export function RegisterPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-screen items-center justify-center bg-transparent p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
@@ -48,7 +55,9 @@ export function RegisterPage() {
<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="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
+4 -2
View File
@@ -37,7 +37,7 @@ export function DashboardPage() {
const servers = serversData?.data ?? [];
const running = servers.filter((s) => s.status === 'running').length;
const totalNodes = nodesData?.meta.total ?? 0;
const totalNodes = nodesData?.meta?.total ?? nodesData?.data?.length ?? 0;
return (
<div className="space-y-6">
@@ -54,7 +54,9 @@ export function DashboardPage() {
<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>
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Servers
</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
+169 -25
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Network,
@@ -9,14 +10,26 @@ import {
MemoryStick,
HardDrive,
Server,
Activity,
Plus,
Globe,
} from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface NodeDetail {
id: string;
@@ -51,8 +64,20 @@ interface ServerSummary {
gameName: string;
}
interface Allocation {
id: string;
nodeId: string;
serverId: string | null;
ip: string;
port: number;
}
export function NodeDetailPage() {
const { orgId, nodeId } = useParams();
const queryClient = useQueryClient();
const [allocOpen, setAllocOpen] = useState(false);
const [allocIp, setAllocIp] = useState('0.0.0.0');
const [allocPorts, setAllocPorts] = useState('');
const { data: node } = useQuery({
queryKey: ['node', orgId, nodeId],
@@ -68,11 +93,41 @@ export function NodeDetailPage() {
const { data: serversData } = useQuery({
queryKey: ['node-servers', orgId, nodeId],
queryFn: () =>
api.get<{ data: ServerSummary[] }>(
`/organizations/${orgId}/nodes/${nodeId}/servers`,
),
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) {
@@ -83,12 +138,10 @@ export function NodeDetailPage() {
);
}
const memPercent = stats
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
: 0;
const diskPercent = stats
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
: 0;
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">
@@ -108,9 +161,13 @@ export function NodeDetailPage() {
</div>
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
{node.isOnline ? (
<><Wifi className="mr-1 h-3 w-3" /> Online</>
<>
<Wifi className="mr-1 h-3 w-3" /> Online
</>
) : (
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
<>
<WifiOff className="mr-1 h-3 w-3" /> Offline
</>
)}
</Badge>
</div>
@@ -138,9 +195,7 @@ export function NodeDetailPage() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats
? `${formatBytes(stats.memoryUsed)} / ${formatBytes(stats.memoryTotal)}`
: '—'}
{stats ? `${formatBytes(stats.memoryUsed)} / ${formatBytes(stats.memoryTotal)}` : '—'}
</div>
<Progress value={memPercent} className="mt-2 h-2" />
</CardContent>
@@ -153,9 +208,7 @@ export function NodeDetailPage() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats
? `${formatBytes(stats.diskUsed)} / ${formatBytes(stats.diskTotal)}`
: '—'}
{stats ? `${formatBytes(stats.diskUsed)} / ${formatBytes(stats.diskTotal)}` : '—'}
</div>
<Progress value={diskPercent} className="mt-2 h-2" />
</CardContent>
@@ -189,9 +242,7 @@ export function NodeDetailPage() {
<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} />
)}
{node.daemonVersion && <InfoRow label="Daemon Version" value={node.daemonVersion} />}
<InfoRow label="Created" value={new Date(node.createdAt).toLocaleDateString()} />
</CardContent>
</Card>
@@ -218,9 +269,7 @@ export function NodeDetailPage() {
<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'}
>
<Badge variant={srv.status === 'running' ? 'default' : 'outline'}>
{srv.status}
</Badge>
<span className="text-xs text-muted-foreground">
@@ -234,10 +283,105 @@ export function NodeDetailPage() {
</CardContent>
</Card>
</div>
{/* Allocations */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5" />
<CardTitle>Allocations</CardTitle>
</div>
<Dialog open={allocOpen} onOpenChange={setAllocOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4" /> Add Ports
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Allocations</DialogTitle>
</DialogHeader>
<form onSubmit={handleAddAllocations} className="space-y-4">
<div className="space-y-2">
<Label>IP Address</Label>
<Input
value={allocIp}
onChange={(e) => setAllocIp(e.target.value)}
placeholder="0.0.0.0"
required
/>
</div>
<div className="space-y-2">
<Label>Ports</Label>
<Input
value={allocPorts}
onChange={(e) => setAllocPorts(e.target.value)}
placeholder="25565, 25566-25570"
required
/>
<p className="text-xs text-muted-foreground">
Comma-separated ports or ranges (e.g. 25565, 25566-25570)
</p>
</div>
<DialogFooter>
<Button type="submit" disabled={createAllocMutation.isPending}>
{createAllocMutation.isPending ? 'Adding...' : 'Add'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
{allocations.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No allocations yet. Add ports to assign to servers.
</p>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{allocations.map((alloc) => (
<div
key={alloc.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="font-mono text-sm">
{alloc.ip}:{alloc.port}
</div>
<Badge variant={alloc.serverId ? 'default' : 'outline'}>
{alloc.serverId ? 'In use' : 'Available'}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
/** Parse port input like "25565, 25566-25570, 27015" into flat number array */
function parsePorts(input: string): number[] {
const ports: number[] = [];
const parts = input
.split(',')
.map((s) => s.trim())
.filter(Boolean);
for (const part of parts) {
if (part.includes('-')) {
const [startStr, endStr] = part.split('-');
const start = parseInt(startStr!, 10);
const end = parseInt(endStr!, 10);
if (isNaN(start) || isNaN(end) || start > end || start < 1 || end > 65535) continue;
for (let p = start; p <= end; p++) ports.push(p);
} else {
const p = parseInt(part, 10);
if (!isNaN(p) && p >= 1 && p <= 65535) ports.push(p);
}
}
return ports;
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between text-sm">
+64 -6
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Network, Wifi, WifiOff } from 'lucide-react';
import { Plus, Network, Wifi, WifiOff, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -16,6 +17,7 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from '@/components/ui/dialog';
interface NodeItem {
@@ -29,6 +31,10 @@ interface NodeItem {
isOnline: boolean;
}
interface CreatedNode extends NodeItem {
daemonToken: string;
}
interface PaginatedResponse<T> {
data: T[];
meta: { total: number };
@@ -38,6 +44,9 @@ export function NodesPage() {
const { orgId } = useParams();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [tokenDialog, setTokenDialog] = useState(false);
const [createdToken, setCreatedToken] = useState('');
const [copied, setCopied] = useState(false);
const [name, setName] = useState('');
const [fqdn, setFqdn] = useState('');
const [daemonPort, setDaemonPort] = useState(8443);
@@ -52,17 +61,28 @@ export function NodesPage() {
const createMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post(`/organizations/${orgId}/nodes`, body),
onSuccess: () => {
api.post<CreatedNode>(`/organizations/${orgId}/nodes`, body),
onSuccess: (node) => {
queryClient.invalidateQueries({ queryKey: ['nodes', orgId] });
setOpen(false);
setName('');
setFqdn('');
// Show token dialog
setCreatedToken(node.daemonToken);
setTokenDialog(true);
setCopied(false);
},
});
const nodes = data?.data ?? [];
const handleCopyToken = async () => {
await navigator.clipboard.writeText(createdToken);
setCopied(true);
toast.success('Token copied to clipboard');
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -151,6 +171,38 @@ export function NodesPage() {
</Dialog>
</div>
{/* Token display dialog */}
<Dialog open={tokenDialog} onOpenChange={setTokenDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Node Created Successfully</DialogTitle>
<DialogDescription>
Save this daemon token now. It will not be shown again.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Label>Daemon Token</Label>
<div className="flex gap-2">
<Input readOnly value={createdToken} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={handleCopyToken}>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Use this token in your daemon configuration file (config.yml) to authenticate with the
panel.
</p>
</div>
<DialogFooter>
<Button onClick={() => setTokenDialog(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="grid gap-4 sm:grid-cols-2">
{nodes.map((node) => (
<Link key={node.id} to={`/org/${orgId}/nodes/${node.id}`}>
@@ -162,14 +214,20 @@ export function NodesPage() {
</div>
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
{node.isOnline ? (
<><Wifi className="mr-1 h-3 w-3" /> Online</>
<>
<Wifi className="mr-1 h-3 w-3" /> Online
</>
) : (
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
<>
<WifiOff className="mr-1 h-3 w-3" /> Offline
</>
)}
</Badge>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{node.fqdn}:{node.daemonPort}</p>
<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>
+8 -19
View File
@@ -54,16 +54,13 @@ export function BackupsPage() {
const { data } = useQuery({
queryKey: ['backups', orgId, serverId],
queryFn: () =>
api.get<{ backups: Backup[] }>(
`/organizations/${orgId}/servers/${serverId}/backups`,
),
api.get<{ backups: Backup[] }>(`/organizations/${orgId}/servers/${serverId}/backups`),
});
const deleteMutation = useMutation({
mutationFn: (backupId: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}`),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
});
const restoreMutation = useMutation({
@@ -75,8 +72,7 @@ export function BackupsPage() {
const lockMutation = useMutation({
mutationFn: (backupId: string) =>
api.patch(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}/lock`, {}),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
});
const backupList = data?.backups ?? [];
@@ -89,7 +85,8 @@ export function BackupsPage() {
<div>
<h2 className="text-lg font-semibold">Backups</h2>
<p className="text-xs text-muted-foreground">
{backupList.length} backup{backupList.length !== 1 ? 's' : ''} {formatBytes(totalSize)} total
{backupList.length} backup{backupList.length !== 1 ? 's' : ''} {' '}
{formatBytes(totalSize)} total
</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
@@ -154,9 +151,7 @@ export function BackupsPage() {
<span>{formatBytes(backup.sizeBytes)}</span>
<span>{new Date(backup.createdAt).toLocaleString()}</span>
{backup.checksum && (
<span className="font-mono">
{backup.checksum.slice(0, 12)}...
</span>
<span className="font-mono">{backup.checksum.slice(0, 12)}...</span>
)}
</div>
</div>
@@ -238,9 +233,7 @@ function CreateBackupForm({
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState(
`backup-${new Date().toISOString().slice(0, 10)}`,
);
const [name, setName] = useState(`backup-${new Date().toISOString().slice(0, 10)}`);
const createMutation = useMutation({
mutationFn: (data: { name: string }) =>
@@ -261,11 +254,7 @@ function CreateBackupForm({
>
<div className="grid gap-1.5">
<Label>Backup Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
+32 -32
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Settings2, FileText, Save } from 'lucide-react';
@@ -30,6 +30,21 @@ interface ConfigDetail {
raw: string;
}
function mergeConfigEntries(entries: ConfigEntry[], editableKeys: string[] | null): ConfigEntry[] {
if (!editableKeys || editableKeys.length === 0) return entries;
const existing = new Map(entries.map((entry) => [entry.key, entry]));
const merged = [...entries];
for (const key of editableKeys) {
if (!existing.has(key)) {
merged.push({ key, value: '' });
}
}
return merged;
}
export function ConfigPage() {
const { orgId, serverId } = useParams();
const queryClient = useQueryClient();
@@ -37,9 +52,7 @@ export function ConfigPage() {
const { data: configsData } = useQuery({
queryKey: ['configs', orgId, serverId],
queryFn: () =>
api.get<{ configs: ConfigFile[] }>(
`/organizations/${orgId}/servers/${serverId}/config`,
),
api.get<{ configs: ConfigFile[] }>(`/organizations/${orgId}/servers/${serverId}/config`),
});
const configs = configsData?.configs ?? [];
@@ -96,26 +109,19 @@ function ConfigEditor({
const { data: detail } = useQuery({
queryKey: ['config-detail', orgId, serverId, configIndex],
queryFn: () =>
api.get<ConfigDetail>(
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
),
api.get<ConfigDetail>(`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`),
});
const [entries, setEntries] = useState<ConfigEntry[]>([]);
const [initialized, setInitialized] = useState(false);
// Initialize entries from server data
if (detail && !initialized) {
setEntries(detail.entries);
setInitialized(true);
}
useEffect(() => {
if (!detail) return;
setEntries(mergeConfigEntries(detail.entries, configFile.editableKeys));
}, [detail, configFile.editableKeys]);
const saveMutation = useMutation({
mutationFn: (data: { entries: ConfigEntry[] }) =>
api.patch(
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
data,
),
api.put(`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['config-detail', orgId, serverId, configIndex],
@@ -124,15 +130,9 @@ function ConfigEditor({
});
const updateEntry = (key: string, value: string) => {
setEntries((prev) =>
prev.map((e) => (e.key === key ? { ...e, value } : e)),
);
setEntries((prev) => prev.map((e) => (e.key === key ? { ...e, value } : e)));
};
const displayEntries = configFile.editableKeys
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
: entries;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
@@ -143,8 +143,8 @@ function ConfigEditor({
</CardTitle>
<CardDescription>
{configFile.editableKeys
? `${configFile.editableKeys.length} editable keys`
: 'All keys editable'}
? `${configFile.editableKeys.length} allowed additions, plus existing keys`
: 'All detected keys editable'}
</CardDescription>
</div>
<Button
@@ -157,17 +157,17 @@ function ConfigEditor({
</Button>
</CardHeader>
<CardContent>
{displayEntries.length === 0 ? (
{entries.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{detail ? 'No entries found. The server may need to be started first to generate config files.' : 'Loading...'}
{detail
? 'No entries found. The server may need to be started first to generate config files.'
: 'Loading...'}
</p>
) : (
<div className="space-y-3">
{displayEntries.map((entry) => (
{entries.map((entry) => (
<div key={entry.key} className="grid gap-1.5">
<Label className="font-mono text-xs text-muted-foreground">
{entry.key}
</Label>
<Label className="font-mono text-xs text-muted-foreground">{entry.key}</Label>
<Input
value={entry.value}
onChange={(e) => updateEntry(entry.key, e.target.value)}
+79 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { useOutletContext, useParams } from 'react-router';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
@@ -10,17 +10,50 @@ import { Button } from '@/components/ui/button';
import { Send } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
interface ConsoleOutletContext {
server?: {
status: string;
};
}
export function ConsolePage() {
const { orgId, serverId } = useParams();
const { server } = useOutletContext<ConsoleOutletContext>();
const termRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const serverStatusRef = useRef<string | null>(server?.status ?? null);
const rejoinTimeoutRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const [command, setCommand] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
useEffect(() => {
if (!termRef.current) return;
serverStatusRef.current = server?.status ?? null;
}, [server?.status]);
useEffect(() => {
if (!termRef.current || !serverId) return;
const joinConsole = () => {
connectSocket();
const socket = getSocket();
socket.emit('server:console:join', { serverId });
};
const scheduleRejoin = (delayMs = 1_000) => {
const status = serverStatusRef.current;
if (status !== 'starting' && status !== 'running') return;
if (rejoinTimeoutRef.current) {
window.clearTimeout(rejoinTimeoutRef.current);
}
rejoinTimeoutRef.current = window.setTimeout(() => {
rejoinTimeoutRef.current = null;
joinConsole();
}, delayMs);
};
const terminal = new Terminal({
cursorBlink: false,
@@ -48,33 +81,72 @@ export function ConsolePage() {
terminal.writeln('\x1b[90m--- Console connected ---\x1b[0m');
// Socket.IO connection
connectSocket();
const socket = getSocket();
socket.emit('server:console:join', { serverId });
const handleConnect = () => {
joinConsole();
};
const handleOutput = (data: { line: string }) => {
terminal.writeln(data.line);
if (data.line === '[console] Stream ended') {
scheduleRejoin();
}
};
const handleCommandAck = (data: { ok: boolean; error?: string }) => {
if (!data.ok && data.error) {
terminal.writeln(`[error] ${data.error}`);
}
};
socket.on('connect', handleConnect);
socket.on('server:console:output', handleOutput);
socket.on('server:console:command:ack', handleCommandAck);
const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize);
joinConsole();
return () => {
if (rejoinTimeoutRef.current) {
window.clearTimeout(rejoinTimeoutRef.current);
rejoinTimeoutRef.current = null;
}
socket.off('connect', handleConnect);
socket.off('server:console:output', handleOutput);
socket.off('server:console:command:ack', handleCommandAck);
socket.emit('server:console:leave', { serverId });
window.removeEventListener('resize', handleResize);
terminal.dispose();
};
}, [serverId]);
useEffect(() => {
if (!serverId) return;
const status = server?.status;
if (status !== 'starting' && status !== 'running') {
if (rejoinTimeoutRef.current) {
window.clearTimeout(rejoinTimeoutRef.current);
rejoinTimeoutRef.current = null;
}
return;
}
connectSocket();
const socket = getSocket();
socket.emit('server:console:join', { serverId });
}, [server?.status, serverId]);
const sendCommand = () => {
if (!command.trim()) return;
const socket = getSocket();
socket.emit('server:console:command', { serverId, orgId, command: command.trim() });
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
socket.emit('server:console:command', {
serverId,
orgId,
command: command.trim(),
requestId,
});
setHistory((prev) => [...prev, command.trim()]);
setHistoryIndex(-1);
setCommand('');
+320
View File
@@ -0,0 +1,320 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, ExternalLink, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { ApiError, api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface ManagedDatabase {
id: string;
name: string;
databaseName: string;
username: string;
password: string;
host: string;
port: number;
phpMyAdminUrl: string | null;
createdAt: string;
updatedAt: string;
}
function extractApiMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
const maybeMessage = (error.data as { message?: unknown }).message;
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
return maybeMessage;
}
}
return fallback;
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs">{value}</div>
</div>
);
}
export function DatabasesPage() {
const { orgId, serverId } = useParams();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [editingDatabase, setEditingDatabase] = useState<ManagedDatabase | null>(null);
const [editName, setEditName] = useState('');
const [editPassword, setEditPassword] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['server-databases', orgId, serverId],
queryFn: () =>
api.get<{ data: ManagedDatabase[] }>(`/organizations/${orgId}/servers/${serverId}/databases`),
});
useEffect(() => {
if (!editingDatabase) return;
setEditName(editingDatabase.name);
setEditPassword('');
}, [editingDatabase]);
const databases = data?.data ?? [];
const resetCreateForm = () => {
setCreateName('');
setCreatePassword('');
};
const createMutation = useMutation({
mutationFn: (body: { name: string; password?: string }) =>
api.post<ManagedDatabase>(`/organizations/${orgId}/servers/${serverId}/databases`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
setCreateOpen(false);
resetCreateForm();
toast.success('Database created');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to create database'));
},
});
const updateMutation = useMutation({
mutationFn: (body: { name?: string; password?: string }) =>
api.patch<ManagedDatabase>(
`/organizations/${orgId}/servers/${serverId}/databases/${editingDatabase!.id}`,
body,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
setEditingDatabase(null);
setEditPassword('');
toast.success('Database updated');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to update database'));
},
});
const deleteMutation = useMutation({
mutationFn: (databaseId: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/databases/${databaseId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
toast.success('Database deleted');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to delete database'));
},
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">Databases</h2>
<p className="text-sm text-muted-foreground">
Unlimited MySQL databases for this server, with password rotation and phpMyAdmin links.
</p>
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetCreateForm();
}}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" /> Create Database
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create MySQL Database</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
createMutation.mutate({
name: createName,
password: createPassword.trim() || undefined,
});
}}
>
<div className="space-y-2">
<Label>Label</Label>
<Input
value={createName}
onChange={(event) => setCreateName(event.target.value)}
placeholder="LuckPerms"
required
/>
</div>
<div className="space-y-2">
<Label>Password (Optional)</Label>
<Input
value={createPassword}
onChange={(event) => setCreatePassword(event.target.value)}
minLength={8}
placeholder="Leave empty to auto-generate"
/>
<p className="text-xs text-muted-foreground">
If left empty, the panel generates a strong password automatically.
</p>
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<Dialog
open={Boolean(editingDatabase)}
onOpenChange={(open) => {
if (!open) {
setEditingDatabase(null);
setEditPassword('');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Database</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
updateMutation.mutate({
name: editName !== editingDatabase?.name ? editName : undefined,
password: editPassword.trim() || undefined,
});
}}
>
<div className="space-y-2">
<Label>Label</Label>
<Input
value={editName}
onChange={(event) => setEditName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>New Password (Optional)</Label>
<Input
value={editPassword}
onChange={(event) => setEditPassword(event.target.value)}
minLength={8}
placeholder="Leave empty to keep the current password"
/>
<p className="text-xs text-muted-foreground">
Entering a value rotates the MySQL user password immediately.
</p>
</div>
<DialogFooter>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : databases.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
No databases yet. Create one for plugins, web panels, or server-side data.
</CardContent>
</Card>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{databases.map((database) => (
<Card key={database.id}>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-primary" />
<CardTitle className="text-base">{database.name}</CardTitle>
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(database.createdAt).toLocaleString()}
</p>
</div>
<div className="flex gap-2">
{database.phpMyAdminUrl ? (
<Button asChild size="sm" variant="outline">
<a href={database.phpMyAdminUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" /> phpMyAdmin
</a>
</Button>
) : null}
<Button size="sm" variant="outline" onClick={() => setEditingDatabase(database)}>
<RefreshCw className="h-4 w-4" /> Edit
</Button>
<Button
size="sm"
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
const confirmed = window.confirm(
`Delete "${database.name}" and permanently drop ${database.databaseName}?`,
);
if (!confirmed) return;
deleteMutation.mutate(database.id);
}}
>
<Trash2 className="h-4 w-4" /> Delete
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<InfoRow label="Host" value={database.host} />
<InfoRow label="Port" value={String(database.port)} />
<InfoRow label="Database" value={database.databaseName} />
<InfoRow label="Username" value={database.username} />
</div>
<InfoRow label="Password" value={database.password} />
<InfoRow
label="Connection URI"
value={`mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}`}
/>
{!database.phpMyAdminUrl ? (
<p className="text-xs text-muted-foreground">
phpMyAdmin link is not configured. Set `managed_mysql.phpmyadmin_url` in the
daemon config for this node.
</p>
) : null}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+625 -77
View File
@@ -1,29 +1,31 @@
import { useState } from 'react';
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react';
import { useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Folder,
FileText,
ArrowUp,
Trash2,
Plus,
Download,
Upload,
FileText,
Folder,
FolderPlus,
Plus,
Save,
Trash2,
Upload,
X,
} from 'lucide-react';
import { api } from '@/lib/api';
import { toast } from 'sonner';
import { ApiError, api } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
interface FileEntry {
@@ -34,23 +36,110 @@ interface FileEntry {
modifiedAt: number;
}
interface FileReadResponse {
data: string;
encoding: 'utf8' | 'base64';
mimeType: string;
}
interface EditingFile {
path: string;
content: string;
originalContent: string;
}
interface UploadItem {
file: File;
targetPath: string;
}
function extractApiMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
const payload = error.data as { message?: string } | null;
if (payload?.message) return payload.message;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
}
function joinRemotePath(basePath: string, relativePath: string): string {
const safeSegments = relativePath
.replace(/\\/g, '/')
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..');
const baseSegments = basePath.replace(/\\/g, '/').split('/').filter(Boolean);
return `/${[...baseSegments, ...safeSegments].join('/')}`.replace(/\/{2,}/g, '/');
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode file'));
return;
}
const commaIndex = reader.result.indexOf(',');
resolve(commaIndex >= 0 ? reader.result.slice(commaIndex + 1) : reader.result);
};
reader.onerror = () => {
reject(reader.error ?? new Error(`Failed to read file: ${file.name}`));
};
reader.readAsDataURL(file);
});
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
function isLikelyBinaryText(content: string): boolean {
const sample = content.slice(0, 4096);
return sample.includes('\u0000');
}
export function FilesPage() {
const { orgId, serverId } = useParams();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<HTMLTextAreaElement>(null);
const [currentPath, setCurrentPath] = useState('/');
const [editingFile, setEditingFile] = useState<{ path: string; content: string } | null>(null);
const [newFileName, setNewFileName] = useState('');
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
const [showNewFile, setShowNewFile] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [newFileName, setNewFileName] = useState('');
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [deleteTarget, setDeleteTarget] = useState<FileEntry | null>(null);
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(
null,
);
const hasUnsavedChanges = !!editingFile && editingFile.content !== editingFile.originalContent;
const isUploading = !!uploadProgress;
const filesQuery = useQuery({
queryKey: ['files', orgId, serverId, currentPath],
enabled: Boolean(orgId && serverId) && !editingFile,
queryFn: () =>
api.get<{ files: FileEntry[] }>(
`/organizations/${orgId}/servers/${serverId}/files`,
{ path: currentPath },
),
enabled: !editingFile,
api.get<{ files: FileEntry[] }>(`/organizations/${orgId}/servers/${serverId}/files`, {
path: currentPath,
}),
});
const deleteMutation = useMutation({
@@ -59,38 +148,132 @@ export function FilesPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
setDeleteTarget(null);
toast.success('Deleted successfully');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Delete failed'));
},
});
const saveMutation = useMutation({
mutationFn: ({ path, data }: { path: string; data: string }) =>
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }),
onSuccess: () => {
setEditingFile(null);
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
path,
data,
encoding: 'utf8',
}),
onSuccess: (_result, variables) => {
setEditingFile((prev) =>
prev && prev.path === variables.path
? {
...prev,
content: variables.data,
originalContent: variables.data,
}
: prev,
);
toast.success('File saved');
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Save failed'));
},
});
const createFileMutation = useMutation({
mutationFn: ({ path, data }: { path: string; data: string }) =>
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }),
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
path,
data,
encoding: 'utf8',
}),
onSuccess: () => {
setShowNewFile(false);
setNewFileName('');
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
toast.success('File created');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to create file'));
},
});
const createFolderMutation = useMutation({
mutationFn: async (folderPath: string) => {
const markerName = `.gp_create_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const markerPath = `${folderPath.replace(/\/+$/g, '')}/${markerName}`;
await api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
path: markerPath,
data: '',
encoding: 'utf8',
});
await api.post(`/organizations/${orgId}/servers/${serverId}/files/delete`, {
paths: [markerPath],
});
},
onSuccess: () => {
setShowNewFolder(false);
setNewFolderName('');
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
toast.success('Folder created');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to create folder'));
},
});
const files = filesQuery.data?.files ?? [];
const breadcrumbs = currentPath.split('/').filter(Boolean);
const openFile = async (file: FileEntry) => {
if (!orgId || !serverId) return;
if (file.isDirectory) {
setCurrentPath(file.path);
return;
}
const res = await api.get<{ data: string }>(
try {
const res = await api.get<FileReadResponse>(
`/organizations/${orgId}/servers/${serverId}/files/read`,
{ path: file.path },
{ path: file.path, encoding: 'utf8' },
);
setEditingFile({ path: file.path, content: res.data });
if (isLikelyBinaryText(res.data)) {
toast.error('This file looks binary. Use download instead of editor.');
return;
}
setEditingFile({
path: file.path,
content: res.data,
originalContent: res.data,
});
requestAnimationFrame(() => {
editorRef.current?.focus();
});
} catch (error) {
toast.error(extractApiMessage(error, 'Failed to open file'));
}
};
const saveCurrentFile = () => {
if (!editingFile) return;
saveMutation.mutate({ path: editingFile.path, data: editingFile.content });
};
const closeEditor = () => {
if (hasUnsavedChanges) {
const shouldClose = window.confirm(
'You have unsaved changes. Close the editor and discard them?',
);
if (!shouldClose) return;
}
setEditingFile(null);
};
const goUp = () => {
@@ -100,28 +283,256 @@ export function FilesPage() {
setCurrentPath('/' + parts.join('/'));
};
const breadcrumbs = currentPath.split('/').filter(Boolean);
const applyEditorContent = (
nextValue: string,
selectionStart: number,
selectionEnd: number,
textarea: HTMLTextAreaElement,
) => {
setEditingFile((prev) => (prev ? { ...prev, content: nextValue } : prev));
const files = filesQuery.data?.files ?? [];
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(selectionStart, selectionEnd);
const lineHeight = Number.parseFloat(getComputedStyle(textarea).lineHeight) || 20;
const lineBeforeCursor = textarea.value.slice(0, selectionEnd).split('\n').length - 1;
const caretTop = lineBeforeCursor * lineHeight;
const viewportTop = textarea.scrollTop;
const viewportBottom = viewportTop + textarea.clientHeight;
if (caretTop < viewportTop + lineHeight) {
textarea.scrollTop = Math.max(0, caretTop - lineHeight);
} else if (caretTop > viewportBottom - lineHeight * 2) {
textarea.scrollTop = Math.max(0, caretTop - textarea.clientHeight + lineHeight * 2);
}
});
};
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (!editingFile) return;
const textarea = event.currentTarget;
const value = editingFile.content;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
if (hasUnsavedChanges && !saveMutation.isPending) {
saveCurrentFile();
}
return;
}
if (event.key === 'Tab') {
event.preventDefault();
if (start !== end) {
const blockStart = value.lastIndexOf('\n', start - 1) + 1;
const rawBlockEnd = value.indexOf('\n', end);
const blockEnd = rawBlockEnd === -1 ? value.length : rawBlockEnd;
const block = value.slice(blockStart, blockEnd);
const lines = block.split('\n');
if (event.shiftKey) {
let firstLineRemoved = 0;
let totalRemoved = 0;
const outdented = lines.map((line, index) => {
let remove = 0;
if (line.startsWith('\t')) remove = 1;
else if (line.startsWith(' ')) remove = 2;
else if (line.startsWith(' ')) remove = 1;
if (index === 0) {
firstLineRemoved = remove;
}
totalRemoved += remove;
return line.slice(remove);
});
const replacement = outdented.join('\n');
const nextValue = `${value.slice(0, blockStart)}${replacement}${value.slice(blockEnd)}`;
const nextStart = Math.max(blockStart, start - firstLineRemoved);
const nextEnd = Math.max(nextStart, end - totalRemoved);
applyEditorContent(nextValue, nextStart, nextEnd, textarea);
return;
}
const indented = lines.map((line) => ` ${line}`).join('\n');
const nextValue = `${value.slice(0, blockStart)}${indented}${value.slice(blockEnd)}`;
const nextStart = start + 2;
const nextEnd = end + lines.length * 2;
applyEditorContent(nextValue, nextStart, nextEnd, textarea);
return;
}
if (event.shiftKey) {
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const beforeCursor = value.slice(lineStart, start);
let remove = 0;
if (beforeCursor.endsWith('\t')) remove = 1;
else if (beforeCursor.endsWith(' ')) remove = 2;
else if (beforeCursor.endsWith(' ')) remove = 1;
if (remove > 0) {
const nextValue = `${value.slice(0, start - remove)}${value.slice(end)}`;
const nextPos = start - remove;
applyEditorContent(nextValue, nextPos, nextPos, textarea);
}
return;
}
const nextValue = `${value.slice(0, start)} ${value.slice(end)}`;
const nextPos = start + 2;
applyEditorContent(nextValue, nextPos, nextPos, textarea);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const currentLine = value.slice(lineStart, start);
const leadingWhitespace = currentLine.match(/^\s*/)?.[0] ?? '';
const shouldIndentMore = /[{[(]$/.test(currentLine.trimEnd());
const insertion = `\n${leadingWhitespace}${shouldIndentMore ? ' ' : ''}`;
const nextValue = `${value.slice(0, start)}${insertion}${value.slice(end)}`;
const nextPos = start + insertion.length;
applyEditorContent(nextValue, nextPos, nextPos, textarea);
}
};
const triggerUploadFiles = () => {
const input = fileInputRef.current;
if (!input) return;
input.value = '';
input.click();
};
const triggerUploadFolder = () => {
const input = folderInputRef.current;
if (!input) return;
input.value = '';
input.setAttribute('webkitdirectory', '');
input.setAttribute('directory', '');
input.click();
};
const uploadItems = async (items: UploadItem[]) => {
if (!orgId || !serverId || items.length === 0) return;
setUploadProgress({ done: 0, total: items.length });
let successCount = 0;
let failedCount = 0;
for (let index = 0; index < items.length; index += 1) {
const item = items[index]!;
try {
const data = await fileToBase64(item.file);
await api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
path: item.targetPath,
data,
encoding: 'base64',
});
successCount += 1;
} catch {
failedCount += 1;
}
setUploadProgress({ done: index + 1, total: items.length });
}
setUploadProgress(null);
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
if (failedCount === 0) {
toast.success(`${successCount} item uploaded`);
} else {
toast.error(`${failedCount} item failed to upload`);
}
};
const onFilesPicked = async (event: ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(event.target.files ?? []);
if (selected.length === 0) return;
const uploadList: UploadItem[] = selected.map((file) => ({
file,
targetPath: joinRemotePath(currentPath, file.name),
}));
await uploadItems(uploadList);
};
const onFolderPicked = async (event: ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(event.target.files ?? []);
if (selected.length === 0) return;
const uploadList: UploadItem[] = selected.map((file) => {
const pathFromFolder =
(file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
return {
file,
targetPath: joinRemotePath(currentPath, pathFromFolder),
};
});
await uploadItems(uploadList);
};
const downloadFile = async (file: FileEntry) => {
if (!orgId || !serverId || file.isDirectory) return;
try {
const response = await api.get<FileReadResponse>(
`/organizations/${orgId}/servers/${serverId}/files/read`,
{ path: file.path, encoding: 'base64' },
);
const blob = new Blob([base64ToArrayBuffer(response.data)], {
type: response.mimeType || 'application/octet-stream',
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = file.name;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(extractApiMessage(error, 'Download failed'));
}
};
if (editingFile) {
return (
<div className="space-y-4">
{editingFile ? (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
<div className="space-y-1">
<CardTitle className="text-sm font-mono">{editingFile.path}</CardTitle>
<div className="flex gap-2">
<p className="text-xs text-muted-foreground">
Tab/Shift+Tab indent, Enter auto-indent, Ctrl/Cmd+S save
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{hasUnsavedChanges && <span className="text-xs text-amber-600">Unsaved changes</span>}
<Button
size="sm"
onClick={() =>
saveMutation.mutate({ path: editingFile.path, data: editingFile.content })
}
disabled={saveMutation.isPending}
onClick={saveCurrentFile}
disabled={saveMutation.isPending || !hasUnsavedChanges}
>
<Save className="h-4 w-4" />
Save
{saveMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="outline" onClick={() => setEditingFile(null)}>
<Button size="sm" variant="outline" onClick={closeEditor}>
<X className="h-4 w-4" />
Close
</Button>
@@ -129,53 +540,116 @@ export function FilesPage() {
</CardHeader>
<CardContent>
<textarea
ref={editorRef}
value={editingFile.content}
onChange={(e) => setEditingFile({ ...editingFile, content: e.target.value })}
className="min-h-[500px] w-full rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
onChange={(event) =>
setEditingFile((prev) => (prev ? { ...prev, content: event.target.value } : prev))
}
onKeyDown={handleEditorKeyDown}
className="min-h-[560px] w-full resize-y rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
spellCheck={false}
style={{ tabSize: 2 }}
/>
</CardContent>
</Card>
) : (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
</div>
);
}
return (
<div className="space-y-4">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
void onFilesPicked(event);
}}
/>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
void onFolderPicked(event);
}}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Button variant="ghost" size="icon" onClick={goUp} disabled={currentPath === '/'}>
<ArrowUp className="h-4 w-4" />
</Button>
<span className="text-muted-foreground">/</span>
{breadcrumbs.map((crumb, i) => (
<span key={i} className="flex items-center gap-1">
<button
className="text-muted-foreground hover:text-foreground"
onClick={() =>
setCurrentPath('/' + breadcrumbs.slice(0, i + 1).join('/'))
}
className="text-muted-foreground transition hover:text-foreground"
onClick={() => setCurrentPath('/')}
>
/
</button>
{breadcrumbs.map((crumb, index) => (
<span key={index} className="flex items-center gap-1">
<span className="text-muted-foreground">/</span>
<button
className="text-muted-foreground transition hover:text-foreground"
onClick={() => setCurrentPath('/' + breadcrumbs.slice(0, index + 1).join('/'))}
>
{crumb}
</button>
{i < breadcrumbs.length - 1 && (
<span className="text-muted-foreground">/</span>
)}
</span>
))}
</div>
<Button size="sm" variant="outline" onClick={() => setShowNewFile(true)}>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={triggerUploadFiles} disabled={isUploading}>
<Upload className="h-4 w-4" />
Upload Files
</Button>
<Button size="sm" variant="outline" onClick={triggerUploadFolder} disabled={isUploading}>
<Upload className="h-4 w-4" />
Upload Folder
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setShowNewFolder(true)}
disabled={isUploading}
>
<FolderPlus className="h-4 w-4" />
New Folder
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setShowNewFile(true)}
disabled={isUploading}
>
<Plus className="h-4 w-4" />
New File
</Button>
</div>
</div>
{isUploading && uploadProgress && (
<Card>
<CardContent className="py-3">
<p className="text-sm text-muted-foreground">
Uploading... {uploadProgress.done}/{uploadProgress.total}
</p>
</CardContent>
</Card>
)}
{showNewFile && (
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Input
placeholder="filename.txt"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newFileName) {
const path =
currentPath === '/' ? `/${newFileName}` : `${currentPath}/${newFileName}`;
onChange={(event) => setNewFileName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && newFileName.trim()) {
const path = joinRemotePath(currentPath, newFileName.trim());
createFileMutation.mutate({ path, data: '' });
}
}}
@@ -183,11 +657,11 @@ export function FilesPage() {
<Button
size="sm"
onClick={() => {
if (!newFileName) return;
const path =
currentPath === '/' ? `/${newFileName}` : `${currentPath}/${newFileName}`;
if (!newFileName.trim()) return;
const path = joinRemotePath(currentPath, newFileName.trim());
createFileMutation.mutate({ path, data: '' });
}}
disabled={createFileMutation.isPending}
>
Create
</Button>
@@ -204,42 +678,115 @@ export function FilesPage() {
</div>
)}
{showNewFolder && (
<div className="flex flex-wrap gap-2">
<Input
placeholder="folder-name"
value={newFolderName}
onChange={(event) => setNewFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && newFolderName.trim()) {
const folderPath = joinRemotePath(currentPath, newFolderName.trim());
createFolderMutation.mutate(folderPath);
}
}}
/>
<Button
size="sm"
onClick={() => {
if (!newFolderName.trim()) return;
const folderPath = joinRemotePath(currentPath, newFolderName.trim());
createFolderMutation.mutate(folderPath);
}}
disabled={createFolderMutation.isPending}
>
Create
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowNewFolder(false);
setNewFolderName('');
}}
>
Cancel
</Button>
</div>
)}
<Card>
<CardContent className="p-0">
<div className="divide-y">
{files.length === 0 && (
{filesQuery.isLoading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
Loading files...
</div>
)}
{filesQuery.isError && (
<div className="space-y-2 py-8 text-center">
<p className="text-sm text-destructive">Failed to load directory</p>
<Button size="sm" variant="outline" onClick={() => filesQuery.refetch()}>
Retry
</Button>
</div>
)}
{!filesQuery.isLoading && !filesQuery.isError && files.length === 0 && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
This directory is empty
</div>
)}
{files.map((file) => (
{!filesQuery.isLoading &&
!filesQuery.isError &&
files.map((file) => (
<div
key={file.path}
className="flex cursor-pointer items-center justify-between px-4 py-2.5 hover:bg-muted/50"
onClick={() => openFile(file)}
onClick={() => {
void openFile(file);
}}
>
<div className="flex items-center gap-3">
<div className="flex min-w-0 items-center gap-3">
{file.isDirectory ? (
<Folder className="h-4 w-4 text-blue-400" />
<Folder className="h-4 w-4 shrink-0 text-blue-400" />
) : (
<FileText className="h-4 w-4 text-muted-foreground" />
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-sm">{file.name}</span>
<span className="truncate text-sm">{file.name}</span>
</div>
<div className="flex items-center gap-4">
<div className="ml-3 flex items-center gap-2">
{!file.isDirectory && (
<span className="text-xs text-muted-foreground">
{formatBytes(file.size)}
</span>
)}
{!file.isDirectory && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(file.path);
onClick={(event) => {
event.stopPropagation();
void downloadFile(file);
}}
title="Download"
>
<Download className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={(event) => {
event.stopPropagation();
setDeleteTarget(file);
}}
title="Delete"
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
@@ -253,10 +800,10 @@ export function FilesPage() {
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete File</DialogTitle>
<DialogTitle>{deleteTarget?.isDirectory ? 'Delete Folder' : 'Delete File'}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to delete <code className="font-mono">{deleteTarget}</code>?
Are you sure you want to delete <code className="font-mono">{deleteTarget?.path}</code>?
</p>
<DialogFooter>
<DialogClose asChild>
@@ -264,7 +811,10 @@ export function FilesPage() {
</DialogClose>
<Button
variant="destructive"
onClick={() => deleteTarget && deleteMutation.mutate([deleteTarget])}
onClick={() => {
if (!deleteTarget) return;
deleteMutation.mutate([deleteTarget.path]);
}}
disabled={deleteMutation.isPending}
>
Delete
@@ -272,8 +822,6 @@ export function FilesPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
);
}
+1 -3
View File
@@ -21,9 +21,7 @@ export function PlayersPage() {
const { data, isLoading, refetch } = useQuery({
queryKey: ['players', orgId, serverId],
queryFn: () =>
api.get<PlayerListResponse>(
`/organizations/${orgId}/servers/${serverId}/players`,
),
api.get<PlayerListResponse>(`/organizations/${orgId}/servers/${serverId}/players`),
refetchInterval: 30000,
});
File diff suppressed because it is too large Load Diff
+34 -28
View File
@@ -63,30 +63,25 @@ export function SchedulesPage() {
const { data } = useQuery({
queryKey: ['schedules', orgId, serverId],
queryFn: () =>
api.get<{ tasks: ScheduledTask[] }>(
`/organizations/${orgId}/servers/${serverId}/schedules`,
),
api.get<{ tasks: ScheduledTask[] }>(`/organizations/${orgId}/servers/${serverId}/schedules`),
});
const deleteMutation = useMutation({
mutationFn: (taskId: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
});
const triggerMutation = useMutation({
mutationFn: (taskId: string) =>
api.post(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}/trigger`, {}),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
});
const toggleMutation = useMutation({
mutationFn: ({ taskId, isActive }: { taskId: string; isActive: boolean }) =>
api.patch(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`, { isActive }),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
});
const tasks = data?.tasks ?? [];
@@ -150,14 +145,10 @@ export function SchedulesPage() {
{formatSchedule(task.scheduleType, task.scheduleData)}
</span>
{task.nextRunAt && (
<span>
Next: {new Date(task.nextRunAt).toLocaleString()}
</span>
<span>Next: {new Date(task.nextRunAt).toLocaleString()}</span>
)}
{task.lastRunAt && (
<span>
Last: {new Date(task.lastRunAt).toLocaleString()}
</span>
<span>Last: {new Date(task.lastRunAt).toLocaleString()}</span>
)}
</div>
{task.action === 'command' && (
@@ -188,11 +179,7 @@ export function SchedulesPage() {
}
title={task.isActive ? 'Pause' : 'Resume'}
>
{task.isActive ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
{task.isActive ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
@@ -243,7 +230,9 @@ function CreateScheduleForm({
const [name, setName] = useState('');
const [action, setAction] = useState<'command' | 'power' | 'backup'>('command');
const [payload, setPayload] = useState('');
const [scheduleType, setScheduleType] = useState<'interval' | 'daily' | 'weekly' | 'cron'>('interval');
const [scheduleType, setScheduleType] = useState<'interval' | 'daily' | 'weekly' | 'cron'>(
'interval',
);
// Schedule data fields
const [minutes, setMinutes] = useState('60');
@@ -268,7 +257,11 @@ function CreateScheduleForm({
case 'daily':
return { hour: parseInt(hour, 10), minute: parseInt(minute, 10) };
case 'weekly':
return { dayOfWeek: parseInt(dayOfWeek, 10), hour: parseInt(hour, 10), minute: parseInt(minute, 10) };
return {
dayOfWeek: parseInt(dayOfWeek, 10),
hour: parseInt(hour, 10),
minute: parseInt(minute, 10),
};
case 'cron':
return { expression: cronExpression };
}
@@ -301,7 +294,9 @@ function CreateScheduleForm({
<div className="grid gap-1.5">
<Label>Action</Label>
<Select value={action} onValueChange={(v) => setAction(v as typeof action)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="command">Run Command</SelectItem>
<SelectItem value="power">Power Action</SelectItem>
@@ -322,7 +317,9 @@ function CreateScheduleForm({
/>
) : (
<Select value={payload} onValueChange={setPayload}>
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="start">Start</SelectItem>
<SelectItem value="stop">Stop</SelectItem>
@@ -337,8 +334,13 @@ function CreateScheduleForm({
<div className="grid gap-1.5">
<Label>Schedule Type</Label>
<Select value={scheduleType} onValueChange={(v) => setScheduleType(v as typeof scheduleType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<Select
value={scheduleType}
onValueChange={(v) => setScheduleType(v as typeof scheduleType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="interval">Interval</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
@@ -366,10 +368,14 @@ function CreateScheduleForm({
<div className="col-span-2 grid gap-1.5">
<Label>Day of Week</Label>
<Select value={dayOfWeek} onValueChange={setDayOfWeek}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAYS_OF_WEEK.map((day, i) => (
<SelectItem key={day} value={String(i)}>{day}</SelectItem>
<SelectItem key={day} value={String(i)}>
{day}
</SelectItem>
))}
</SelectContent>
</Select>
+585 -8
View File
@@ -1,40 +1,366 @@
import { useState } from 'react';
import { useParams, useOutletContext } from 'react-router';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { useEffect, useState } from 'react';
import { useNavigate, useOutletContext, useParams } from 'react-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { ApiError, api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { formatBytes } from '@/lib/utils';
interface ServerDetail {
id: string;
gameId: string;
name: string;
description?: string;
memoryLimit: number;
diskLimit: number;
cpuLimit: number;
startupOverride?: string;
startupOverride?: string | null;
environment?: Record<string, string>;
}
interface GameEnvironmentVar {
key: string;
label?: string;
default?: string;
description?: string;
required?: boolean;
inputType?: 'text' | 'boolean';
composeInto?: string;
flagValue?: string;
enabledLabel?: string;
disabledLabel?: string;
}
interface GameDefinition {
id: string;
startupCommand: string;
environmentVars?: GameEnvironmentVar[];
}
interface EnvironmentField {
key: string;
label: string;
value: string;
defaultValue: string;
description: string;
required: boolean;
inputType: 'text' | 'boolean';
composeInto?: string;
flagValue?: string;
enabledLabel?: string;
disabledLabel?: string;
isCustom: boolean;
}
type AutomationEvent =
| 'server.created'
| 'server.install.completed'
| 'server.power.started'
| 'server.power.stopped';
interface AutomationRunResult {
workflowsMatched: number;
workflowsExecuted: number;
workflowsSkipped: number;
workflowsFailed: number;
actionFailures: number;
failures: Array<{
level: 'action' | 'workflow';
workflowId: string;
actionId?: string;
message: string;
}>;
}
interface AutomationRunResponse {
success: boolean;
event: AutomationEvent;
force: boolean;
result: AutomationRunResult;
}
const AUTOMATION_EVENTS: AutomationEvent[] = [
'server.created',
'server.install.completed',
'server.power.started',
'server.power.stopped',
];
function extractApiMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
const maybeMessage = (error.data as { message?: unknown }).message;
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
return maybeMessage;
}
}
return fallback;
}
function normalizeStringRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const normalized: Record<string, string> = {};
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
const normalizedKey = key.trim();
if (!normalizedKey) continue;
normalized[normalizedKey] = String(entryValue ?? '');
}
return normalized;
}
function buildEnvironmentFields(
game: GameDefinition | undefined,
serverEnvironment: unknown,
): EnvironmentField[] {
const overrides = normalizeStringRecord(serverEnvironment);
const fields: EnvironmentField[] = [];
const knownKeys = new Set<string>();
for (const variable of game?.environmentVars ?? []) {
const key = variable.key?.trim();
if (!key) continue;
const composeInto = variable.composeInto?.trim();
const flagValue = variable.flagValue?.trim();
if (!composeInto) {
knownKeys.add(key);
}
if (composeInto && flagValue) {
const baseValue = overrides[composeInto] ?? '';
const tokens = baseValue.trim() ? baseValue.trim().split(/\s+/) : [];
fields.push({
key,
label: variable.label?.trim() || key,
value: tokens.includes(flagValue) ? 'true' : 'false',
defaultValue: 'false',
description: variable.description ?? '',
required: Boolean(variable.required),
inputType: variable.inputType === 'boolean' ? 'boolean' : 'text',
composeInto,
flagValue,
enabledLabel: variable.enabledLabel,
disabledLabel: variable.disabledLabel,
isCustom: false,
});
continue;
}
fields.push({
key,
label: variable.label?.trim() || key,
value: overrides[key] ?? String(variable.default ?? ''),
defaultValue: String(variable.default ?? ''),
description: variable.description ?? '',
required: Boolean(variable.required),
inputType: variable.inputType === 'boolean' ? 'boolean' : 'text',
composeInto,
flagValue,
enabledLabel: variable.enabledLabel,
disabledLabel: variable.disabledLabel,
isCustom: false,
});
}
for (const [key, value] of Object.entries(overrides)) {
if (knownKeys.has(key)) continue;
fields.push({
key,
label: key,
value,
defaultValue: '',
description: '',
required: false,
inputType: 'text',
isCustom: true,
});
}
return fields;
}
function buildEnvironmentPayload(fields: EnvironmentField[]): Record<string, string> {
const payload: Record<string, string> = {};
const defaults = new Map<string, string>();
for (const field of fields) {
const key = field.key.trim();
if (!key) continue;
if (!field.isCustom) {
defaults.set(key, field.defaultValue);
}
if (field.isCustom) {
payload[key] = field.value;
continue;
}
if (field.composeInto) continue;
if (field.value !== field.defaultValue) {
payload[key] = field.value;
}
}
for (const field of fields) {
if (field.isCustom || !field.composeInto || !field.flagValue) continue;
const targetKey = field.composeInto.trim();
if (!targetKey) continue;
const defaultValue = defaults.get(targetKey) ?? '';
const currentValue = payload[targetKey] ?? defaultValue;
const tokens = currentValue.trim() ? currentValue.trim().split(/\s+/) : [];
const nextTokens = tokens.filter((token) => token !== field.flagValue);
if (field.value === 'true') {
nextTokens.push(field.flagValue);
}
const nextValue = nextTokens.join(' ').trim();
if (!nextValue || nextValue === defaultValue) {
delete payload[targetKey];
continue;
}
payload[targetKey] = nextValue;
}
return payload;
}
export function ServerSettingsPage() {
const { orgId, serverId } = useParams();
const navigate = useNavigate();
const { server } = useOutletContext<{ server?: ServerDetail }>();
const queryClient = useQueryClient();
const [name, setName] = useState(server?.name ?? '');
const [description, setDescription] = useState(server?.description ?? '');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [startupOverride, setStartupOverride] = useState('');
const [environmentFields, setEnvironmentFields] = useState<EnvironmentField[]>([]);
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>(
'server.install.completed',
);
const [forceAutomationRun, setForceAutomationRun] = useState(false);
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(
null,
);
const { data: gamesData } = useQuery({
queryKey: ['games'],
queryFn: () => api.get<{ data: GameDefinition[] }>('/games'),
});
const activeGame = (gamesData?.data ?? []).find((game) => game.id === server?.gameId);
const serverEnvironmentJson = JSON.stringify(server?.environment ?? {});
useEffect(() => {
if (!server) return;
setName(server.name);
setDescription(server.description ?? '');
}, [server?.id, server?.name, server?.description]);
useEffect(() => {
if (!server) return;
setStartupOverride(server.startupOverride ?? '');
setEnvironmentFields(buildEnvironmentFields(activeGame, server.environment));
}, [server?.id, server?.startupOverride, serverEnvironmentJson, activeGame]);
const updateMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.patch(`/organizations/${orgId}/servers/${serverId}`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] });
toast.success('Server settings saved');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to save server settings'));
},
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/organizations/${orgId}/servers/${serverId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
navigate(`/org/${orgId}/servers`);
},
});
const automationRunMutation = useMutation({
mutationFn: (body: { event: AutomationEvent; force: boolean }) =>
api.post<AutomationRunResponse>(
`/organizations/${orgId}/servers/${serverId}/automation/run`,
body,
),
onSuccess: (response) => {
setLastAutomationResult(response.result);
if (response.result.workflowsFailed > 0 || response.result.actionFailures > 0) {
const firstFailure = response.result.failures[0]?.message;
toast.error(
firstFailure
? `Automation failed: ${firstFailure}`
: `Automation completed with errors (${response.result.workflowsFailed} workflow failures)`,
);
return;
}
toast.success(
`Automation completed: ${response.result.workflowsExecuted} workflows executed`,
);
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to run automation'));
},
});
const updateEnvironmentField = (
index: number,
patch: Partial<Pick<EnvironmentField, 'key' | 'value'>>,
) => {
setEnvironmentFields((prev) =>
prev.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)),
);
};
const addCustomEnvironmentField = () => {
setEnvironmentFields((prev) => [
...prev,
{
key: '',
label: '',
value: '',
defaultValue: '',
description: '',
required: false,
inputType: 'text',
isCustom: true,
},
]);
};
const removeEnvironmentField = (index: number) => {
setEnvironmentFields((prev) => prev.filter((_, fieldIndex) => fieldIndex !== index));
};
const saveStartupSettings = () => {
updateMutation.mutate({
startupOverride: startupOverride.trim(),
environment: buildEnvironmentPayload(environmentFields),
});
};
return (
<div className="space-y-6">
<Card>
@@ -87,13 +413,264 @@ export function ServerSettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Startup</CardTitle>
<CardDescription>
Saving these values recreates the container with the same files and restarts it if it
was running.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Startup Override</Label>
<Input
value={startupOverride}
onChange={(e) => setStartupOverride(e.target.value)}
placeholder={activeGame?.startupCommand || 'Use image default command'}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the game default startup command or the image entrypoint.
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Environment Variables</Label>
<p className="text-xs text-muted-foreground">
Add custom keys for image-specific startup switches such as extra launch args.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addCustomEnvironmentField}>
<Plus className="h-4 w-4" />
Add Variable
</Button>
</div>
{environmentFields.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
This game does not define any startup variables yet.
</p>
) : (
<div className="space-y-3">
{environmentFields.map((field, index) =>
field.isCustom ? (
<div
key={`custom-${index}`}
className="grid gap-2 rounded-md border p-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"
>
<Input
value={field.key}
onChange={(e) => updateEnvironmentField(index, { key: e.target.value })}
placeholder="ENV_KEY"
className="font-mono text-sm"
/>
<Input
value={field.value}
onChange={(e) => updateEnvironmentField(index, { value: e.target.value })}
placeholder="value"
className="font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeEnvironmentField(index)}
aria-label="Remove environment variable"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<div key={field.key} className="grid gap-1.5 rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<Label className="font-mono text-xs text-muted-foreground">
{field.label}
</Label>
<span className="text-[11px] text-muted-foreground">
Default:{' '}
<span className="font-mono">{field.defaultValue || 'empty'}</span>
</span>
</div>
{field.inputType === 'boolean' ? (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={field.value === 'true' ? 'default' : 'outline'}
size="sm"
onClick={() => updateEnvironmentField(index, { value: 'true' })}
>
{field.enabledLabel ?? 'Enabled'}
</Button>
<Button
type="button"
variant={field.value === 'false' ? 'secondary' : 'outline'}
size="sm"
onClick={() => updateEnvironmentField(index, { value: 'false' })}
>
{field.disabledLabel ?? 'Disabled'}
</Button>
</div>
) : (
<Input
value={field.value}
onChange={(e) => updateEnvironmentField(index, { value: e.target.value })}
className="font-mono text-sm"
/>
)}
{(field.description || field.required) && (
<p className="text-xs text-muted-foreground">
{field.description || 'Required startup variable'}
{field.required ? ' Required.' : ''}
</p>
)}
</div>
),
)}
</div>
)}
</div>
<Button onClick={saveStartupSettings} disabled={updateMutation.isPending || !server}>
{updateMutation.isPending ? 'Applying...' : 'Save Startup Settings'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Automation</CardTitle>
<CardDescription>Manually trigger an automation event for this server</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Event</Label>
<Select
value={automationEvent}
onValueChange={(value) => setAutomationEvent(value as AutomationEvent)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTOMATION_EVENTS.map((eventName) => (
<SelectItem key={eventName} value={eventName}>
{eventName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={forceAutomationRun ? 'default' : 'outline'}
onClick={() => setForceAutomationRun((prev) => !prev)}
>
{forceAutomationRun ? 'Force: ON' : 'Force: OFF'}
</Button>
<Button
type="button"
onClick={() =>
automationRunMutation.mutate({ event: automationEvent, force: forceAutomationRun })
}
disabled={automationRunMutation.isPending}
>
{automationRunMutation.isPending ? 'Running...' : 'Run Automation Event'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Enabling force will rerun workflows that are marked runOncePerServer.
</p>
{lastAutomationResult && (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Matched</p>
<p className="text-lg font-semibold">{lastAutomationResult.workflowsMatched}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Executed</p>
<p className="text-lg font-semibold">{lastAutomationResult.workflowsExecuted}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Skipped</p>
<p className="text-lg font-semibold">{lastAutomationResult.workflowsSkipped}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Failed</p>
<p className="text-lg font-semibold">{lastAutomationResult.workflowsFailed}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">Action Failures</p>
<p className="text-lg font-semibold">{lastAutomationResult.actionFailures}</p>
</div>
</div>
{lastAutomationResult.failures.length > 0 && (
<div className="space-y-2 rounded-md border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">Failure Details</p>
<div className="space-y-1">
{lastAutomationResult.failures.slice(0, 5).map((failure, index) => (
<p
key={`${failure.workflowId}-${failure.actionId ?? 'workflow'}-${index}`}
className="text-xs text-destructive"
>
[{failure.workflowId}
{failure.actionId ? ` > ${failure.actionId}` : ''}] {failure.message}
</p>
))}
</div>
</div>
)}
</div>
)}
{automationRunMutation.isSuccess &&
lastAutomationResult &&
lastAutomationResult.failures.length === 0 && (
<p className="text-xs text-green-600">Automation run completed successfully.</p>
)}
{automationRunMutation.isSuccess &&
lastAutomationResult &&
lastAutomationResult.failures.length > 0 && (
<p className="text-xs text-destructive">
Automation run completed with {lastAutomationResult.failures.length} error(s).
</p>
)}
{automationRunMutation.isError && (
<p className="text-xs text-destructive">Failed to run automation event.</p>
)}
</CardContent>
</Card>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>Irreversible actions</CardDescription>
</CardHeader>
<CardContent>
<Button variant="destructive">Delete Server</Button>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (
!window.confirm('Delete this server permanently? This action cannot be undone.')
) {
return;
}
deleteMutation.mutate();
}}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete Server'}
</Button>
</CardContent>
</Card>
</div>
+271 -10
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
@@ -7,15 +7,34 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface Game {
environmentVars?: GameEnvironmentVar[];
id: string;
name: string;
slug: string;
dockerImage: string;
}
interface GameEnvironmentVar {
key: string;
label?: string;
default?: string;
description?: string;
required?: boolean;
inputType?: 'text' | 'boolean';
composeInto?: string;
enabledLabel?: string;
disabledLabel?: string;
}
interface Node {
id: string;
name: string;
@@ -36,6 +55,58 @@ interface PaginatedResponse<T> {
meta: { total: number };
}
interface AdditionalPortRequirement {
key: string;
label: string;
defaultPort: number;
protocols: Array<'tcp' | 'udp'>;
description: string;
}
function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] {
const slug = gameSlug.trim().toLowerCase();
if (slug === 'satisfactory') {
return [
{
key: 'satisfactory-messaging',
label: 'Messaging Port',
defaultPort: 8888,
protocols: ['tcp'],
description: 'Required by the Satisfactory server messaging API.',
},
];
}
if (slug === 'ark-se') {
return [
{
key: 'ark-raw-udp',
label: 'Raw UDP Socket Port',
defaultPort: 7778,
protocols: ['udp'],
description: 'ARK opens a second UDP socket, normally the game port + 1.',
},
{
key: 'ark-query',
label: 'Steam Query Port',
defaultPort: 27015,
protocols: ['udp'],
description: 'Used by the Steam server browser to list the server.',
},
{
key: 'ark-rcon',
label: 'RCON Port',
defaultPort: 27020,
protocols: ['tcp'],
description: 'Console commands and the player list are sent over RCON.',
},
];
}
return [];
}
export function CreateServerPage() {
const { orgId } = useParams();
const navigate = useNavigate();
@@ -46,13 +117,17 @@ export function CreateServerPage() {
const [gameId, setGameId] = useState('');
const [nodeId, setNodeId] = useState('');
const [allocationId, setAllocationId] = useState('');
const [additionalAllocationIds, setAdditionalAllocationIds] = useState<Record<string, string>>(
{},
);
const [memoryLimit, setMemoryLimit] = useState(1024);
const [diskLimit, setDiskLimit] = useState(5120);
const [cpuLimit, setCpuLimit] = useState(100);
const [environment, setEnvironment] = useState<Record<string, string>>({});
const { data: gamesData } = useQuery({
queryKey: ['admin-games'],
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
queryKey: ['games'],
queryFn: () => api.get<PaginatedResponse<Game>>('/games'),
});
const { data: nodesData } = useQuery({
@@ -63,9 +138,7 @@ export function CreateServerPage() {
const { data: allocationsData } = useQuery({
queryKey: ['allocations', orgId, nodeId],
queryFn: () =>
api.get<PaginatedResponse<Allocation>>(
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
),
api.get<PaginatedResponse<Allocation>>(`/organizations/${orgId}/nodes/${nodeId}/allocations`),
enabled: !!nodeId,
});
@@ -81,8 +154,60 @@ export function CreateServerPage() {
const games = gamesData?.data ?? [];
const nodes = nodesData?.data ?? [];
const activeGame = games.find((game) => game.id === gameId);
const additionalPortRequirements = activeGame
? additionalPortRequirementsForGame(activeGame.slug)
: [];
const visibleEnvironmentVars = (activeGame?.environmentVars ?? []).filter((variable) => {
const key = variable.key?.trim();
return Boolean(key) && !variable.composeInto?.trim();
});
const missingRequiredEnvironment = visibleEnvironmentVars.some((variable) => {
const key = variable.key.trim();
const currentValue = environment[key] ?? String(variable.default ?? '');
return Boolean(variable.required) && !currentValue.trim();
});
const missingRequiredAdditionalPorts = additionalPortRequirements.some(
(requirement) => !additionalAllocationIds[requirement.key],
);
useEffect(() => {
if (!activeGame) {
setEnvironment({});
return;
}
setEnvironment((current) => {
const next: Record<string, string> = {};
for (const variable of activeGame.environmentVars ?? []) {
const key = variable.key?.trim();
if (!key || variable.composeInto?.trim()) continue;
next[key] = current[key] ?? String(variable.default ?? '');
}
return next;
});
setAdditionalAllocationIds({});
if (activeGame.slug.trim().toLowerCase() === 'satisfactory') {
setMemoryLimit((current) => Math.max(current, 8192));
setDiskLimit((current) => Math.max(current, 12288));
}
}, [activeGame?.id]);
useEffect(() => {
setAdditionalAllocationIds((current) => {
const allowedKeys = new Set(additionalPortRequirements.map((requirement) => requirement.key));
const next = Object.fromEntries(
Object.entries(current).filter(([key]) => allowedKeys.has(key)),
);
return next;
});
}, [activeGame?.slug]);
const handleCreate = () => {
const environmentPayload = Object.fromEntries(
Object.entries(environment).filter(([, value]) => value.trim() !== ''),
);
createMutation.mutate({
name,
description: description || undefined,
@@ -92,9 +217,30 @@ export function CreateServerPage() {
memoryLimit: memoryLimit * 1024 * 1024,
diskLimit: diskLimit * 1024 * 1024,
cpuLimit,
additionalAllocationIds:
additionalPortRequirements.length > 0
? additionalPortRequirements
.map((requirement) => additionalAllocationIds[requirement.key])
.filter(Boolean)
: undefined,
environment: Object.keys(environmentPayload).length > 0 ? environmentPayload : undefined,
});
};
const allocationOptionsForRequirement = (requirementKey: string) => {
const selectedByOtherRequirements = new Set(
Object.entries(additionalAllocationIds)
.filter(([key]) => key !== requirementKey)
.map(([, id]) => id)
.filter(Boolean),
);
return freeAllocations.filter(
(allocation) =>
allocation.id !== allocationId && !selectedByOtherRequirements.has(allocation.id),
);
};
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
@@ -149,7 +295,69 @@ export function CreateServerPage() {
</SelectContent>
</Select>
</div>
<Button onClick={() => setStep(2)} disabled={!name || !gameId}>
{visibleEnvironmentVars.map((variable) => {
const key = variable.key.trim();
const label = variable.label?.trim() || key;
const value = environment[key] ?? String(variable.default ?? '');
const isSecret =
key.toLowerCase().includes('password') || key.toLowerCase().includes('license');
return (
<div key={key} className="space-y-2">
<Label>
{label}
{variable.required ? ' *' : ''}
</Label>
{variable.inputType === 'boolean' ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={value === 'true' ? 'default' : 'outline'}
onClick={() =>
setEnvironment((current) => ({
...current,
[key]: 'true',
}))
}
>
{variable.enabledLabel ?? 'Enabled'}
</Button>
<Button
type="button"
variant={value === 'false' ? 'secondary' : 'outline'}
onClick={() =>
setEnvironment((current) => ({
...current,
[key]: 'false',
}))
}
>
{variable.disabledLabel ?? 'Disabled'}
</Button>
</div>
) : (
<Input
type={isSecret ? 'password' : 'text'}
value={value}
onChange={(e) =>
setEnvironment((current) => ({
...current,
[key]: e.target.value,
}))
}
placeholder={variable.description || label}
/>
)}
{variable.description && (
<p className="text-xs text-muted-foreground">{variable.description}</p>
)}
</div>
);
})}
<Button
onClick={() => setStep(2)}
disabled={!name || !gameId || missingRequiredEnvironment}
>
Next
</Button>
</CardContent>
@@ -170,6 +378,7 @@ export function CreateServerPage() {
onValueChange={(v) => {
setNodeId(v);
setAllocationId('');
setAdditionalAllocationIds({});
}}
>
<SelectTrigger>
@@ -187,7 +396,17 @@ export function CreateServerPage() {
{nodeId && (
<div className="space-y-2">
<Label>Port Allocation</Label>
<Select value={allocationId} onValueChange={setAllocationId}>
<Select
value={allocationId}
onValueChange={(value) => {
setAllocationId(value);
setAdditionalAllocationIds((current) =>
Object.fromEntries(
Object.entries(current).filter(([, selectedId]) => selectedId !== value),
),
);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a port" />
</SelectTrigger>
@@ -204,11 +423,53 @@ export function CreateServerPage() {
)}
</div>
)}
{nodeId &&
additionalPortRequirements.map((requirement) => {
const options = allocationOptionsForRequirement(requirement.key);
const protocols = requirement.protocols.join('/').toUpperCase();
return (
<div key={requirement.key} className="space-y-2">
<Label>
{requirement.label} ({protocols})
</Label>
<Select
value={additionalAllocationIds[requirement.key] ?? ''}
onValueChange={(value) =>
setAdditionalAllocationIds((current) => ({
...current,
[requirement.key]: value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder={`Select port ${requirement.defaultPort}`} />
</SelectTrigger>
<SelectContent>
{options.map((allocation) => (
<SelectItem key={allocation.id} value={allocation.id}>
{allocation.ip}:{allocation.port}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{requirement.description}</p>
{options.length === 0 && (
<p className="text-sm text-destructive">
No free allocation available for this port
</p>
)}
</div>
);
})}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(1)}>
Back
</Button>
<Button onClick={() => setStep(3)} disabled={!nodeId || !allocationId}>
<Button
onClick={() => setStep(3)}
disabled={!nodeId || !allocationId || missingRequiredAdditionalPorts}
>
Next
</Button>
</div>
+90
View File
@@ -0,0 +1,90 @@
import { useParams, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { Server, Plus } from 'lucide-react';
import { api } from '@/lib/api';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { statusBadgeVariant } from '@/lib/utils';
interface ServerSummary {
id: string;
uuid: string;
name: string;
status: string;
gameName: string;
nodeName: string;
port: number;
}
interface PaginatedResponse<T> {
data: T[];
meta: { total: number; page: number; perPage: number; totalPages: number };
}
export function ServersPage() {
const { orgId } = useParams();
const { data: serversData } = useQuery({
queryKey: ['servers', orgId],
queryFn: () => api.get<PaginatedResponse<ServerSummary>>(`/organizations/${orgId}/servers`),
});
const servers = serversData?.data ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Servers</h1>
<p className="text-muted-foreground">
{servers.length} server{servers.length !== 1 ? 's' : ''}
</p>
</div>
<Link to={`/org/${orgId}/servers/new`}>
<Button>
<Plus className="h-4 w-4" />
New Server
</Button>
</Link>
</div>
{servers.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Server className="mb-4 h-12 w-12 text-muted-foreground/50" />
<p className="text-muted-foreground">No servers yet</p>
<Link to={`/org/${orgId}/servers/new`}>
<Button variant="outline" className="mt-4">
Create your first server
</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{servers.map((server) => (
<Link key={server.id} to={`/org/${orgId}/servers/${server.id}/console`}>
<Card className="transition-colors hover:border-primary/50">
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Server className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{server.name}</p>
<p className="text-sm text-muted-foreground">
{server.gameName} &middot; {server.nodeName} &middot; :{server.port}
</p>
</div>
</div>
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+75 -8
View File
@@ -16,7 +16,13 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface Member {
id: string;
@@ -24,6 +30,32 @@ interface Member {
username: string;
email: string;
role: 'admin' | 'user';
customPermissions: Record<string, boolean>;
}
type MembershipPreset = 'admin' | 'moderator' | 'user';
const MODERATOR_PERMISSIONS: Record<string, boolean> = {
'plugin.manage': true,
};
function getMemberPreset(member: Member): MembershipPreset {
if (member.role === 'admin') return 'admin';
if (member.customPermissions?.['plugin.manage']) return 'moderator';
return 'user';
}
function buildPresetPayload(preset: MembershipPreset): {
role: 'admin' | 'user';
customPermissions: Record<string, boolean>;
} {
if (preset === 'admin') {
return { role: 'admin', customPermissions: {} };
}
if (preset === 'moderator') {
return { role: 'user', customPermissions: MODERATOR_PERMISSIONS };
}
return { role: 'user', customPermissions: {} };
}
export function MembersPage() {
@@ -32,12 +64,15 @@ export function MembersPage() {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState('');
const [role, setRole] = useState<'admin' | 'user'>('user');
const [updatingMemberId, setUpdatingMemberId] = useState<string | null>(null);
const { data: members } = useQuery({
const { data: membersData } = useQuery({
queryKey: ['members', orgId],
queryFn: () => api.get<Member[]>(`/organizations/${orgId}/members`),
queryFn: () => api.get<{ data: Member[] }>(`/organizations/${orgId}/members`),
});
const members = membersData?.data ?? [];
const addMutation = useMutation({
mutationFn: (body: { email: string; role: string }) =>
api.post(`/organizations/${orgId}/members`, body),
@@ -49,13 +84,26 @@ export function MembersPage() {
});
const removeMutation = useMutation({
mutationFn: (memberId: string) =>
api.delete(`/organizations/${orgId}/members/${memberId}`),
mutationFn: (memberId: string) => api.delete(`/organizations/${orgId}/members/${memberId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['members', orgId] });
},
});
const updateMutation = useMutation({
mutationFn: ({ memberId, preset }: { memberId: string; preset: MembershipPreset }) =>
api.patch(`/organizations/${orgId}/members/${memberId}`, buildPresetPayload(preset)),
onMutate: ({ memberId }) => {
setUpdatingMemberId(memberId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['members', orgId] });
},
onSettled: () => {
setUpdatingMemberId(null);
},
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -114,16 +162,35 @@ export function MembersPage() {
<Card>
<CardContent className="p-0">
<div className="divide-y">
{(members ?? []).map((member) => (
{members.map((member) => (
<div key={member.id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="font-medium">{member.username}</p>
<p className="text-sm text-muted-foreground">{member.email}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={member.role === 'admin' ? 'default' : 'secondary'}>
{member.role}
<Badge variant={getMemberPreset(member) === 'admin' ? 'default' : 'secondary'}>
{getMemberPreset(member)}
</Badge>
<Select
value={getMemberPreset(member)}
onValueChange={(value) =>
updateMutation.mutate({
memberId: member.id,
preset: value as MembershipPreset,
})
}
disabled={updateMutation.isPending && updatingMemberId === member.id}
>
<SelectTrigger className="h-8 w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
+2 -2
View File
@@ -56,8 +56,8 @@ export const useAuthStore = create<AuthState>((set) => ({
fetchUser: async () => {
try {
const user = await api.get<User>('/auth/me');
set({ user, isAuthenticated: true, isLoading: false });
const data = await api.get<{ user: User }>('/auth/me');
set({ user: data.user, isAuthenticated: true, isLoading: false });
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
set({ user: null, isAuthenticated: false, isLoading: false });
@@ -0,0 +1,82 @@
=== CONDUIT CRASH REPORT ==========================================
Framework : Conduit 0.1.0-dev
Time : 2026-06-13 11:34:58 (local)
Process : pid=36
Signal : 11 (Segmentation fault), fault address (nil)
--- ATTRIBUTION ---------------------------------------------------
1. owner=crash_test_plugin callsite=conduit_crash_test
>>> Most likely culprit: 'crash_test_plugin' (in 'conduit_crash_test')
--- STACK TRACE ---------------------------------------------------
#00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbfb64) [0x7fdf9e694b64]
#01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050]
#02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf66c) [0x7fdf9e69466c]
#03 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902]
--- RECENT EVENTS (oldest first) ----------------------------------
[-1767.736s] [INFO] perf: heartbeat: 1921 frames, avg 2.22 ms, max 20.85 ms (last 30 s)
[-1737.735s] [INFO] perf: heartbeat: 1920 frames, avg 2.16 ms, max 5.31 ms (last 30 s)
[-1707.720s] [INFO] perf: heartbeat: 1921 frames, avg 2.12 ms, max 4.39 ms (last 30 s)
[-1677.719s] [INFO] perf: heartbeat: 1920 frames, avg 2.20 ms, max 5.59 ms (last 30 s)
[-1647.706s] [INFO] perf: heartbeat: 1921 frames, avg 2.39 ms, max 23.56 ms (last 30 s)
[-1617.705s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 7.46 ms (last 30 s)
[-1587.691s] [INFO] perf: heartbeat: 1921 frames, avg 2.37 ms, max 8.58 ms (last 30 s)
[-1557.680s] [INFO] perf: heartbeat: 1921 frames, avg 2.33 ms, max 4.84 ms (last 30 s)
[-1527.679s] [INFO] perf: heartbeat: 1920 frames, avg 2.25 ms, max 5.09 ms (last 30 s)
[-1497.674s] [INFO] perf: heartbeat: 1920 frames, avg 2.18 ms, max 19.43 ms (last 30 s)
[-1467.673s] [INFO] perf: heartbeat: 1920 frames, avg 2.30 ms, max 6.87 ms (last 30 s)
[-1437.669s] [INFO] perf: heartbeat: 1920 frames, avg 2.23 ms, max 34.46 ms (last 30 s)
[-1407.668s] [INFO] perf: heartbeat: 1920 frames, avg 2.45 ms, max 6.17 ms (last 30 s)
[-1377.653s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.25 ms (last 30 s)
[-1347.653s] [INFO] perf: heartbeat: 1920 frames, avg 2.53 ms, max 5.44 ms (last 30 s)
[-1317.644s] [INFO] perf: heartbeat: 1921 frames, avg 2.51 ms, max 20.77 ms (last 30 s)
[-1287.631s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.45 ms (last 30 s)
[-1257.614s] [INFO] perf: heartbeat: 1921 frames, avg 2.57 ms, max 5.90 ms (last 30 s)
[-1227.600s] [INFO] perf: heartbeat: 1921 frames, avg 2.54 ms, max 5.79 ms (last 30 s)
[-1197.583s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 6.94 ms (last 30 s)
[-1167.578s] [INFO] perf: heartbeat: 1920 frames, avg 2.46 ms, max 20.36 ms (last 30 s)
[-1137.562s] [INFO] perf: heartbeat: 1921 frames, avg 2.47 ms, max 7.86 ms (last 30 s)
[-1107.548s] [INFO] perf: heartbeat: 1921 frames, avg 2.53 ms, max 6.38 ms (last 30 s)
[-1077.547s] [INFO] perf: heartbeat: 1920 frames, avg 2.54 ms, max 4.09 ms (last 30 s)
[-1047.543s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 33.69 ms (last 30 s)
[-1017.532s] [INFO] perf: heartbeat: 1921 frames, avg 2.44 ms, max 5.97 ms (last 30 s)
[- 987.516s] [INFO] perf: heartbeat: 1921 frames, avg 2.59 ms, max 5.71 ms (last 30 s)
[- 957.501s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 7.43 ms (last 30 s)
[- 927.501s] [INFO] perf: heartbeat: 1920 frames, avg 2.60 ms, max 9.17 ms (last 30 s)
[- 897.490s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 24.84 ms (last 30 s)
[- 867.475s] [INFO] perf: heartbeat: 1921 frames, avg 2.58 ms, max 6.20 ms (last 30 s)
[- 837.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 5.57 ms (last 30 s)
[- 807.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 6.15 ms (last 30 s)
[- 777.460s] [INFO] perf: heartbeat: 1921 frames, avg 2.70 ms, max 5.47 ms (last 30 s)
[- 747.449s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 24.22 ms (last 30 s)
[- 717.434s] [INFO] perf: heartbeat: 1921 frames, avg 2.80 ms, max 8.64 ms (last 30 s)
[- 687.419s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.28 ms (last 30 s)
[- 657.418s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 6.34 ms (last 30 s)
[- 627.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 31.89 ms (last 30 s)
[- 597.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 6.72 ms (last 30 s)
[- 567.400s] [INFO] perf: heartbeat: 1921 frames, avg 2.67 ms, max 6.53 ms (last 30 s)
[- 537.384s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.66 ms (last 30 s)
[- 507.369s] [INFO] perf: heartbeat: 1921 frames, avg 2.55 ms, max 5.30 ms (last 30 s)
[- 477.365s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 34.05 ms (last 30 s)
[- 447.364s] [INFO] perf: heartbeat: 1920 frames, avg 2.59 ms, max 5.42 ms (last 30 s)
[- 417.349s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.55 ms (last 30 s)
[- 387.334s] [INFO] perf: heartbeat: 1921 frames, avg 2.34 ms, max 4.63 ms (last 30 s)
[- 357.334s] [INFO] perf: heartbeat: 1920 frames, avg 2.77 ms, max 6.65 ms (last 30 s)
[- 327.332s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 26.14 ms (last 30 s)
[- 312.170s] detour 'detour_self_test' installed at 0x7fdf9e693cd0
[- 312.170s] [INFO] hooks: detour 'detour_self_test' installed at 0x7fdf9e693cd0
[- 312.169s] detour 'detour_self_test' removed
[- 312.169s] [INFO] hooks: detour 'detour_self_test' removed
[- 297.318s] [INFO] perf: heartbeat: 1921 frames, avg 2.73 ms, max 6.96 ms (last 30 s)
[- 267.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.58 ms, max 6.68 ms (last 30 s)
[- 237.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 7.08 ms (last 30 s)
[- 207.296s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 25.68 ms (last 30 s)
[- 177.293s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 5.12 ms (last 30 s)
[- 147.280s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.80 ms (last 30 s)
[- 117.280s] [INFO] perf: heartbeat: 1920 frames, avg 2.64 ms, max 6.24 ms (last 30 s)
[- 87.265s] [INFO] perf: heartbeat: 1921 frames, avg 2.78 ms, max 5.95 ms (last 30 s)
[- 57.263s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 31.12 ms (last 30 s)
[- 27.248s] [INFO] perf: heartbeat: 1921 frames, avg 2.72 ms, max 7.34 ms (last 30 s)
[- 0.001s] [WARN] core: conduit_crash_test invoked — crashing deliberately
===================================================================
@@ -0,0 +1,374 @@
# Conduit Linux Accumulated Re-run - 2026-06-15
## Scope
- Pulled all accumulated commits from `origin/main`.
- Inspected the changed files and current `docs/linux-bringup.md`.
- Rebuilt Conduit on Linux with GCC 12.
- Deployed to CS2 server `Test Sunucusu`.
- Verified the new timer, chat interception, usermessage/chat-send, and console adopt paths.
- Cleaned temporary test subscriptions/timers/commands.
- Satisfactory server was not modified.
## Git
Previous local HEAD before this run:
```text
bb58461 Phase 2: dynamic console commands + convar access
```
Pulled commits:
```text
43855ff PLAN: chat ships plain (white) via UTIL_SayTextFilter; color is a follow-up
8afbeec chat: send via UTIL_SayTextFilter again (plain text works; SayText2 shows nothing)
faded69 docs: refresh README (Phase 2 status + what works) and PLAN chat-send entry
18e7970 chat colors: prefer UTIL_SayText2Filter (renders color codes)
1743147 PLAN: chat send visually verified on Windows (msgType 0 = chat); colors pending
4b3d322 PLAN: correct the chat-send entry (native UTIL_SayTextFilter, SDK drift finding)
4ba6019 chat sending: call native UTIL_SayTextFilter instead of building the usermessage
a4af862 Phase 2: usermessages + chat sending (SayText2)
6985830 Phase 2: chat interception (say / say_team)
a375e05 Phase 2: game-thread timers driven by GameFrame
62e4ca0 Close out Linux verification of the Commands & ConVars slice
15cc25e console: track adopted convars under their owner
```
Diff summary:
```text
PLAN.md | 8 +-
README.md | 17 +++-
core/CMakeLists.txt | 3 +
core/src/conduit/chat.cpp | 165 ++++++++++++++++++++++++++++++++++++++
core/src/conduit/chat.h | 46 +++++++++++
core/src/conduit/commands.cpp | 131 ++++++++++++++++++++++++++++++
core/src/conduit/console.cpp | 23 +++++-
core/src/conduit/timers.cpp | 181 ++++++++++++++++++++++++++++++++++++++++++
core/src/conduit/timers.h | 47 +++++++++++
core/src/conduit/usermsg.cpp | 147 ++++++++++++++++++++++++++++++++++
core/src/conduit/usermsg.h | 23 ++++++
core/src/plugin.cpp | 16 ++++
docs/linux-bringup.md | 76 +++++++++++++++---
gamedata/core.json | 15 +++-
```
Working tree after the run:
```text
## main...origin/main
```
## Build
Command:
```sh
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_C_COMPILER=gcc-12 \
-DCMAKE_CXX_COMPILER=g++-12
cmake --build /root/codex/Conduit/build/linux-rerun
```
Result: build and link succeeded.
```text
-- Configuring done
-- Generating done
-- Build files have been written to: /root/codex/Conduit/build/linux-rerun
[1/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/timers.cpp.o
[2/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o
[3/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/usermsg.cpp.o
[4/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/console.cpp.o
[5/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/chat.cpp.o
[6/7] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o
[7/7] Linking CXX shared library package/addons/conduit/bin/conduit.so
```
Only warning observed:
```text
/root/codex/Conduit/core/src/conduit/commands.cpp:521:13: warning: compound assignment with volatile-qualified left operand is deprecated [-Wvolatile]
```
No failed CMake/Ninja output exists because the build passed.
## Deploy And Load
Deployed:
```text
/root/codex/Conduit/build/linux-rerun/package/.
```
To:
```text
/var/lib/gamepanel/servers/c2b47a90/game/csgo/
```
Load checks:
```text
--- meta list ---
Listing 1 plugin:
[01] Conduit (0.1.0-dev) by Conduit Contributors
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 73.6 s
game frames : 3983
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260615.log
crash report: armed (use conduit_crash_test to verify)
```
Gamedata and usermessage function resolution:
```text
--- conduit_gamedata ---
gamedata: 1 files, 2 signatures, 0 offsets
[ok] UTIL_SayText2Filter server 0x7fd2de250b30 (core.json)
[ok] UTIL_SayTextFilter server 0x7fd2de2508d0 (core.json)
--- conduit_usermsg ---
[Conduit] [INFO] usermsg: chat sending live via UTIL_SayTextFilter (0x7fd2de2508d0) - plain text, no color yet
usermessages: chat sending live (SayText2 resolved, SayText resolved)
```
Startup log:
```text
2026-06-15 10:50:26 [INFO] gamedata: 1 files loaded: 2 signatures, 0 offsets, all valid
2026-06-15 10:50:27 [INFO] entity: armed via CGameEntitySystem vtable 0x7fd2dec4ffd8
2026-06-15 10:50:27 [INFO] gameevents: armed via CGameEventManager vtable 0x7fd2dec50f50
2026-06-15 10:50:27 [INFO] chat: ready - hook installs on first subscriber
2026-06-15 10:50:27 [INFO] core: Conduit 0.1.0-dev loaded - crash reporter armed, profiler on
2026-06-15 10:50:27 [INFO] gameevents: game event manager connected (0x7fd2defd0460)
2026-06-15 10:50:37 [INFO] core: first GameFrame observed - hook dispatch confirmed
```
Entity system still resolves on Linux at `GameResourceServiceServerV001+0x50`:
```text
--- conduit_entity ---
[Conduit] [INFO] entity: entity system connected (0x7fd2da446000) via GameResourceServiceServerV001+0x50 - vtable matches CGameEntitySystem
entity system: connected
slot 0 cs_player_controller team=2 pawn=player health=100
slot 1 cs_player_controller team=3 pawn=player health=100
```
## Timer Tests
Schedule/list:
```text
--- conduit_timer_after 2 ---
[Conduit] one-shot timer #1 scheduled in 2.00 s
--- conduit_timer_every 1 ---
[Conduit] repeating timer #2 every 1.00 s (stops after 5)
--- conduit_timers ---
timers: 2 active
#1 owner=timer_test once next in 1938 ms
#2 owner=timer_test repeat next in 969 ms
```
Log block:
```text
2026-06-15 10:51:55 [INFO] timer_test: repeat timer tick 1
2026-06-15 10:51:56 [INFO] timer_test: one-shot timer fired
2026-06-15 10:51:56 [INFO] timer_test: repeat timer tick 2
2026-06-15 10:51:57 [INFO] timer_test: repeat timer tick 3
2026-06-15 10:51:58 [INFO] timer_test: repeat timer tick 4
2026-06-15 10:51:59 [INFO] timer_test: repeat timer tick 5
2026-06-15 10:51:59 [INFO] timer_test: repeat timer self-cancelled after 5 ticks
```
After firing:
```text
--- conduit_timers ---
timers: 0 active
--- conduit_prof ---
timer_test timer 6 71.5us 131.1us 131.1us 101.4us 429.0us
```
Group cancel:
```text
--- conduit_timer_every 1 ---
[Conduit] repeating timer #3 every 1.00 s (stops after 5)
--- conduit_timer_after 30 ---
[Conduit] one-shot timer #4 scheduled in 30.00 s
--- conduit_timers ---
timers: 2 active
#3 owner=timer_test repeat next in 937 ms
#4 owner=timer_test once next in 29969 ms
--- conduit_timer_stop ---
[Conduit] cancelled 2 test timer(s)
--- conduit_timers ---
timers: 0 active
```
Result: timers passed, including self-cancel from inside callback and owner group cancel.
## Chat Interception Tests
RCON output:
```text
--- conduit_chat ---
chat: 0 subscriber(s), interception hook off
--- conduit_chat_listen ---
[Conduit] [INFO] chat: 'chat_test' subscribed to chat
[Conduit] listening to chat
--- conduit_chat ---
chat: 1 subscriber(s), interception hook on
owner=chat_test hits=0
--- say "hello from server" ---
[Conduit] [INFO] chat_test: say slot=-1: "hello from server"
[All Chat][Console (0)]: "hello from server"
L 06/15/2026 - 10:53:03: "Console<0>" say ""hello from server""
--- say "this has badword inside" ---
[Conduit] [INFO] chat_test: say slot=-1: "this has badword inside" [SUPPRESSED]
--- say_team "x" ---
[Conduit] [INFO] chat_test: say_team slot=-1: "x"
--- conduit_prof ---
chat_test chat 3 97.0us 131.1us 131.1us 127.4us 290.9us
```
Conduit log:
```text
2026-06-15 10:53:03 [INFO] chat: 'chat_test' subscribed to chat
2026-06-15 10:53:03 [INFO] chat_test: say slot=-1: "hello from server"
2026-06-15 10:53:03 [INFO] chat_test: say slot=-1: "this has badword inside" [SUPPRESSED]
2026-06-15 10:53:03 [INFO] chat_test: say_team slot=-1: "x"
```
The suppressed `badword` message did not echo as `[All Chat]` in the RCON output.
Cleanup:
```text
--- conduit_chat_stop ---
[Conduit] removed 1 chat subscriber(s)
--- conduit_chat ---
chat: 0 subscriber(s), interception hook off
--- say "after stop visible" ---
[All Chat][Console (0)]: "after stop visible"
L 06/15/2026 - 10:53:20: "Console<0>" say ""after stop visible""
--- conduit_chat ---
chat: 0 subscriber(s), interception hook off
```
Result: chat interception, suppression, say_team distinction, profiler scope, and hook removal all passed.
## Chat Sending / UserMessages
Commands:
```text
--- conduit_usermsg ---
usermessages: chat sending live (SayText2 resolved, SayText resolved)
--- conduit_say linux broadcast test ---
[Conduit] PrintToAll sent
--- conduit_say_player 0 private_ping ---
[Conduit] PrintToPlayer(0) sent
--- conduit_usermsg ---
usermessages: chat sending live (SayText2 resolved, SayText resolved)
```
Result: Linux signatures resolve and native send calls return `sent`. This verifies the server-side path and no crash. I cannot visually confirm client rendering from this environment; per latest plan this path sends plain white text through `UTIL_SayTextFilter`.
## Console Adopt Fix
This re-tested the previously observed adopted-convar listing issue.
```text
--- conduit_console ---
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
[Conduit] [INFO] console: convar 'conduit_demo_value' created (owner 'console_demo')
console: ready, 1 command(s), 1 plugin convar(s)
cmd conduit_demo owner=console_demo
cvar conduit_demo_value owner=console_demo value=7
--- conduit_cvar conduit_demo_value 42 ---
[Conduit] set 'conduit_demo_value' = '42'
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
--- conduit_concmd_stop ---
[Conduit] removed 2 demo registration(s)
--- conduit_console ---
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
[Conduit] [INFO] console: convar 'conduit_demo_value' already registered - adopting (owner 'console_demo')
console: ready, 1 command(s), 1 plugin convar(s)
cmd conduit_demo owner=console_demo
cvar conduit_demo_value owner=console_demo value=42
```
Final cleanup:
```text
--- conduit_concmd_stop ---
[Conduit] removed 2 demo registration(s)
--- conduit_demo x ---
```
Result: adopt tracking is fixed. Adopted convar now appears under `1 plugin convar(s)` and owner cleanup removes 2 registrations.
## Final Runtime State
```text
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 248.6 s
game frames : 15184
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260615.log
crash report: armed (use conduit_crash_test to verify)
--- conduit_timers ---
timers: 0 active
--- conduit_chat ---
chat: 0 subscriber(s), interception hook off
--- conduit_events ---
game events: manager connected, 0 subscription(s), 0 event(s) registered, FireEvent detour off
--- conduit_usermsg ---
usermessages: chat sending live (SayText2 resolved, SayText resolved)
```
Final profiler:
```text
engine GameFrame 13848 2.77ms 4.19ms 8.39ms 39.34ms 38.34s
timer_test timer 6 71.5us 131.1us 131.1us 101.4us 429.0us
chat_test chat 3 97.0us 131.1us 131.1us 127.4us 290.9us
core SchemaFindField 3 13.2us 8.2us 32.8us 31.7us 39.5us
```
## Final Server State
Docker:
```text
/gp_c2b47a90 running running=true exit=0 oom=false
/gp_d8f13411 exited running=false exit=130 oom=false
```
Panel DB:
```text
uuid | name | status | slug | port
----------+----------------+---------+--------------+-------
c2b47a90 | Test Sunucusu | running | cs2 | 27015
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
(2 rows)
```
## Notes
- No Conduit/Metamod/signal-specific load failure or crash was observed.
- Server console contained ordinary CS2/Steam/map warnings and warmup long-frame messages.
- No watchdog stall block appeared during this run.
- CS2 panel status was `stopped` after Docker restart; I updated only the CS2 row back to `running`. Satisfactory remained `stopped`.

Some files were not shown because too many files have changed in this diff Show More