Compare commits
5 Commits
5215560ede
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 276150a769 | |||
| c1adb94abb | |||
| d0d3a58907 | |||
| 7ca55bc94d | |||
| 11924416a9 |
@@ -38,6 +38,17 @@ WEB_PORT=80
|
|||||||
# --- Daemon ---
|
# --- Daemon ---
|
||||||
DAEMON_CONFIG=/etc/gamepanel/config.yml
|
DAEMON_CONFIG=/etc/gamepanel/config.yml
|
||||||
DAEMON_GRPC_PORT=50051
|
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 (Plugin Artifacts) ---
|
||||||
CDN_BASE_URL=https://cdn.hibna.com.tr
|
CDN_BASE_URL=https://cdn.hibna.com.tr
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [main, develop]
|
||||||
|
tags: ["v*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
@@ -55,8 +56,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Install protoc
|
||||||
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
|
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
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
@@ -95,3 +102,54 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Daemon image
|
- name: Build Daemon image
|
||||||
run: docker build -f apps/daemon/Dockerfile -t gamepanel-daemon:ci .
|
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"
|
||||||
|
|||||||
+4
-2
@@ -23,8 +23,10 @@ Thumbs.db
|
|||||||
apps/daemon/target/
|
apps/daemon/target/
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
packages/database/drizzle/*
|
# Hand-written data migrations in drizzle/*.sql are part of the repo — the
|
||||||
!packages/database/drizzle/0007_satisfactory_game.sql
|
# 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
|
# Common JS/TS
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -3,3 +3,7 @@ dist
|
|||||||
.turbo
|
.turbo
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
apps/daemon/target
|
apps/daemon/target
|
||||||
|
|
||||||
|
# Captured bring-up reports, not maintained sources — reflowing them would
|
||||||
|
# only churn a record of what happened.
|
||||||
|
conduit-bringup-artifacts
|
||||||
|
|||||||
+238
-117
@@ -1,6 +1,7 @@
|
|||||||
# Installation Guide
|
# Installation Guide
|
||||||
|
|
||||||
This guide covers three deployment methods:
|
This guide covers three deployment methods:
|
||||||
|
|
||||||
1. **Development Setup** — for local development
|
1. **Development Setup** — for local development
|
||||||
2. **Docker Production** — single-command deployment with Docker Compose
|
2. **Docker Production** — single-command deployment with Docker Compose
|
||||||
3. **Manual Production** — step-by-step on Ubuntu 22.04+
|
3. **Manual Production** — step-by-step on Ubuntu 22.04+
|
||||||
@@ -10,10 +11,12 @@ This guide covers three deployment methods:
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### All Methods
|
### All Methods
|
||||||
|
|
||||||
- Git
|
- Git
|
||||||
- A PostgreSQL 16+ database (or use the included Docker Compose)
|
- A PostgreSQL 16+ database (or use the included Docker Compose)
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
- **Node.js** 20+ ([nodejs.org](https://nodejs.org))
|
- **Node.js** 20+ ([nodejs.org](https://nodejs.org))
|
||||||
- **pnpm** 9.15+ (`corepack enable && corepack prepare pnpm@9.15.4 --activate`)
|
- **pnpm** 9.15+ (`corepack enable && corepack prepare pnpm@9.15.4 --activate`)
|
||||||
- **Rust** 1.83+ ([rustup.rs](https://rustup.rs))
|
- **Rust** 1.83+ ([rustup.rs](https://rustup.rs))
|
||||||
@@ -21,6 +24,7 @@ This guide covers three deployment methods:
|
|||||||
- **Docker** — for running PostgreSQL and Redis locally
|
- **Docker** — for running PostgreSQL and Redis locally
|
||||||
|
|
||||||
### Docker Production
|
### Docker Production
|
||||||
|
|
||||||
- **Docker** 24+ with Docker Compose v2
|
- **Docker** 24+ with Docker Compose v2
|
||||||
- At least **2 GB RAM** and **10 GB disk** for the panel itself
|
- At least **2 GB RAM** and **10 GB disk** for the panel itself
|
||||||
- Additional resources for game servers on daemon nodes
|
- Additional resources for game servers on daemon nodes
|
||||||
@@ -65,19 +69,22 @@ docker compose -f docker-compose.dev.yml up -d
|
|||||||
### 1.4 Database Setup
|
### 1.4 Database Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate migration files (if schema changed)
|
# Sync the schema from packages/database/src/schema, then apply the
|
||||||
pnpm db:generate
|
# hand-written data migrations in packages/database/drizzle/*.sql
|
||||||
|
|
||||||
# Apply migrations to create all tables
|
|
||||||
pnpm db:migrate
|
pnpm db:migrate
|
||||||
|
|
||||||
# Seed admin user and default games
|
# Seed admin user and default games
|
||||||
pnpm db:seed
|
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:
|
After seeding, you'll have:
|
||||||
|
|
||||||
- **Admin account**: `admin@gamepanel.local` / `admin123`
|
- **Admin account**: `admin@gamepanel.local` / `admin123`
|
||||||
- **Games**: Minecraft Java, CS2, Minecraft Bedrock, Terraria, Rust
|
- **Games**: Minecraft Java & Bedrock, CS2, Terraria, Rust, Satisfactory,
|
||||||
|
FiveM, ARK: Survival Evolved
|
||||||
|
|
||||||
### 1.5 Start Development Servers
|
### 1.5 Start Development Servers
|
||||||
|
|
||||||
@@ -129,123 +136,156 @@ cargo build --release # Production build
|
|||||||
|
|
||||||
## 2. Docker Production Deployment
|
## 2. Docker Production Deployment
|
||||||
|
|
||||||
### 2.1 Prepare Environment
|
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
|
```bash
|
||||||
git clone https://github.com/your-org/source-gamepanel.git
|
git clone https://github.com/your-org/source-gamepanel.git
|
||||||
cd source-gamepanel
|
cd source-gamepanel
|
||||||
|
|
||||||
cp .env.example .env
|
./scripts/install.sh
|
||||||
```
|
|
||||||
|
|
||||||
Edit `.env` with production values:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# REQUIRED — Generate unique secrets for each!
|
|
||||||
JWT_SECRET=<generate-with-openssl-rand-hex-64>
|
|
||||||
JWT_REFRESH_SECRET=<generate-another-secret>
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_USER=gamepanel
|
|
||||||
DB_PASSWORD=<strong-random-password>
|
|
||||||
DB_NAME=gamepanel
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_PASSWORD=<strong-random-password>
|
|
||||||
|
|
||||||
# Networking
|
|
||||||
CORS_ORIGIN=https://panel.yourdomain.com
|
|
||||||
WEB_PORT=80
|
|
||||||
API_PORT=3000
|
|
||||||
|
|
||||||
# Rate limiting
|
|
||||||
RATE_LIMIT_MAX=100
|
|
||||||
RATE_LIMIT_WINDOW_MS=60000
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Configure Daemon
|
|
||||||
|
|
||||||
Edit `daemon-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
api_url: "http://api:3000"
|
|
||||||
node_token: "<generate-a-secure-token>"
|
|
||||||
grpc_port: 50051
|
|
||||||
data_path: "/var/lib/gamepanel/servers"
|
|
||||||
backup_path: "/var/lib/gamepanel/backups"
|
|
||||||
docker:
|
|
||||||
socket: "/var/run/docker.sock"
|
|
||||||
network: "gamepanel_nw"
|
|
||||||
network_subnet: "172.18.0.0/16"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 Build and Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and start all services
|
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts 5 services:
|
`scripts/install.sh` generates `.env` with fresh secrets, writes a
|
||||||
| Service | Port | Description |
|
`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
|
||||||
| `postgres` | 5432 | PostgreSQL database |
|
re-run.
|
||||||
| `redis` | 6379 | Rate limiting & cache |
|
|
||||||
| `api` | 3000 | Fastify REST API |
|
|
||||||
| `web` | 80 | nginx + React SPA |
|
|
||||||
| `daemon` | 50051 | Rust gRPC daemon |
|
|
||||||
|
|
||||||
### 2.4 Initialize Database
|
Then open `http://<server-ip>:80` and sign in with
|
||||||
|
`admin@gamepanel.local` / `admin123` — change the password immediately.
|
||||||
|
|
||||||
```bash
|
### 2.2 What gets started
|
||||||
# Run migrations
|
|
||||||
docker compose exec api node -e "
|
|
||||||
import('drizzle-kit').then(m => console.log('Use drizzle-kit migrate'))
|
|
||||||
"
|
|
||||||
|
|
||||||
# Or use the pnpm scripts with the container's DATABASE_URL
|
| Service | Port | Description |
|
||||||
docker compose exec api sh -c 'cd /app && node apps/api/dist/index.js'
|
| ---------- | -------------------------- | -------------------------------------------------- |
|
||||||
```
|
| `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 |
|
||||||
|
|
||||||
For the initial setup, the easiest approach is:
|
Only `web` and `daemon` publish ports. Postgres, Redis and the API stay on the
|
||||||
|
internal Compose network.
|
||||||
|
|
||||||
```bash
|
The `migrate` service runs on every `docker compose up`; all three of its steps
|
||||||
# Run migrations from your host machine pointed at the Docker PostgreSQL
|
(`drizzle-kit push`, the data migrations, the seed) are idempotent.
|
||||||
DATABASE_URL=postgresql://gamepanel:<your-password>@localhost:5432/gamepanel pnpm db:migrate
|
|
||||||
DATABASE_URL=postgresql://gamepanel:<your-password>@localhost:5432/gamepanel pnpm db:seed
|
### 2.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
|
### 2.5 Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check all services are healthy
|
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
# Test API health
|
|
||||||
curl http://localhost:3000/api/health
|
|
||||||
# {"status":"ok","timestamp":"2025-..."}
|
|
||||||
|
|
||||||
# Test web
|
|
||||||
curl -s http://localhost | head -5
|
|
||||||
# <!DOCTYPE html>...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.6 Monitoring
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View logs
|
|
||||||
docker compose logs -f api
|
docker compose logs -f api
|
||||||
docker compose logs -f daemon
|
docker compose logs -f daemon
|
||||||
docker compose logs -f web
|
|
||||||
|
|
||||||
# Restart a service
|
curl -s http://localhost/api/health
|
||||||
docker compose restart api
|
# {"status":"ok","timestamp":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
# Update to latest
|
### 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
|
git pull
|
||||||
docker compose up -d --build
|
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. Manual Production Setup (Ubuntu 22.04+)
|
||||||
@@ -498,6 +538,82 @@ 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
|
## Post-Installation
|
||||||
|
|
||||||
### First Login
|
### First Login
|
||||||
@@ -546,25 +662,30 @@ Then add the node in the panel with the remote machine's FQDN.
|
|||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### API won't start
|
### API won't start
|
||||||
|
|
||||||
- Check `DATABASE_URL` is correct and PostgreSQL is running
|
- Check `DATABASE_URL` is correct and PostgreSQL is running
|
||||||
- Ensure migrations have been applied: `pnpm db:migrate`
|
- Ensure migrations have been applied: `pnpm db:migrate`
|
||||||
- Check logs: `journalctl -u gamepanel-api -f` or `docker compose logs api`
|
- Check logs: `journalctl -u gamepanel-api -f` or `docker compose logs api`
|
||||||
|
|
||||||
### Daemon can't connect
|
### Daemon can't connect
|
||||||
|
|
||||||
- Verify `api_url` in daemon config points to the API
|
- Verify `api_url` in daemon config points to the API
|
||||||
- Check `node_token` matches what's stored in the panel's nodes table
|
- Check `node_token` matches what's stored in the panel's nodes table
|
||||||
- Ensure the daemon's gRPC port (50051) is open
|
- Ensure the daemon's gRPC port (50051) is open
|
||||||
|
|
||||||
### Web shows blank page
|
### Web shows blank page
|
||||||
|
|
||||||
- Build the SPA: `pnpm --filter @source/web build`
|
- Build the SPA: `pnpm --filter @source/web build`
|
||||||
- Check nginx config: `sudo nginx -t`
|
- Check nginx config: `sudo nginx -t`
|
||||||
- Verify API proxy is working: `curl http://localhost:3000/api/health`
|
- Verify API proxy is working: `curl http://localhost:3000/api/health`
|
||||||
|
|
||||||
### Docker permission denied
|
### Docker permission denied
|
||||||
|
|
||||||
- Ensure the daemon user is in the `docker` group: `usermod -aG docker <user>`
|
- Ensure the daemon user is in the `docker` group: `usermod -aG docker <user>`
|
||||||
- Or run the daemon with appropriate privileges
|
- Or run the daemon with appropriate privileges
|
||||||
|
|
||||||
### protoc not found (daemon build)
|
### protoc not found (daemon build)
|
||||||
|
|
||||||
- Ubuntu: `sudo apt install protobuf-compiler`
|
- Ubuntu: `sudo apt install protobuf-compiler`
|
||||||
- macOS: `brew install protobuf`
|
- macOS: `brew install protobuf`
|
||||||
- Or download from [github.com/protocolbuffers/protobuf/releases](https://github.com/protocolbuffers/protobuf/releases)
|
- Or download from [github.com/protocolbuffers/protobuf/releases](https://github.com/protocolbuffers/protobuf/releases)
|
||||||
@@ -607,24 +728,24 @@ sudo systemctl reload nginx
|
|||||||
|
|
||||||
## Environment Variables Reference
|
## Environment Variables Reference
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
| ---------------------- | --------------------------- | --------------------------------------- |
|
||||||
| `DATABASE_URL` | — | PostgreSQL connection string |
|
| `DATABASE_URL` | — | PostgreSQL connection string |
|
||||||
| `DB_USER` | `gamepanel` | PostgreSQL username (Docker) |
|
| `DB_USER` | `gamepanel` | PostgreSQL username (Docker) |
|
||||||
| `DB_PASSWORD` | `gamepanel` | PostgreSQL password (Docker) |
|
| `DB_PASSWORD` | `gamepanel` | PostgreSQL password (Docker) |
|
||||||
| `DB_NAME` | `gamepanel` | Database name (Docker) |
|
| `DB_NAME` | `gamepanel` | Database name (Docker) |
|
||||||
| `DB_PORT` | `5432` | PostgreSQL exposed port |
|
| `DB_PORT` | `5432` | PostgreSQL exposed port |
|
||||||
| `REDIS_URL` | — | Redis connection string |
|
| `REDIS_URL` | — | Redis connection string |
|
||||||
| `REDIS_PASSWORD` | `gamepanel` | Redis password |
|
| `REDIS_PASSWORD` | `gamepanel` | Redis password |
|
||||||
| `PORT` | `3000` | API listen port |
|
| `PORT` | `3000` | API listen port |
|
||||||
| `HOST` | `0.0.0.0` | API listen host |
|
| `HOST` | `0.0.0.0` | API listen host |
|
||||||
| `NODE_ENV` | `development` | Environment mode |
|
| `NODE_ENV` | `development` | Environment mode |
|
||||||
| `JWT_SECRET` | — | **Required.** Access token signing key |
|
| `JWT_SECRET` | — | **Required.** Access token signing key |
|
||||||
| `JWT_REFRESH_SECRET` | — | **Required.** Refresh token signing key |
|
| `JWT_REFRESH_SECRET` | — | **Required.** Refresh token signing key |
|
||||||
| `CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin |
|
| `CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin |
|
||||||
| `RATE_LIMIT_MAX` | `100` | Max requests per window |
|
| `RATE_LIMIT_MAX` | `100` | Max requests per window |
|
||||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit window (ms) |
|
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit window (ms) |
|
||||||
| `WEB_PORT` | `80` | Web nginx exposed port |
|
| `WEB_PORT` | `80` | Web nginx exposed port |
|
||||||
| `API_PORT` | `3000` | API exposed port (Docker) |
|
| `API_PORT` | `3000` | API exposed port (Docker) |
|
||||||
| `DAEMON_CONFIG` | `/etc/gamepanel/config.yml` | Daemon config file path |
|
| `DAEMON_CONFIG` | `/etc/gamepanel/config.yml` | Daemon config file path |
|
||||||
| `DAEMON_GRPC_PORT` | `50051` | Daemon gRPC exposed port |
|
| `DAEMON_GRPC_PORT` | `50051` | Daemon gRPC exposed port |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Modern, open-source game server management panel built with a multi-tenant SaaS
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
- **Multi-Tenant Organizations** — Isolated environments with role-based access control (Admin / User + custom JSONB permissions)
|
- **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
|
- **Docker Container Management** — Full lifecycle: create, start, stop, restart, kill, delete
|
||||||
- **Multi-Node Architecture** — Distribute game servers across multiple daemon nodes with health monitoring
|
- **Multi-Node Architecture** — Distribute game servers across multiple daemon nodes with health monitoring
|
||||||
@@ -15,16 +16,19 @@ Modern, open-source game server management panel built with a multi-tenant SaaS
|
|||||||
- **Server Creation Wizard** — 3-step guided flow: Basic Info, Node & Allocation, Resources
|
- **Server Creation Wizard** — 3-step guided flow: Basic Info, Node & Allocation, Resources
|
||||||
|
|
||||||
### Game-Specific
|
### Game-Specific
|
||||||
|
|
||||||
- **Config Editor** — Tab-based UI with parsers for `.properties`, `.json`, `.yaml`, and Source Engine `.cfg` formats
|
- **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
|
- **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`)
|
- **Player Tracking** — Live player list via RCON protocol (Minecraft `list`, CS2 `status`)
|
||||||
|
|
||||||
### Advanced
|
### Advanced
|
||||||
|
|
||||||
- **Scheduled Tasks** — Visual scheduler with interval, daily, weekly, and cron expression support
|
- **Scheduled Tasks** — Visual scheduler with interval, daily, weekly, and cron expression support
|
||||||
- **Backup System** — Create, restore, lock/unlock, delete backups with CDN storage integration
|
- **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
|
- **Audit Logging** — Track all actions across the panel with user, server, and IP metadata
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
- **Rate Limiting** — Configurable per-window request limits
|
- **Rate Limiting** — Configurable per-window request limits
|
||||||
- **Security Headers** — Helmet.js with CSP, XSS protection, content-type sniffing prevention
|
- **Security Headers** — Helmet.js with CSP, XSS protection, content-type sniffing prevention
|
||||||
- **Health Checks** — Built-in endpoints for all services
|
- **Health Checks** — Built-in endpoints for all services
|
||||||
@@ -56,18 +60,18 @@ The API acts as a **gateway** between the frontend and daemon nodes. The fronten
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Component | Technology |
|
| Component | Technology |
|
||||||
|-----------|-----------|
|
| -------------- | ---------------------------------------------- |
|
||||||
| Monorepo | Turborepo + pnpm |
|
| Monorepo | Turborepo + pnpm |
|
||||||
| Frontend | React 19 + Vite 6 + Tailwind CSS 3 + shadcn/ui |
|
| Frontend | React 19 + Vite 6 + Tailwind CSS 3 + shadcn/ui |
|
||||||
| Backend API | Fastify 5 + TypeBox validation |
|
| Backend API | Fastify 5 + TypeBox validation |
|
||||||
| Daemon | Rust + tonic gRPC + bollard (Docker) + tokio |
|
| Daemon | Rust + tonic gRPC + bollard (Docker) + tokio |
|
||||||
| Database | PostgreSQL 16 + Drizzle ORM |
|
| Database | PostgreSQL 16 + Drizzle ORM |
|
||||||
| Auth | JWT (access + refresh) + Argon2id |
|
| Auth | JWT (access + refresh) + Argon2id |
|
||||||
| Realtime | Socket.IO (frontend ↔ API) |
|
| Realtime | Socket.IO (frontend ↔ API) |
|
||||||
| Panel ↔ Daemon | gRPC with protobuf |
|
| Panel ↔ Daemon | gRPC with protobuf |
|
||||||
| Containers | Docker |
|
| Containers | Docker |
|
||||||
| CI/CD | GitHub Actions |
|
| CI/CD | GitHub Actions |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -150,57 +154,65 @@ source-gamepanel/
|
|||||||
|
|
||||||
## Supported Games
|
## Supported Games
|
||||||
|
|
||||||
| Game | Docker Image | Default Port | Config Format | Plugin Support |
|
| Game | Docker Image | Default Port | Config Format | Plugin Support |
|
||||||
|------|-------------|-------------|---------------|---------------|
|
| -------------------------- | ------------------------------- | ------------------------------------------- | ---------------------------------- | ------------------- |
|
||||||
| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual |
|
| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual |
|
||||||
| Counter-Strike 2 | `cm2network/cs2` | 27015 | Source `.cfg` (keyvalue) | Manual |
|
| Counter-Strike 2 | `cm2network/cs2` | 27015 | Source `.cfg` (keyvalue) | Manual |
|
||||||
| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — |
|
| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — |
|
||||||
| Terraria | `ryshe/terraria` | 7777 | keyvalue | — |
|
| Terraria | `ryshe/terraria` | 7777 | keyvalue | — |
|
||||||
| Rust | `didstopia/rust-server` | 28015 | — | — |
|
| Rust | `didstopia/rust-server` | 28015 | — | — |
|
||||||
| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — |
|
| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — |
|
||||||
| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — |
|
| 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` | — |
|
||||||
|
|
||||||
Many games can be added with a database seed entry alone. Some images still need small daemon-side tweaks for mount paths, port protocols, or config parsing.
|
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
|
## API Endpoints
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
| Method | Path | Description |
|
||||||
| POST | `/api/auth/register` | Create account |
|
| ------ | -------------------- | ------------------------------------ |
|
||||||
| POST | `/api/auth/login` | Login (returns JWT + refresh cookie) |
|
| POST | `/api/auth/register` | Create account |
|
||||||
| POST | `/api/auth/refresh` | Refresh access token |
|
| POST | `/api/auth/login` | Login (returns JWT + refresh cookie) |
|
||||||
| POST | `/api/auth/logout` | Invalidate session |
|
| POST | `/api/auth/refresh` | Refresh access token |
|
||||||
| GET | `/api/auth/me` | Current user profile |
|
| POST | `/api/auth/logout` | Invalidate session |
|
||||||
|
| GET | `/api/auth/me` | Current user profile |
|
||||||
|
|
||||||
### Organizations
|
### Organizations
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
| Method | Path | Description |
|
||||||
| GET | `/api/organizations` | List user's orgs |
|
| ---------------- | ----------------------------------- | ----------------- |
|
||||||
| POST | `/api/organizations` | Create org |
|
| GET | `/api/organizations` | List user's orgs |
|
||||||
| GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD |
|
| POST | `/api/organizations` | Create org |
|
||||||
| GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management |
|
| GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD |
|
||||||
|
| GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management |
|
||||||
|
|
||||||
### Servers
|
### Servers
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
| Method | Path | Description |
|
||||||
| GET/POST | `.../servers` | List / create |
|
| --------------------- | ------------------------------------------- | --------------------------------------- |
|
||||||
| GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD |
|
| GET/POST | `.../servers` | List / create |
|
||||||
| POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) |
|
| GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD |
|
||||||
| GET/PUT | `.../servers/:serverId/config` | Config read/write |
|
| POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) |
|
||||||
| GET/POST/DELETE | `.../servers/:serverId/plugins` | Plugin management |
|
| GET/PUT | `.../servers/:serverId/config` | Config read/write |
|
||||||
| GET/POST/DELETE | `.../servers/:serverId/backups` | Backup management |
|
| GET/POST/DELETE | `.../servers/:serverId/plugins` | Plugin management |
|
||||||
| POST | `.../servers/:serverId/backups/:id/restore` | Restore backup |
|
| GET/POST/DELETE | `.../servers/:serverId/backups` | Backup management |
|
||||||
| GET/POST/PATCH/DELETE | `.../servers/:serverId/schedules` | Scheduled tasks |
|
| POST | `.../servers/:serverId/backups/:id/restore` | Restore backup |
|
||||||
|
| GET/POST/PATCH/DELETE | `.../servers/:serverId/schedules` | Scheduled tasks |
|
||||||
|
|
||||||
### Admin (Super Admin only)
|
### Admin (Super Admin only)
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
| Method | Path | Description |
|
||||||
| GET | `/api/admin/users` | All users |
|
| -------- | ----------------------- | --------------- |
|
||||||
| GET/POST | `/api/admin/games` | Game management |
|
| GET | `/api/admin/users` | All users |
|
||||||
| GET | `/api/admin/audit-logs` | Audit trail |
|
| GET/POST | `/api/admin/games` | Game management |
|
||||||
|
| GET | `/api/admin/audit-logs` | Audit trail |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -258,19 +270,23 @@ Open `http://localhost:5173` — login with `admin@gamepanel.local` / `admin123`
|
|||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Configure environment
|
git clone https://github.com/your-org/source-gamepanel.git
|
||||||
cp .env.example .env
|
cd source-gamepanel
|
||||||
# Edit .env with production values (strong JWT secrets, real DB passwords)
|
|
||||||
|
|
||||||
# Deploy full stack
|
# 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
|
docker compose up -d --build
|
||||||
|
|
||||||
# Run migrations inside the API container
|
|
||||||
docker compose exec api node -e "..."
|
|
||||||
# Or connect to the DB directly and run drizzle-kit migrate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The web service is exposed on port 80 with nginx handling SPA routing and API proxying.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ RUN pnpm --filter @source/shared build && \
|
|||||||
pnpm --filter @source/database build && \
|
pnpm --filter @source/database build && \
|
||||||
pnpm --filter @source/api 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 ---
|
# --- Production ---
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
|
|||||||
+36
-34
@@ -18,10 +18,7 @@ import { AppError } from './lib/errors.js';
|
|||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
transport:
|
transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,
|
||||||
process.env.NODE_ENV !== 'production'
|
|
||||||
? { target: 'pino-pretty' }
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,39 +43,44 @@ await app.register(authPlugin);
|
|||||||
await app.register(socketPlugin);
|
await app.register(socketPlugin);
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
|
app.setErrorHandler(
|
||||||
if (error instanceof AppError) {
|
(
|
||||||
return reply.code(error.statusCode).send({
|
error: Error & { validation?: unknown; statusCode?: number; code?: string },
|
||||||
error: error.name,
|
_request,
|
||||||
message: error.message,
|
reply,
|
||||||
code: error.code,
|
) => {
|
||||||
});
|
if (error instanceof AppError) {
|
||||||
}
|
return reply.code(error.statusCode).send({
|
||||||
|
error: error.name,
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fastify validation errors
|
// Fastify validation errors
|
||||||
if (error.validation) {
|
if (error.validation) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Validation Error',
|
error: 'Validation Error',
|
||||||
message: error.message,
|
message: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit errors
|
// Rate limit errors
|
||||||
if (error.statusCode === 429) {
|
if (error.statusCode === 429) {
|
||||||
return reply.code(429).send({
|
return reply.code(429).send({
|
||||||
error: 'Too Many Requests',
|
error: 'Too Many Requests',
|
||||||
message: 'Rate limit exceeded, please try again later',
|
message: 'Rate limit exceeded, please try again later',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
return reply.code(error.statusCode ?? 500).send({
|
return reply.code(error.statusCode ?? 500).send({
|
||||||
error: 'Internal Server Error',
|
error: 'Internal Server Error',
|
||||||
message: process.env.NODE_ENV === 'production'
|
message:
|
||||||
? 'An unexpected error occurred'
|
process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message,
|
||||||
: error.message,
|
});
|
||||||
});
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
|
|||||||
+5
-11
@@ -23,7 +23,9 @@ function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getArtifactAccessTtlSeconds(): number {
|
function getArtifactAccessTtlSeconds(): number {
|
||||||
const raw = Number(process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS);
|
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;
|
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
|
||||||
return Math.floor(raw);
|
return Math.floor(raw);
|
||||||
}
|
}
|
||||||
@@ -100,11 +102,7 @@ export async function ensurePrivatePluginBucket(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw toCdnAppError(
|
throw toCdnAppError(error, 'Failed to fetch CDN plugin bucket', 'CDN_BUCKET_READ_FAILED');
|
||||||
error,
|
|
||||||
'Failed to fetch CDN plugin bucket',
|
|
||||||
'CDN_BUCKET_READ_FAILED',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,10 +190,6 @@ export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<s
|
|||||||
|
|
||||||
return new URL(resolvedUrl, config.baseUrl).toString();
|
return new URL(resolvedUrl, config.baseUrl).toString();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw toCdnAppError(
|
throw toCdnAppError(error, 'Failed to get temporary CDN access URL', 'CDN_ACCESS_URL_FAILED');
|
||||||
error,
|
|
||||||
'Failed to get temporary CDN access URL',
|
|
||||||
'CDN_ACCESS_URL_FAILED',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
import {
|
|
||||||
daemonReadFile,
|
|
||||||
daemonWriteFile,
|
|
||||||
type DaemonNodeConnection,
|
|
||||||
} from './daemon.js';
|
|
||||||
|
|
||||||
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
|
|
||||||
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
|
|
||||||
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
|
|
||||||
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
|
|
||||||
|
|
||||||
hostname "GamePanel CS2 Server" // Set server hostname
|
|
||||||
sv_cheats 0 // Enable or disable cheats
|
|
||||||
sv_hibernate_when_empty 0 // Disable server hibernation
|
|
||||||
|
|
||||||
// Passwords
|
|
||||||
|
|
||||||
rcon_password "" // Set rcon password
|
|
||||||
sv_password "" // Set server password
|
|
||||||
|
|
||||||
// CSTV
|
|
||||||
|
|
||||||
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
|
|
||||||
|
|
||||||
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
|
|
||||||
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
|
|
||||||
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
|
|
||||||
tv_chatgroupsize 0 // Set the default chat group size
|
|
||||||
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
|
|
||||||
tv_debug 0 // CSTV debug info.
|
|
||||||
tv_delay 0 // CSTV broadcast delay in seconds
|
|
||||||
tv_delaymapchange 1 // Delays map change until broadcast is complete
|
|
||||||
tv_deltacache 2 // Enable delta entity bit stream cache
|
|
||||||
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
|
|
||||||
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
|
|
||||||
tv_maxclients 10 // Maximum client number on CSTV server.
|
|
||||||
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
|
|
||||||
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
|
|
||||||
tv_overridemaster 0 // Overrides the CSTV master root address.
|
|
||||||
tv_port 27020 // Host SourceTV port
|
|
||||||
tv_password "changeme" // CSTV password for clients
|
|
||||||
tv_relaypassword "changeme" // CSTV password for relay proxies
|
|
||||||
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
|
|
||||||
tv_timeout 60 // CSTV connection timeout in seconds.
|
|
||||||
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
|
|
||||||
tv_transmitall 1 // Transmit all entities (not only director view)
|
|
||||||
|
|
||||||
// Logs
|
|
||||||
|
|
||||||
log on // Turns logging 'on' or 'off', defaults to 'on'
|
|
||||||
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
|
|
||||||
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
|
|
||||||
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
|
|
||||||
`;
|
|
||||||
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
|
|
||||||
// CS2 Server Config
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// ---- Sunucu Bilgileri ----
|
|
||||||
hostname "SourceGamePanel CS2 Server"
|
|
||||||
sv_password ""
|
|
||||||
rcon_password "changeme"
|
|
||||||
sv_cheats 0
|
|
||||||
|
|
||||||
// ---- Topluluk Sunucu Gorunurlugu ----
|
|
||||||
sv_region 3
|
|
||||||
sv_tags "competitive,community"
|
|
||||||
sv_lan 0
|
|
||||||
sv_steamgroup ""
|
|
||||||
sv_steamgroup_exclusive 0
|
|
||||||
|
|
||||||
// ---- Performans ----
|
|
||||||
sv_maxrate 0
|
|
||||||
sv_minrate 64000
|
|
||||||
sv_max_queries_sec 5
|
|
||||||
sv_max_queries_window 30
|
|
||||||
sv_parallel_sendsnapshot 1
|
|
||||||
net_maxroutable 1200
|
|
||||||
|
|
||||||
// ---- Baglanti ----
|
|
||||||
sv_maxclients 16
|
|
||||||
sv_timeout 60
|
|
||||||
|
|
||||||
// ---- GOTV (Tamamen Kapali) ----
|
|
||||||
tv_enable 0
|
|
||||||
tv_autorecord 0
|
|
||||||
tv_delay 0
|
|
||||||
tv_maxclients 0
|
|
||||||
tv_port 0
|
|
||||||
|
|
||||||
// ---- Loglama ----
|
|
||||||
log on
|
|
||||||
mp_logmoney 0
|
|
||||||
mp_logdetail 0
|
|
||||||
mp_logdetail_items 0
|
|
||||||
sv_logfile 1
|
|
||||||
|
|
||||||
// ---- Genel Oyun Ayarlari ----
|
|
||||||
mp_autokick 0
|
|
||||||
sv_allow_votes 0
|
|
||||||
sv_alltalk 0
|
|
||||||
sv_deadtalk 1
|
|
||||||
sv_voiceenable 1
|
|
||||||
`;
|
|
||||||
|
|
||||||
function normalizePath(path: string): string {
|
|
||||||
const normalized = path
|
|
||||||
.trim()
|
|
||||||
.replace(/\\/g, '/')
|
|
||||||
.replace(/^\/+/, '')
|
|
||||||
.replace(/\/{2,}/g, '/');
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMissingFileError(error: unknown): boolean {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return (
|
|
||||||
message.includes('No such file or directory') ||
|
|
||||||
message.includes('Server responded with NOT_FOUND') ||
|
|
||||||
message.includes('status code 404')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeComparableContent(content: string): string {
|
|
||||||
return content.replace(/\r\n/g, '\n').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isManagedCs2ServerConfigPath(gameSlug: string, path: string): boolean {
|
|
||||||
return (
|
|
||||||
gameSlug.trim().toLowerCase() === 'cs2' &&
|
|
||||||
normalizePath(path) === CS2_SERVER_CFG_PATH
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readManagedCs2ServerConfig(
|
|
||||||
node: DaemonNodeConnection,
|
|
||||||
serverUuid: string,
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
const persisted = await daemonReadFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH);
|
|
||||||
return persisted.data.toString('utf8');
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingFileError(error)) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const current = await daemonReadFile(node, serverUuid, CS2_SERVER_CFG_PATH);
|
|
||||||
const content = current.data.toString('utf8');
|
|
||||||
const nextContent =
|
|
||||||
normalizeComparableContent(content) === normalizeComparableContent(LEGACY_IMAGE_CS2_SERVER_CFG)
|
|
||||||
? DEFAULT_CS2_SERVER_CFG
|
|
||||||
: content;
|
|
||||||
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, nextContent);
|
|
||||||
return nextContent;
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingFileError(error)) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG);
|
|
||||||
return DEFAULT_CS2_SERVER_CFG;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeManagedCs2ServerConfig(
|
|
||||||
node: DaemonNodeConnection,
|
|
||||||
serverUuid: string,
|
|
||||||
content: string | Buffer,
|
|
||||||
): Promise<void> {
|
|
||||||
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, content);
|
|
||||||
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reapplyManagedCs2ServerConfig(
|
|
||||||
node: DaemonNodeConnection,
|
|
||||||
serverUuid: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const content = await readManagedCs2ServerConfig(node, serverUuid);
|
|
||||||
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,9 @@ export interface DaemonCreateServerRequest {
|
|||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
ports: DaemonPortMapping[];
|
ports: DaemonPortMapping[];
|
||||||
install_plugin_urls: string[];
|
install_plugin_urls: string[];
|
||||||
|
data_path: string;
|
||||||
|
stop_command: string;
|
||||||
|
stop_timeout_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DaemonUpdateServerRequest {
|
export interface DaemonUpdateServerRequest {
|
||||||
@@ -36,6 +39,16 @@ export interface DaemonUpdateServerRequest {
|
|||||||
startup_command: string;
|
startup_command: string;
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
ports: DaemonPortMapping[];
|
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 {
|
interface DaemonServerResponse {
|
||||||
@@ -216,7 +229,12 @@ interface DaemonServiceClient extends grpc.Client {
|
|||||||
callback: UnaryCallback<EmptyResponse>,
|
callback: UnaryCallback<EmptyResponse>,
|
||||||
): void;
|
): void;
|
||||||
setPowerState(
|
setPowerState(
|
||||||
request: { uuid: string; action: number },
|
request: {
|
||||||
|
uuid: string;
|
||||||
|
action: number;
|
||||||
|
stop_command: string;
|
||||||
|
stop_timeout_seconds: number;
|
||||||
|
},
|
||||||
metadata: grpc.Metadata,
|
metadata: grpc.Metadata,
|
||||||
callback: UnaryCallback<EmptyResponse>,
|
callback: UnaryCallback<EmptyResponse>,
|
||||||
): void;
|
): void;
|
||||||
@@ -437,6 +455,7 @@ function toBuffer(data: Uint8Array | Buffer): Buffer {
|
|||||||
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
|
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
|
||||||
const DEFAULT_RPC_TIMEOUT_MS = 20_000;
|
const DEFAULT_RPC_TIMEOUT_MS = 20_000;
|
||||||
const POWER_RPC_TIMEOUT_MS = 45_000;
|
const POWER_RPC_TIMEOUT_MS = 45_000;
|
||||||
|
const MAX_POWER_RPC_TIMEOUT_MS = 360_000;
|
||||||
|
|
||||||
interface DaemonRequestTimeoutOptions {
|
interface DaemonRequestTimeoutOptions {
|
||||||
connectTimeoutMs?: number;
|
connectTimeoutMs?: number;
|
||||||
@@ -641,18 +660,37 @@ export async function daemonSetPowerState(
|
|||||||
node: DaemonNodeConnection,
|
node: DaemonNodeConnection,
|
||||||
serverUuid: string,
|
serverUuid: string,
|
||||||
action: PowerAction,
|
action: PowerAction,
|
||||||
|
options: DaemonPowerOptions = {},
|
||||||
): Promise<void> {
|
): 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);
|
const client = createClient(node);
|
||||||
try {
|
try {
|
||||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||||
await callUnary<EmptyResponse>(
|
await callUnary<EmptyResponse>(
|
||||||
(callback) =>
|
(callback) =>
|
||||||
client.setPowerState(
|
client.setPowerState(
|
||||||
{ uuid: serverUuid, action: POWER_ACTIONS[action] },
|
{
|
||||||
|
uuid: serverUuid,
|
||||||
|
action: POWER_ACTIONS[action],
|
||||||
|
stop_command: options.stopCommand?.trim() ?? '',
|
||||||
|
stop_timeout_seconds: stopTimeoutSeconds,
|
||||||
|
},
|
||||||
getMetadata(node.daemonToken),
|
getMetadata(node.daemonToken),
|
||||||
callback,
|
callback,
|
||||||
),
|
),
|
||||||
POWER_RPC_TIMEOUT_MS,
|
rpcTimeoutMs,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
|
|||||||
+30
-3
@@ -14,8 +14,33 @@ export interface RefreshTokenPayload {
|
|||||||
const ACCESS_TOKEN_EXPIRY = '15m';
|
const ACCESS_TOKEN_EXPIRY = '15m';
|
||||||
const REFRESH_TOKEN_EXPIRY = '7d';
|
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 {
|
export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string {
|
||||||
const signer = (app as any).jwt?.sign;
|
const signer = getJwt(app)?.sign;
|
||||||
if (typeof signer !== 'function') {
|
if (typeof signer !== 'function') {
|
||||||
throw new Error('JWT signer is not configured');
|
throw new Error('JWT signer is not configured');
|
||||||
}
|
}
|
||||||
@@ -23,7 +48,8 @@ export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayloa
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string {
|
export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string {
|
||||||
const signer = (app as any).jwt?.refresh?.sign ?? (app as any).jwt?.jwtRefresh?.sign;
|
const jwt = getJwt(app);
|
||||||
|
const signer = jwt?.refresh?.sign ?? jwt?.jwtRefresh?.sign;
|
||||||
if (typeof signer !== 'function') {
|
if (typeof signer !== 'function') {
|
||||||
throw new Error('Refresh JWT signer is not configured');
|
throw new Error('Refresh JWT signer is not configured');
|
||||||
}
|
}
|
||||||
@@ -31,7 +57,8 @@ export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayl
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload {
|
export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload {
|
||||||
const verifier = (app as any).jwt?.refresh?.verify ?? (app as any).jwt?.jwtRefresh?.verify;
|
const jwt = getJwt(app);
|
||||||
|
const verifier = jwt?.refresh?.verify ?? jwt?.jwtRefresh?.verify;
|
||||||
if (typeof verifier !== 'function') {
|
if (typeof verifier !== 'function') {
|
||||||
throw new Error('Refresh JWT verifier is not configured');
|
throw new Error('Refresh JWT verifier is not configured');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -5,7 +5,16 @@ export const PaginationQuerySchema = Type.Object({
|
|||||||
perPage: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })),
|
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 page = query.page ?? 1;
|
||||||
const perPage = query.perPage ?? 20;
|
const perPage = query.perPage ?? 20;
|
||||||
const offset = (page - 1) * perPage;
|
const offset = (page - 1) * perPage;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function getOrgMembership(
|
|||||||
return 'super_admin';
|
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(
|
where: and(
|
||||||
eq(organizationMembers.organizationId, orgId),
|
eq(organizationMembers.organizationId, orgId),
|
||||||
eq(organizationMembers.userId, user.sub),
|
eq(organizationMembers.userId, user.sub),
|
||||||
@@ -45,7 +45,10 @@ export async function getOrgMembership(
|
|||||||
* Check if the user has a specific permission in the organization.
|
* Check if the user has a specific permission in the organization.
|
||||||
* Super admins always have all permissions.
|
* 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;
|
if (membership === 'super_admin') return true;
|
||||||
|
|
||||||
// Check custom permission overrides first
|
// Check custom permission overrides first
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Compute the next run time for a scheduled task.
|
* Compute the next run time for a scheduled task.
|
||||||
*/
|
*/
|
||||||
export function computeNextRun(
|
export function computeNextRun(scheduleType: string, scheduleData: Record<string, unknown>): Date {
|
||||||
scheduleType: string,
|
|
||||||
scheduleData: Record<string, unknown>,
|
|
||||||
): Date {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
switch (scheduleType) {
|
switch (scheduleType) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
CS2_PERSISTED_SERVER_CFG_PATH,
|
CS2_PERSISTED_SERVER_CFG_PATH,
|
||||||
CS2_SERVER_CFG_PATH,
|
CS2_SERVER_CFG_PATH,
|
||||||
DEFAULT_CS2_SERVER_CFG,
|
DEFAULT_CS2_SERVER_CFG,
|
||||||
} from './cs2-server-config.js';
|
} from './managed-config.js';
|
||||||
|
|
||||||
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
|
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
|
||||||
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||||
@@ -171,10 +171,7 @@ function readWorkflowId(value: unknown): string | null {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeWorkflow(
|
function normalizeWorkflow(gameSlug: string, workflow: GameAutomationRule): GameAutomationRule {
|
||||||
gameSlug: string,
|
|
||||||
workflow: GameAutomationRule,
|
|
||||||
): GameAutomationRule {
|
|
||||||
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
|
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
|
||||||
|
|
||||||
if (workflow.id === 'cs2-write-default-server-config') {
|
if (workflow.id === 'cs2-write-default-server-config') {
|
||||||
@@ -237,9 +234,7 @@ function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingIds = new Set(
|
const existingIds = new Set(
|
||||||
raw
|
raw.map(readWorkflowId).filter((workflowId): workflowId is string => workflowId !== null),
|
||||||
.map(readWorkflowId)
|
|
||||||
.filter((workflowId): workflowId is string => workflowId !== null),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
|
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
|
||||||
@@ -247,7 +242,9 @@ function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[]
|
|||||||
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...configured, ...missingDefaults].map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
return [...configured, ...missingDefaults].map((workflow) =>
|
||||||
|
normalizeWorkflow(gameSlug, workflow),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
|
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
|
||||||
@@ -386,9 +383,7 @@ interface DirectoryAssetCandidate {
|
|||||||
function extractNumberParts(value: string): number[] {
|
function extractNumberParts(value: string): number[] {
|
||||||
const matches = value.match(/\d+/g);
|
const matches = value.match(/\d+/g);
|
||||||
if (!matches) return [];
|
if (!matches) return [];
|
||||||
return matches
|
return matches.map((part) => Number.parseInt(part, 10)).filter((num) => Number.isFinite(num));
|
||||||
.map((part) => Number.parseInt(part, 10))
|
|
||||||
.filter((num) => Number.isFinite(num));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareNumberPartsDesc(a: number[], b: number[]): number {
|
function compareNumberPartsDesc(a: number[], b: number[]): number {
|
||||||
@@ -431,7 +426,9 @@ function extractDirectoryCandidates(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedUrl = new URL(href, indexUrl);
|
const resolvedUrl = new URL(href, indexUrl);
|
||||||
const filename = decodeURIComponent(resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '');
|
const filename = decodeURIComponent(
|
||||||
|
resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '',
|
||||||
|
);
|
||||||
if (!filename || !assetPattern.test(filename)) continue;
|
if (!filename || !assetPattern.test(filename)) continue;
|
||||||
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
@@ -611,7 +608,8 @@ async function executeGitHubReleaseExtract(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
const maxBytes =
|
||||||
|
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||||
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
|
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
|
||||||
const files = await extractArtifactFiles(
|
const files = await extractArtifactFiles(
|
||||||
artifact,
|
artifact,
|
||||||
@@ -651,7 +649,8 @@ async function executeHttpDirectoryExtract(
|
|||||||
action: ServerAutomationHttpDirectoryExtractAction,
|
action: ServerAutomationHttpDirectoryExtractAction,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const selectedAsset = await resolveLatestDirectoryAsset(action);
|
const selectedAsset = await resolveLatestDirectoryAsset(action);
|
||||||
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
const maxBytes =
|
||||||
|
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||||
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
|
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
|
||||||
const files = await extractArtifactFiles(
|
const files = await extractArtifactFiles(
|
||||||
artifact,
|
artifact,
|
||||||
@@ -701,9 +700,7 @@ async function executeInsertBeforeLine(
|
|||||||
|
|
||||||
const skipIfExists = action.skipIfExists !== false;
|
const skipIfExists = action.skipIfExists !== false;
|
||||||
if (skipIfExists) {
|
if (skipIfExists) {
|
||||||
const existsRegex = action.existsPattern
|
const existsRegex = action.existsPattern ? new RegExp(action.existsPattern, 'i') : null;
|
||||||
? new RegExp(action.existsPattern, 'i')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const alreadyExists = lines.some((line) =>
|
const alreadyExists = lines.some((line) =>
|
||||||
existsRegex ? existsRegex.test(line) : line === action.line,
|
existsRegex ? existsRegex.test(line) : line === action.line,
|
||||||
@@ -777,9 +774,7 @@ async function executeAction(
|
|||||||
|
|
||||||
case 'write_file': {
|
case 'write_file': {
|
||||||
const payload =
|
const payload =
|
||||||
action.encoding === 'base64'
|
action.encoding === 'base64' ? Buffer.from(action.data, 'base64') : action.data;
|
||||||
? Buffer.from(action.data, 'base64')
|
|
||||||
: action.data;
|
|
||||||
|
|
||||||
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
|
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
|
||||||
app.log.info(
|
app.log.info(
|
||||||
@@ -847,7 +842,7 @@ export async function runServerAutomationEvent(
|
|||||||
if (
|
if (
|
||||||
runOnce &&
|
runOnce &&
|
||||||
!context.force &&
|
!context.force &&
|
||||||
await hasMarker(context.node, context.serverUuid, context.event, workflow.id)
|
(await hasMarker(context.node, context.serverUuid, context.event, workflow.id))
|
||||||
) {
|
) {
|
||||||
result.workflowsSkipped += 1;
|
result.workflowsSkipped += 1;
|
||||||
app.log.info(
|
app.log.info(
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export default fp(async (app: FastifyInstance) => {
|
|||||||
const db = createDb(databaseUrl);
|
const db = createDb(databaseUrl);
|
||||||
app.decorate('db', db);
|
app.decorate('db', db);
|
||||||
|
|
||||||
await db.execute(sql.raw(`
|
await db.execute(
|
||||||
|
sql.raw(`
|
||||||
CREATE TABLE IF NOT EXISTS server_databases (
|
CREATE TABLE IF NOT EXISTS server_databases (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
@@ -32,7 +33,8 @@ export default fp(async (app: FastifyInstance) => {
|
|||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
`));
|
`),
|
||||||
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
sql.raw(
|
sql.raw(
|
||||||
'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)',
|
'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Server as SocketIOServer } from 'socket.io';
|
|||||||
import { nodes, organizationMembers, servers } from '@source/database';
|
import { nodes, organizationMembers, servers } from '@source/database';
|
||||||
import { ROLES } from '@source/shared';
|
import { ROLES } from '@source/shared';
|
||||||
import type { Role } from '@source/shared';
|
import type { Role } from '@source/shared';
|
||||||
|
import { getJwt } from '../lib/jwt.js';
|
||||||
import type { AccessTokenPayload } from '../lib/jwt.js';
|
import type { AccessTokenPayload } from '../lib/jwt.js';
|
||||||
import {
|
import {
|
||||||
daemonOpenConsoleStream,
|
daemonOpenConsoleStream,
|
||||||
@@ -58,16 +59,15 @@ export default fp(async (app: FastifyInstance) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const token = typeof socket.handshake.auth?.token === 'string'
|
const token =
|
||||||
? socket.handshake.auth.token
|
typeof socket.handshake.auth?.token === 'string' ? socket.handshake.auth.token : null;
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
next(new Error('Unauthorized'));
|
next(new Error('Unauthorized'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const verifier = (app as any).jwt?.verify;
|
const verifier = getJwt(app)?.verify;
|
||||||
if (typeof verifier !== 'function') {
|
if (typeof verifier !== 'function') {
|
||||||
next(new Error('Authentication is not configured'));
|
next(new Error('Authentication is not configured'));
|
||||||
return;
|
return;
|
||||||
@@ -101,9 +101,10 @@ export default fp(async (app: FastifyInstance) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.on('server:console:join', async (payload: unknown) => {
|
socket.on('server:console:join', async (payload: unknown) => {
|
||||||
const serverId = typeof (payload as { serverId?: unknown })?.serverId === 'string'
|
const serverId =
|
||||||
? ((payload as { serverId: string }).serverId)
|
typeof (payload as { serverId?: unknown })?.serverId === 'string'
|
||||||
: '';
|
? (payload as { serverId: string }).serverId
|
||||||
|
: '';
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
socket.emit('server:console:output', { line: '[error] Invalid server id' });
|
socket.emit('server:console:output', { line: '[error] Invalid server id' });
|
||||||
return;
|
return;
|
||||||
@@ -201,9 +202,8 @@ export default fp(async (app: FastifyInstance) => {
|
|||||||
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
|
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
|
||||||
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
|
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
|
||||||
const command = typeof body.command === 'string' ? body.command.trim() : '';
|
const command = typeof body.command === 'string' ? body.command.trim() : '';
|
||||||
const requestId = typeof body.requestId === 'string' && body.requestId.trim()
|
const requestId =
|
||||||
? body.requestId.trim()
|
typeof body.requestId === 'string' && body.requestId.trim() ? body.requestId.trim() : null;
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!serverId || !orgId || !command) {
|
if (!serverId || !orgId || !command) {
|
||||||
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
|
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
|
||||||
@@ -253,8 +253,11 @@ export default fp(async (app: FastifyInstance) => {
|
|||||||
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
||||||
'Failed to send console command',
|
'Failed to send console command',
|
||||||
);
|
);
|
||||||
socket.emit('server:console:output', { line: '[error] Failed to send command' });
|
// The daemon explains *why* (server not running, no RCON password, …) —
|
||||||
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Failed to send command' };
|
// 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.emit('server:console:command:ack', ack);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -277,6 +280,15 @@ export default fp(async (app: FastifyInstance) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 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(
|
async function hasConsolePermission(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
user: AccessTokenPayload,
|
user: AccessTokenPayload,
|
||||||
|
|||||||
+278
-255
@@ -6,6 +6,7 @@ import { users, games, nodes, auditLogs, plugins, pluginReleases } from '@source
|
|||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requireSuperAdmin } from '../../lib/permissions.js';
|
import { requireSuperAdmin } from '../../lib/permissions.js';
|
||||||
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
||||||
|
import type { PaginationQuery } from '../../lib/pagination.js';
|
||||||
import { uploadPluginArtifact } from '../../lib/cdn.js';
|
import { uploadPluginArtifact } from '../../lib/cdn.js';
|
||||||
import * as yazl from 'yazl';
|
import * as yazl from 'yazl';
|
||||||
import {
|
import {
|
||||||
@@ -85,10 +86,7 @@ function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonArrayUploadFile(
|
function parseJsonArrayUploadFile(file: UploadJsonFile | null, fieldName: string): unknown[] {
|
||||||
file: UploadJsonFile | null,
|
|
||||||
fieldName: string,
|
|
||||||
): unknown[] {
|
|
||||||
if (!file) return [];
|
if (!file) return [];
|
||||||
|
|
||||||
let rawValue = file.data.toString('utf8');
|
let rawValue = file.data.toString('utf8');
|
||||||
@@ -114,8 +112,10 @@ function parseOptionalBoolean(rawValue: unknown): boolean | undefined {
|
|||||||
if (typeof rawValue !== 'string') return undefined;
|
if (typeof rawValue !== 'string') return undefined;
|
||||||
|
|
||||||
const normalized = rawValue.trim().toLowerCase();
|
const normalized = rawValue.trim().toLowerCase();
|
||||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on')
|
||||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
|
return true;
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off')
|
||||||
|
return false;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,8 @@ function parseReleaseChannel(rawValue: unknown): ReleaseChannel {
|
|||||||
if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue;
|
if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue;
|
||||||
if (typeof rawValue === 'string') {
|
if (typeof rawValue === 'string') {
|
||||||
const normalized = rawValue.trim().toLowerCase();
|
const normalized = rawValue.trim().toLowerCase();
|
||||||
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized;
|
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable')
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
return 'stable';
|
return 'stable';
|
||||||
}
|
}
|
||||||
@@ -202,7 +203,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// GET /api/admin/users
|
// GET /api/admin/users
|
||||||
app.get('/users', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
|
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);
|
const [totalResult] = await app.db.select({ count: count() }).from(users);
|
||||||
|
|
||||||
@@ -227,10 +228,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// GET /api/admin/games
|
// GET /api/admin/games
|
||||||
app.get('/games', async () => {
|
app.get('/games', async () => {
|
||||||
const gameList = await app.db
|
const gameList = await app.db.select().from(games).orderBy(games.name);
|
||||||
.select()
|
|
||||||
.from(games)
|
|
||||||
.orderBy(games.name);
|
|
||||||
|
|
||||||
return { data: gameList };
|
return { data: gameList };
|
||||||
});
|
});
|
||||||
@@ -244,6 +242,8 @@ export default async function adminRoutes(app: FastifyInstance) {
|
|||||||
defaultPort: number;
|
defaultPort: number;
|
||||||
startupCommand: string;
|
startupCommand: string;
|
||||||
stopCommand?: string;
|
stopCommand?: string;
|
||||||
|
stopTimeoutSeconds?: number;
|
||||||
|
containerDataPath?: string;
|
||||||
configFiles?: unknown[];
|
configFiles?: unknown[];
|
||||||
environmentVars?: unknown[];
|
environmentVars?: unknown[];
|
||||||
automationRules?: unknown[];
|
automationRules?: unknown[];
|
||||||
@@ -268,20 +268,24 @@ export default async function adminRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/admin/games/:gameId
|
// PATCH /api/admin/games/:gameId
|
||||||
app.patch('/games/:gameId', { schema: { ...GameIdParamSchema, ...UpdateGameSchema } }, async (request) => {
|
app.patch(
|
||||||
const { gameId } = request.params as { gameId: string };
|
'/games/:gameId',
|
||||||
const body = request.body as Record<string, unknown>;
|
{ schema: { ...GameIdParamSchema, ...UpdateGameSchema } },
|
||||||
|
async (request) => {
|
||||||
|
const { gameId } = request.params as { gameId: string };
|
||||||
|
const body = request.body as Record<string, unknown>;
|
||||||
|
|
||||||
const [updated] = await app.db
|
const [updated] = await app.db
|
||||||
.update(games)
|
.update(games)
|
||||||
.set({ ...body, updatedAt: new Date() })
|
.set({ ...body, updatedAt: new Date() })
|
||||||
.where(eq(games.id, gameId))
|
.where(eq(games.id, gameId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) throw AppError.notFound('Game not found');
|
if (!updated) throw AppError.notFound('Game not found');
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// === Nodes (global view) ===
|
// === Nodes (global view) ===
|
||||||
|
|
||||||
@@ -578,49 +582,56 @@ export default async function adminRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => {
|
app.patch(
|
||||||
const { pluginId } = request.params as { pluginId: string };
|
'/plugins/:pluginId',
|
||||||
const body = request.body as {
|
{ schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } },
|
||||||
name?: string;
|
async (request) => {
|
||||||
slug?: string;
|
const { pluginId } = request.params as { pluginId: string };
|
||||||
description?: string;
|
const body = request.body as {
|
||||||
source?: 'manual' | 'spiget';
|
name?: string;
|
||||||
isGlobal?: boolean;
|
slug?: string;
|
||||||
};
|
description?: string;
|
||||||
|
source?: 'manual' | 'spiget';
|
||||||
|
isGlobal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const existing = await app.db.query.plugins.findFirst({
|
const existing = await app.db.query.plugins.findFirst({
|
||||||
where: eq(plugins.id, pluginId),
|
where: eq(plugins.id, pluginId),
|
||||||
});
|
});
|
||||||
if (!existing) throw AppError.notFound('Plugin not found');
|
if (!existing) throw AppError.notFound('Plugin not found');
|
||||||
|
|
||||||
const nextSlug = body.slug !== undefined
|
const nextSlug =
|
||||||
? toSlug(body.slug)
|
body.slug !== undefined
|
||||||
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
|
? toSlug(body.slug)
|
||||||
if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid');
|
: 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({
|
const duplicate = await app.db.query.plugins.findFirst({
|
||||||
where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)),
|
where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)),
|
||||||
});
|
});
|
||||||
if (duplicate && duplicate.id !== existing.id) {
|
if (duplicate && duplicate.id !== existing.id) {
|
||||||
throw AppError.conflict('Plugin slug already exists for this game');
|
throw AppError.conflict('Plugin slug already exists for this game');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await app.db
|
const [updated] = await app.db
|
||||||
.update(plugins)
|
.update(plugins)
|
||||||
.set({
|
.set({
|
||||||
name: body.name ?? existing.name,
|
name: body.name ?? existing.name,
|
||||||
slug: nextSlug,
|
slug: nextSlug,
|
||||||
description: body.description ?? existing.description,
|
description: body.description ?? existing.description,
|
||||||
source: body.source ?? existing.source,
|
source: body.source ?? existing.source,
|
||||||
isGlobal: body.isGlobal ?? existing.isGlobal,
|
isGlobal: body.isGlobal ?? existing.isGlobal,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(plugins.id, existing.id))
|
.where(eq(plugins.id, existing.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) throw AppError.notFound('Plugin not found');
|
if (!updated) throw AppError.notFound('Plugin not found');
|
||||||
return updated;
|
return updated;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => {
|
app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => {
|
||||||
const { pluginId } = request.params as { pluginId: string };
|
const { pluginId } = request.params as { pluginId: string };
|
||||||
@@ -639,216 +650,231 @@ export default async function adminRoutes(app: FastifyInstance) {
|
|||||||
return { plugin, releases };
|
return { plugin, releases };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/plugins/:pluginId/releases/upload', { schema: PluginIdParamSchema }, async (request, reply) => {
|
app.post(
|
||||||
const { pluginId } = request.params as { pluginId: string };
|
'/plugins/:pluginId/releases/upload',
|
||||||
|
{ schema: PluginIdParamSchema },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { pluginId } = request.params as { pluginId: string };
|
||||||
|
|
||||||
const plugin = await app.db.query.plugins.findFirst({
|
const plugin = await app.db.query.plugins.findFirst({
|
||||||
where: eq(plugins.id, pluginId),
|
where: eq(plugins.id, pluginId),
|
||||||
});
|
});
|
||||||
if (!plugin) throw AppError.notFound('Plugin not found');
|
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||||
|
|
||||||
if (!request.isMultipart()) {
|
if (!request.isMultipart()) {
|
||||||
throw AppError.badRequest('Content-Type must be multipart/form-data');
|
throw AppError.badRequest('Content-Type must be multipart/form-data');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields: Record<string, unknown> = {};
|
const fields: Record<string, unknown> = {};
|
||||||
const files: UploadArtifactFile[] = [];
|
const files: UploadArtifactFile[] = [];
|
||||||
let installSchemaFile: UploadJsonFile | null = null;
|
let installSchemaFile: UploadJsonFile | null = null;
|
||||||
let configTemplatesFile: UploadJsonFile | null = null;
|
let configTemplatesFile: UploadJsonFile | null = null;
|
||||||
const relativePathQueue: string[] = [];
|
const relativePathQueue: string[] = [];
|
||||||
|
|
||||||
for await (const part of request.parts()) {
|
for await (const part of request.parts()) {
|
||||||
if (part.type === 'file') {
|
if (part.type === 'file') {
|
||||||
if (part.fieldname === 'installSchemaFile') {
|
if (part.fieldname === 'installSchemaFile') {
|
||||||
const data = await part.toBuffer();
|
const data = await part.toBuffer();
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
installSchemaFile = {
|
installSchemaFile = {
|
||||||
filename: part.filename || 'install-schema.json',
|
filename: part.filename || 'install-schema.json',
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.fieldname === 'configTemplatesFile') {
|
if (part.fieldname === 'configTemplatesFile') {
|
||||||
const data = await part.toBuffer();
|
const data = await part.toBuffer();
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
configTemplatesFile = {
|
configTemplatesFile = {
|
||||||
filename: part.filename || 'config-templates.json',
|
filename: part.filename || 'config-templates.json',
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackName = `artifact-${files.length + 1}.bin`;
|
const fallbackName = `artifact-${files.length + 1}.bin`;
|
||||||
const queuedPath = relativePathQueue.shift();
|
const queuedPath = relativePathQueue.shift();
|
||||||
const relativePath = normalizeRelativePath(
|
const relativePath = normalizeRelativePath(
|
||||||
queuedPath ?? part.filename ?? '',
|
queuedPath ?? part.filename ?? '',
|
||||||
fallbackName,
|
fallbackName,
|
||||||
);
|
);
|
||||||
const data = await part.toBuffer();
|
const data = await part.toBuffer();
|
||||||
if (data.length === 0) continue;
|
if (data.length === 0) continue;
|
||||||
files.push({ relativePath, data });
|
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 {
|
} else {
|
||||||
if (part.fieldname === 'relativePath') {
|
artifactType = 'file';
|
||||||
const raw = typeof part.value === 'string' ? part.value : '';
|
const [singleFile] = files;
|
||||||
relativePathQueue.push(raw);
|
if (!singleFile) {
|
||||||
continue;
|
throw AppError.badRequest('No artifact file received');
|
||||||
}
|
}
|
||||||
|
|
||||||
fields[part.fieldname] = part.value;
|
artifactContent = singleFile.data;
|
||||||
}
|
const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin';
|
||||||
}
|
uploadFileName = rawFileName || originalName;
|
||||||
|
releaseFileName = uploadFileName;
|
||||||
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 uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, {
|
||||||
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,
|
pluginId: plugin.id,
|
||||||
version,
|
pluginSlug: plugin.slug,
|
||||||
channel,
|
releaseVersion: version,
|
||||||
artifactType,
|
uploadedBy: request.user.sub,
|
||||||
artifactUrl: uploaded.artifactPointer,
|
uploadMode: shouldZip ? 'archive' : 'single',
|
||||||
destination,
|
sourceFileCount: files.length,
|
||||||
fileName: releaseFileName,
|
});
|
||||||
changelog,
|
|
||||||
installSchema,
|
|
||||||
configTemplates,
|
|
||||||
isPublished,
|
|
||||||
createdByUserId: request.user.sub,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return reply.code(201).send({
|
const [created] = await app.db
|
||||||
release: created,
|
.insert(pluginReleases)
|
||||||
artifact: {
|
.values({
|
||||||
bucket: uploaded.bucket,
|
pluginId: plugin.id,
|
||||||
fileId: uploaded.file.id,
|
version,
|
||||||
storedName: uploaded.file.storedName,
|
channel,
|
||||||
originalName: uploaded.file.originalName,
|
artifactType,
|
||||||
pointer: uploaded.artifactPointer,
|
artifactUrl: uploaded.artifactPointer,
|
||||||
},
|
destination,
|
||||||
});
|
fileName: releaseFileName,
|
||||||
});
|
changelog,
|
||||||
|
installSchema,
|
||||||
|
configTemplates,
|
||||||
|
isPublished,
|
||||||
|
createdByUserId: request.user.sub,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => {
|
return reply.code(201).send({
|
||||||
const { pluginId } = request.params as { pluginId: string };
|
release: created,
|
||||||
const body = request.body as {
|
artifact: {
|
||||||
version: string;
|
bucket: uploaded.bucket,
|
||||||
channel?: 'stable' | 'beta' | 'alpha';
|
fileId: uploaded.file.id,
|
||||||
artifactType?: 'file' | 'zip';
|
storedName: uploaded.file.storedName,
|
||||||
artifactUrl: string;
|
originalName: uploaded.file.originalName,
|
||||||
destination?: string;
|
pointer: uploaded.artifactPointer,
|
||||||
fileName?: string;
|
},
|
||||||
changelog?: string;
|
});
|
||||||
installSchema?: unknown[];
|
},
|
||||||
configTemplates?: unknown[];
|
);
|
||||||
isPublished?: boolean;
|
|
||||||
cloneFromReleaseId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const plugin = await app.db.query.plugins.findFirst({
|
app.post(
|
||||||
where: eq(plugins.id, pluginId),
|
'/plugins/:pluginId/releases',
|
||||||
});
|
{ schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } },
|
||||||
if (!plugin) throw AppError.notFound('Plugin not found');
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
|
const plugin = await app.db.query.plugins.findFirst({
|
||||||
if (body.cloneFromReleaseId) {
|
where: eq(plugins.id, pluginId),
|
||||||
baseRelease = await app.db.query.pluginReleases.findFirst({
|
});
|
||||||
where: and(
|
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||||
eq(pluginReleases.id, body.cloneFromReleaseId),
|
|
||||||
eq(pluginReleases.pluginId, pluginId),
|
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
|
||||||
),
|
if (body.cloneFromReleaseId) {
|
||||||
}) ?? null;
|
baseRelease =
|
||||||
if (!baseRelease) {
|
(await app.db.query.pluginReleases.findFirst({
|
||||||
throw AppError.notFound('Clone source release not found');
|
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
|
const [created] = await app.db
|
||||||
.insert(pluginReleases)
|
.insert(pluginReleases)
|
||||||
.values({
|
.values({
|
||||||
pluginId,
|
pluginId,
|
||||||
version: body.version,
|
version: body.version,
|
||||||
channel: body.channel ?? baseRelease?.channel ?? 'stable',
|
channel: body.channel ?? baseRelease?.channel ?? 'stable',
|
||||||
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
|
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
|
||||||
artifactUrl: body.artifactUrl,
|
artifactUrl: body.artifactUrl,
|
||||||
destination: body.destination ?? baseRelease?.destination ?? null,
|
destination: body.destination ?? baseRelease?.destination ?? null,
|
||||||
fileName: body.fileName ?? baseRelease?.fileName ?? null,
|
fileName: body.fileName ?? baseRelease?.fileName ?? null,
|
||||||
changelog: body.changelog ?? baseRelease?.changelog ?? null,
|
changelog: body.changelog ?? baseRelease?.changelog ?? null,
|
||||||
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
|
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
|
||||||
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
|
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
|
||||||
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
|
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
|
||||||
createdByUserId: request.user.sub,
|
createdByUserId: request.user.sub,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return reply.code(201).send(created);
|
return reply.code(201).send(created);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.patch(
|
app.patch(
|
||||||
'/plugins/:pluginId/releases/:releaseId',
|
'/plugins/:pluginId/releases/:releaseId',
|
||||||
@@ -898,10 +924,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// GET /api/admin/nodes
|
// GET /api/admin/nodes
|
||||||
app.get('/nodes', async () => {
|
app.get('/nodes', async () => {
|
||||||
const nodeList = await app.db
|
const nodeList = await app.db.select().from(nodes).orderBy(nodes.createdAt);
|
||||||
.select()
|
|
||||||
.from(nodes)
|
|
||||||
.orderBy(nodes.createdAt);
|
|
||||||
|
|
||||||
return { data: nodeList };
|
return { data: nodeList };
|
||||||
});
|
});
|
||||||
@@ -910,7 +933,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// GET /api/admin/audit-logs
|
// GET /api/admin/audit-logs
|
||||||
app.get('/audit-logs', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
|
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);
|
const [totalResult] = await app.db.select({ count: count() }).from(auditLogs);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const CreateGameSchema = {
|
|||||||
defaultPort: Type.Number({ minimum: 1, maximum: 65535 }),
|
defaultPort: Type.Number({ minimum: 1, maximum: 65535 }),
|
||||||
startupCommand: Type.String({ minLength: 1 }),
|
startupCommand: Type.String({ minLength: 1 }),
|
||||||
stopCommand: Type.Optional(Type.String()),
|
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())),
|
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||||
@@ -21,6 +23,8 @@ export const UpdateGameSchema = {
|
|||||||
defaultPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
|
defaultPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
|
||||||
startupCommand: Type.Optional(Type.String({ minLength: 1 })),
|
startupCommand: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
stopCommand: Type.Optional(Type.String()),
|
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())),
|
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||||
@@ -86,10 +90,14 @@ export const ReleaseInstallFieldSchema = Type.Object({
|
|||||||
description: Type.Optional(Type.String({ maxLength: 1000 })),
|
description: Type.Optional(Type.String({ maxLength: 1000 })),
|
||||||
required: Type.Optional(Type.Boolean()),
|
required: Type.Optional(Type.Boolean()),
|
||||||
defaultValue: Type.Optional(Type.Any()),
|
defaultValue: Type.Optional(Type.Any()),
|
||||||
options: Type.Optional(Type.Array(Type.Object({
|
options: Type.Optional(
|
||||||
label: Type.String({ minLength: 1, maxLength: 255 }),
|
Type.Array(
|
||||||
value: Type.String({ minLength: 1, maxLength: 255 }),
|
Type.Object({
|
||||||
}))),
|
label: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
value: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
min: Type.Optional(Type.Number()),
|
min: Type.Optional(Type.Number()),
|
||||||
max: Type.Optional(Type.Number()),
|
max: Type.Optional(Type.Number()),
|
||||||
pattern: Type.Optional(Type.String({ maxLength: 500 })),
|
pattern: Type.Optional(Type.String({ maxLength: 500 })),
|
||||||
@@ -103,7 +111,9 @@ export const ReleaseTemplateSchema = Type.Object({
|
|||||||
|
|
||||||
const ImportPluginReleasePayloadSchema = Type.Object({
|
const ImportPluginReleasePayloadSchema = Type.Object({
|
||||||
version: Type.String({ minLength: 1, maxLength: 100 }),
|
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
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')])),
|
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||||
artifactUrl: Type.String({ format: 'uri' }),
|
artifactUrl: Type.String({ format: 'uri' }),
|
||||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
@@ -134,7 +144,9 @@ export const ImportPluginsSchema = {
|
|||||||
export const CreatePluginReleaseSchema = {
|
export const CreatePluginReleaseSchema = {
|
||||||
body: Type.Object({
|
body: Type.Object({
|
||||||
version: Type.String({ minLength: 1, maxLength: 100 }),
|
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
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')])),
|
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||||
artifactUrl: Type.String({ format: 'uri' }),
|
artifactUrl: Type.String({ format: 'uri' }),
|
||||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
@@ -150,7 +162,9 @@ export const CreatePluginReleaseSchema = {
|
|||||||
export const UpdatePluginReleaseSchema = {
|
export const UpdatePluginReleaseSchema = {
|
||||||
body: Type.Object({
|
body: Type.Object({
|
||||||
version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||||
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
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')])),
|
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||||
artifactUrl: Type.Optional(Type.String({ format: 'uri' })),
|
artifactUrl: Type.Optional(Type.String({ format: 'uri' })),
|
||||||
destination: Type.Optional(Type.String({ minLength: 1 })),
|
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { users } from '@source/database';
|
import { users } from '@source/database';
|
||||||
import { hashPassword, verifyPassword } from '../../lib/password.js';
|
import { hashPassword, verifyPassword } from '../../lib/password.js';
|
||||||
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.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 { AppError } from '../../lib/errors.js';
|
||||||
import { RegisterSchema, LoginSchema } from './schemas.js';
|
import { RegisterSchema, LoginSchema } from './schemas.js';
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ export default async function gameRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// GET /api/games
|
// GET /api/games
|
||||||
app.get('/', async () => {
|
app.get('/', async () => {
|
||||||
const gameList = await app.db
|
const gameList = await app.db.select().from(games).orderBy(games.name);
|
||||||
.select()
|
|
||||||
.from(games)
|
|
||||||
.orderBy(games.name);
|
|
||||||
|
|
||||||
return { data: gameList };
|
return { data: gameList };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ function extractCdnWebhookSecret(request: FastifyRequest): string | null {
|
|||||||
return byHeader.trim();
|
return byHeader.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeader = typeof request.headers.authorization === 'string'
|
const authHeader =
|
||||||
? request.headers.authorization
|
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined;
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return extractBearerToken(authHeader);
|
return extractBearerToken(authHeader);
|
||||||
}
|
}
|
||||||
@@ -30,9 +29,7 @@ async function requireDaemonToken(
|
|||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const token = extractBearerToken(
|
const token = extractBearerToken(
|
||||||
typeof request.headers.authorization === 'string'
|
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined,
|
||||||
? request.headers.authorization
|
|
||||||
: undefined,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -69,14 +66,14 @@ export default async function internalRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = request.body as Record<string, unknown> | undefined;
|
const body = request.body as Record<string, unknown> | undefined;
|
||||||
const eventType = typeof body?.eventType === 'string'
|
const eventType =
|
||||||
? body.eventType
|
typeof body?.eventType === 'string'
|
||||||
: (typeof body?.type === 'string' ? body.type : 'unknown');
|
? body.eventType
|
||||||
|
: typeof body?.type === 'string'
|
||||||
|
? body.type
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
request.log.info(
|
request.log.info({ eventType, payload: body }, 'Received CDN plugin webhook event');
|
||||||
{ eventType, payload: body },
|
|
||||||
'Received CDN plugin webhook event',
|
|
||||||
);
|
|
||||||
|
|
||||||
return reply.code(202).send({ accepted: true });
|
return reply.code(202).send({ accepted: true });
|
||||||
},
|
},
|
||||||
@@ -98,11 +95,13 @@ export default async function internalRoutes(app: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
.from(scheduledTasks)
|
.from(scheduledTasks)
|
||||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||||
.where(and(
|
.where(
|
||||||
eq(servers.nodeId, node.id),
|
and(
|
||||||
eq(scheduledTasks.isActive, true),
|
eq(servers.nodeId, node.id),
|
||||||
lte(scheduledTasks.nextRunAt, now),
|
eq(scheduledTasks.isActive, true),
|
||||||
));
|
lte(scheduledTasks.nextRunAt, now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tasks: dueTasks.map((task) => ({
|
tasks: dueTasks.map((task) => ({
|
||||||
@@ -139,10 +138,7 @@ export default async function internalRoutes(app: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
.from(scheduledTasks)
|
.from(scheduledTasks)
|
||||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||||
.where(and(
|
.where(and(eq(scheduledTasks.id, taskId), eq(servers.nodeId, node.id)));
|
||||||
eq(scheduledTasks.id, taskId),
|
|
||||||
eq(servers.nodeId, node.id),
|
|
||||||
));
|
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw AppError.notFound('Scheduled task not found');
|
throw AppError.notFound('Scheduled task not found');
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ export default async function daemonNodeRoutes(app: FastifyInstance) {
|
|||||||
// POST /api/nodes/heartbeat
|
// POST /api/nodes/heartbeat
|
||||||
app.post('/heartbeat', { schema: HeartbeatSchema }, async (request) => {
|
app.post('/heartbeat', { schema: HeartbeatSchema }, async (request) => {
|
||||||
const token = extractBearerToken(
|
const token = extractBearerToken(
|
||||||
typeof request.headers.authorization === 'string'
|
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined,
|
||||||
? request.headers.authorization
|
|
||||||
: undefined,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -94,28 +94,32 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/organizations/:orgId/nodes/:nodeId
|
// PATCH /api/organizations/:orgId/nodes/:nodeId
|
||||||
app.patch('/:nodeId', { schema: { ...NodeParamSchema, ...UpdateNodeSchema } }, async (request) => {
|
app.patch(
|
||||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
'/:nodeId',
|
||||||
await requirePermission(request, orgId, 'node.manage');
|
{ schema: { ...NodeParamSchema, ...UpdateNodeSchema } },
|
||||||
|
async (request) => {
|
||||||
|
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||||
|
await requirePermission(request, orgId, 'node.manage');
|
||||||
|
|
||||||
const body = request.body as Record<string, unknown>;
|
const body = request.body as Record<string, unknown>;
|
||||||
|
|
||||||
const [updated] = await app.db
|
const [updated] = await app.db
|
||||||
.update(nodes)
|
.update(nodes)
|
||||||
.set({ ...body, updatedAt: new Date() })
|
.set({ ...body, updatedAt: new Date() })
|
||||||
.where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)))
|
.where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) throw AppError.notFound('Node not found');
|
if (!updated) throw AppError.notFound('Node not found');
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
action: 'node.update',
|
action: 'node.update',
|
||||||
metadata: { nodeId, ...body },
|
metadata: { nodeId, ...body },
|
||||||
});
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// DELETE /api/organizations/:orgId/nodes/:nodeId
|
// DELETE /api/organizations/:orgId/nodes/:nodeId
|
||||||
app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => {
|
app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => {
|
||||||
@@ -244,30 +248,34 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/organizations/:orgId/nodes/:nodeId/allocations
|
// POST /api/organizations/:orgId/nodes/:nodeId/allocations
|
||||||
app.post('/:nodeId/allocations', { schema: { ...NodeParamSchema, ...CreateAllocationSchema } }, async (request, reply) => {
|
app.post(
|
||||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
'/:nodeId/allocations',
|
||||||
await requirePermission(request, orgId, 'node.manage');
|
{ schema: { ...NodeParamSchema, ...CreateAllocationSchema } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||||
|
await requirePermission(request, orgId, 'node.manage');
|
||||||
|
|
||||||
const { ip, ports } = request.body as { ip: string; ports: number[] };
|
const { ip, ports } = request.body as { ip: string; ports: number[] };
|
||||||
|
|
||||||
const values = ports.map((port) => ({
|
const values = ports.map((port) => ({
|
||||||
nodeId,
|
nodeId,
|
||||||
ip,
|
ip,
|
||||||
port,
|
port,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const created = await app.db
|
const created = await app.db
|
||||||
.insert(allocations)
|
.insert(allocations)
|
||||||
.values(values)
|
.values(values)
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
action: 'allocation.create',
|
action: 'allocation.create',
|
||||||
metadata: { nodeId, ip, ports },
|
metadata: { nodeId, ip, ports },
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(201).send({ data: created });
|
return reply.code(201).send({ data: created });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { organizations, organizationMembers, users } from '@source/database';
|
|||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requirePermission, getOrgMembership } from '../../lib/permissions.js';
|
import { requirePermission, getOrgMembership } from '../../lib/permissions.js';
|
||||||
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
||||||
|
import type { PaginationQuery } from '../../lib/pagination.js';
|
||||||
import { createAuditLog } from '../../lib/audit.js';
|
import { createAuditLog } from '../../lib/audit.js';
|
||||||
import {
|
import {
|
||||||
CreateOrgSchema,
|
CreateOrgSchema,
|
||||||
@@ -20,7 +21,7 @@ export default async function organizationRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// GET /api/organizations — list user's organizations
|
// GET /api/organizations — list user's organizations
|
||||||
app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
|
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;
|
const userId = request.user.sub;
|
||||||
|
|
||||||
if (request.user.isSuperAdmin) {
|
if (request.user.isSuperAdmin) {
|
||||||
@@ -173,104 +174,117 @@ export default async function organizationRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/organizations/:orgId/members — invite by email
|
// POST /api/organizations/:orgId/members — invite by email
|
||||||
app.post('/:orgId/members', { schema: { ...OrgIdParamSchema, ...AddMemberSchema } }, async (request, reply) => {
|
app.post(
|
||||||
const { orgId } = request.params as { orgId: string };
|
'/:orgId/members',
|
||||||
await requirePermission(request, orgId, 'org.members');
|
{ schema: { ...OrgIdParamSchema, ...AddMemberSchema } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { orgId } = request.params as { orgId: string };
|
||||||
|
await requirePermission(request, orgId, 'org.members');
|
||||||
|
|
||||||
const { email, role } = request.body as { email: string; role: 'admin' | 'user' };
|
const { email, role } = request.body as { email: string; role: 'admin' | 'user' };
|
||||||
|
|
||||||
const user = await app.db.query.users.findFirst({
|
const user = await app.db.query.users.findFirst({
|
||||||
where: eq(users.email, email),
|
where: eq(users.email, email),
|
||||||
});
|
});
|
||||||
if (!user) throw AppError.notFound('User with this email not found');
|
if (!user) throw AppError.notFound('User with this email not found');
|
||||||
|
|
||||||
const existing = await app.db.query.organizationMembers.findFirst({
|
const existing = await app.db.query.organizationMembers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(organizationMembers.organizationId, orgId),
|
eq(organizationMembers.organizationId, orgId),
|
||||||
eq(organizationMembers.userId, user.id),
|
eq(organizationMembers.userId, user.id),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (existing) throw AppError.conflict('User is already a member');
|
if (existing) throw AppError.conflict('User is already a member');
|
||||||
|
|
||||||
const [member] = await app.db
|
const [member] = await app.db
|
||||||
.insert(organizationMembers)
|
.insert(organizationMembers)
|
||||||
.values({
|
.values({
|
||||||
|
organizationId: orgId,
|
||||||
|
userId: user.id,
|
||||||
|
role,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
userId: user.id,
|
action: 'member.add',
|
||||||
role,
|
metadata: { userId: user.id, email, role },
|
||||||
})
|
});
|
||||||
.returning();
|
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
return reply.code(201).send(member);
|
||||||
organizationId: orgId,
|
},
|
||||||
action: 'member.add',
|
);
|
||||||
metadata: { userId: user.id, email, role },
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.code(201).send(member);
|
|
||||||
});
|
|
||||||
|
|
||||||
// PATCH /api/organizations/:orgId/members/:memberId
|
// PATCH /api/organizations/:orgId/members/:memberId
|
||||||
app.patch('/:orgId/members/:memberId', { schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } }, async (request) => {
|
app.patch(
|
||||||
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
|
'/:orgId/members/:memberId',
|
||||||
await requirePermission(request, orgId, 'org.members');
|
{ 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
|
const [updated] = await app.db
|
||||||
.update(organizationMembers)
|
.update(organizationMembers)
|
||||||
.set(body)
|
.set(body)
|
||||||
.where(and(
|
.where(
|
||||||
eq(organizationMembers.id, memberId),
|
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
|
||||||
eq(organizationMembers.organizationId, orgId),
|
)
|
||||||
))
|
.returning();
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updated) throw AppError.notFound('Member not found');
|
if (!updated) throw AppError.notFound('Member not found');
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
action: 'member.update',
|
action: 'member.update',
|
||||||
metadata: { memberId, ...body },
|
metadata: { memberId, ...body },
|
||||||
});
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// DELETE /api/organizations/:orgId/members/:memberId
|
// DELETE /api/organizations/:orgId/members/:memberId
|
||||||
app.delete('/:orgId/members/:memberId', { schema: MemberIdParamSchema }, async (request, reply) => {
|
app.delete(
|
||||||
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
|
'/:orgId/members/:memberId',
|
||||||
await requirePermission(request, orgId, 'org.members');
|
{ schema: MemberIdParamSchema },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
|
||||||
|
await requirePermission(request, orgId, 'org.members');
|
||||||
|
|
||||||
const member = await app.db.query.organizationMembers.findFirst({
|
const member = await app.db.query.organizationMembers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(organizationMembers.id, memberId),
|
eq(organizationMembers.id, memberId),
|
||||||
eq(organizationMembers.organizationId, orgId),
|
eq(organizationMembers.organizationId, orgId),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (!member) throw AppError.notFound('Member not found');
|
if (!member) throw AppError.notFound('Member not found');
|
||||||
|
|
||||||
// Cannot remove org owner
|
// Cannot remove org owner
|
||||||
const org = await app.db.query.organizations.findFirst({
|
const org = await app.db.query.organizations.findFirst({
|
||||||
where: eq(organizations.id, orgId),
|
where: eq(organizations.id, orgId),
|
||||||
});
|
});
|
||||||
if (org && member.userId === org.ownerId) {
|
if (org && member.userId === org.ownerId) {
|
||||||
throw AppError.badRequest('Cannot remove the organization owner');
|
throw AppError.badRequest('Cannot remove the organization owner');
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.db
|
await app.db
|
||||||
.delete(organizationMembers)
|
.delete(organizationMembers)
|
||||||
.where(and(
|
.where(
|
||||||
eq(organizationMembers.id, memberId),
|
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
|
||||||
eq(organizationMembers.organizationId, orgId),
|
);
|
||||||
));
|
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
action: 'member.remove',
|
action: 'member.remove',
|
||||||
metadata: { memberId, userId: member.userId },
|
metadata: { memberId, userId: member.userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ export default async function backupRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
completedBackup = updated ?? completedBackup;
|
completedBackup = updated ?? completedBackup;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request.log.error({ error, serverId, backupId: backup.id }, 'Failed to create backup on daemon');
|
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));
|
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');
|
throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED');
|
||||||
}
|
}
|
||||||
@@ -140,10 +143,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
|||||||
backup.cdnPath,
|
backup.cdnPath,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request.log.error(
|
request.log.error({ error, serverId, backupId }, 'Failed to restore backup on daemon');
|
||||||
{ error, serverId, backupId },
|
|
||||||
'Failed to restore backup on daemon',
|
|
||||||
);
|
|
||||||
throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED');
|
throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +200,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
|||||||
try {
|
try {
|
||||||
await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id);
|
await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request.log.error(
|
request.log.error({ error, serverId, backupId }, 'Failed to delete backup on daemon');
|
||||||
{ error, serverId, backupId },
|
|
||||||
'Failed to delete backup on daemon',
|
|
||||||
);
|
|
||||||
throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED');
|
throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { requirePermission } from '../../lib/permissions.js';
|
|||||||
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
|
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
|
||||||
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
|
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
|
||||||
import {
|
import {
|
||||||
isManagedCs2ServerConfigPath,
|
managedConfigFileFor,
|
||||||
readManagedCs2ServerConfig,
|
readManagedConfig,
|
||||||
writeManagedCs2ServerConfig,
|
writeManagedConfig,
|
||||||
} from '../../lib/cs2-server-config.js';
|
} from '../../lib/managed-config.js';
|
||||||
|
|
||||||
const ParamSchema = {
|
const ParamSchema = {
|
||||||
params: Type.Object({
|
params: Type.Object({
|
||||||
@@ -66,20 +66,33 @@ export default async function configRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
await requirePermission(request, orgId, 'config.read');
|
await requirePermission(request, orgId, 'config.read');
|
||||||
|
|
||||||
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
const { game, server, node, configFile } = await getServerConfig(
|
||||||
|
app,
|
||||||
|
orgId,
|
||||||
|
serverId,
|
||||||
|
configIndex,
|
||||||
|
);
|
||||||
|
|
||||||
let raw = '';
|
let raw = '';
|
||||||
try {
|
try {
|
||||||
if (isManagedCs2ServerConfigPath(game.slug, configFile.path)) {
|
const managedFile = managedConfigFileFor(game.slug, configFile.path);
|
||||||
raw = await readManagedCs2ServerConfig(node, server.uuid);
|
if (managedFile) {
|
||||||
|
raw = await readManagedConfig(node, server.uuid, managedFile);
|
||||||
} else {
|
} else {
|
||||||
const file = await daemonReadFile(node, server.uuid, configFile.path);
|
const file = await daemonReadFile(node, server.uuid, configFile.path);
|
||||||
raw = file.data.toString('utf8');
|
raw = file.data.toString('utf8');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingConfigFileError(error)) {
|
if (!isMissingConfigFileError(error)) {
|
||||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon');
|
app.log.error(
|
||||||
throw new AppError(502, 'Failed to read config file from daemon', 'DAEMON_CONFIG_READ_FAILED');
|
{ 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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,15 +131,20 @@ export default async function configRoutes(app: FastifyInstance) {
|
|||||||
const { entries } = request.body as { entries: { key: string; value: string }[] };
|
const { entries } = request.body as { entries: { key: string; value: string }[] };
|
||||||
await requirePermission(request, orgId, 'config.write');
|
await requirePermission(request, orgId, 'config.write');
|
||||||
|
|
||||||
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
const { game, server, node, configFile } = await getServerConfig(
|
||||||
|
app,
|
||||||
|
orgId,
|
||||||
|
serverId,
|
||||||
|
configIndex,
|
||||||
|
);
|
||||||
|
|
||||||
const isManagedCs2Config = isManagedCs2ServerConfigPath(game.slug, configFile.path);
|
const managedFile = managedConfigFileFor(game.slug, configFile.path);
|
||||||
|
|
||||||
let originalContent: string | undefined;
|
let originalContent: string | undefined;
|
||||||
let originalEntries: { key: string; value: string }[] = [];
|
let originalEntries: { key: string; value: string }[] = [];
|
||||||
try {
|
try {
|
||||||
if (isManagedCs2Config) {
|
if (managedFile) {
|
||||||
originalContent = await readManagedCs2ServerConfig(node, server.uuid);
|
originalContent = await readManagedConfig(node, server.uuid, managedFile);
|
||||||
} else {
|
} else {
|
||||||
const current = await daemonReadFile(node, server.uuid, configFile.path);
|
const current = await daemonReadFile(node, server.uuid, configFile.path);
|
||||||
originalContent = current.data.toString('utf8');
|
originalContent = current.data.toString('utf8');
|
||||||
@@ -134,8 +152,15 @@ export default async function configRoutes(app: FastifyInstance) {
|
|||||||
originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser);
|
originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingConfigFileError(error)) {
|
if (!isMissingConfigFileError(error)) {
|
||||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write');
|
app.log.error(
|
||||||
throw new AppError(502, 'Failed to read existing config file', 'DAEMON_CONFIG_READ_FAILED');
|
{ 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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,14 +180,10 @@ export default async function configRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = serializeConfig(
|
const content = serializeConfig(entries, configFile.parser as ConfigParser, originalContent);
|
||||||
entries,
|
|
||||||
configFile.parser as ConfigParser,
|
|
||||||
originalContent,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isManagedCs2Config) {
|
if (managedFile) {
|
||||||
await writeManagedCs2ServerConfig(node, server.uuid, content);
|
await writeManagedConfig(node, server.uuid, managedFile, content);
|
||||||
} else {
|
} else {
|
||||||
await daemonWriteFile(node, server.uuid, configFile.path, content);
|
await daemonWriteFile(node, server.uuid, configFile.path, content);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,180 +99,188 @@ export default async function databaseRoutes(app: FastifyInstance) {
|
|||||||
return { data: databases };
|
return { data: databases };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/', { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, async (request, reply) => {
|
app.post(
|
||||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
'/',
|
||||||
await requirePermission(request, orgId, 'server.update');
|
{ 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 body = request.body as { name: string; password?: string };
|
||||||
const name = body.name.trim();
|
const name = body.name.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw AppError.badRequest('Database name is required');
|
throw AppError.badRequest('Database name is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = await getServerContext(app, orgId, serverId);
|
const server = await getServerContext(app, orgId, serverId);
|
||||||
|
|
||||||
let managedDatabase;
|
let managedDatabase;
|
||||||
try {
|
try {
|
||||||
managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), {
|
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,
|
name,
|
||||||
databaseName: managedDatabase.databaseName,
|
password: body.password,
|
||||||
username: managedDatabase.username,
|
serverUuid: server.uuid,
|
||||||
password: managedDatabase.password,
|
});
|
||||||
host: managedDatabase.host,
|
} catch (error) {
|
||||||
port: managedDatabase.port,
|
request.log.error(
|
||||||
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
|
{ 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();
|
.returning();
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
serverId,
|
serverId,
|
||||||
action: 'server.database.create',
|
action: 'server.database.update',
|
||||||
metadata: {
|
metadata: {
|
||||||
name: created!.name,
|
databaseId,
|
||||||
databaseName: created!.databaseName,
|
updatedName: nextName ?? undefined,
|
||||||
username: created!.username,
|
passwordRotated: Boolean(nextPassword),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(201).send(created);
|
return updated;
|
||||||
} 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) => {
|
app.delete('/:databaseId', { schema: ServerDatabaseParamSchema }, async (request, reply) => {
|
||||||
const { orgId, serverId, databaseId } = request.params as {
|
const { orgId, serverId, databaseId } = request.params as {
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import {
|
|||||||
type DaemonNodeConnection,
|
type DaemonNodeConnection,
|
||||||
} from '../../lib/daemon.js';
|
} from '../../lib/daemon.js';
|
||||||
import {
|
import {
|
||||||
CS2_PERSISTED_SERVER_CFG_PATH,
|
isManagedConfigShadowFile,
|
||||||
CS2_PERSISTED_SERVER_CFG_FILE,
|
managedConfigFileFor,
|
||||||
isManagedCs2ServerConfigPath,
|
readManagedConfig,
|
||||||
readManagedCs2ServerConfig,
|
writeManagedConfig,
|
||||||
writeManagedCs2ServerConfig,
|
} from '../../lib/managed-config.js';
|
||||||
} from '../../lib/cs2-server-config.js';
|
|
||||||
|
|
||||||
const FileParamSchema = {
|
const FileParamSchema = {
|
||||||
params: Type.Object({
|
params: Type.Object({
|
||||||
@@ -27,8 +26,8 @@ const FileParamSchema = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean {
|
function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean {
|
||||||
|
if (isManagedConfigShadowFile(gameSlug, fileName)) return true;
|
||||||
if (gameSlug !== 'cs2') return false;
|
if (gameSlug !== 'cs2') return false;
|
||||||
if (fileName.trim() === CS2_PERSISTED_SERVER_CFG_FILE) return true;
|
|
||||||
if (isDirectory) return false;
|
if (isDirectory) return false;
|
||||||
|
|
||||||
const normalizedName = fileName.trim().toLowerCase();
|
const normalizedName = fileName.trim().toLowerCase();
|
||||||
@@ -108,9 +107,10 @@ export default async function fileRoutes(app: FastifyInstance) {
|
|||||||
let payload: Buffer;
|
let payload: Buffer;
|
||||||
let mimeType = 'text/plain';
|
let mimeType = 'text/plain';
|
||||||
|
|
||||||
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) {
|
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
|
||||||
|
if (managedFile) {
|
||||||
payload = Buffer.from(
|
payload = Buffer.from(
|
||||||
await readManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid),
|
await readManagedConfig(serverContext.node, serverContext.serverUuid, managedFile),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -121,9 +121,7 @@ export default async function fileRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data:
|
data:
|
||||||
requestedEncoding === 'base64'
|
requestedEncoding === 'base64' ? payload.toString('base64') : payload.toString('utf8'),
|
||||||
? payload.toString('base64')
|
|
||||||
: payload.toString('utf8'),
|
|
||||||
encoding: requestedEncoding,
|
encoding: requestedEncoding,
|
||||||
mimeType,
|
mimeType,
|
||||||
};
|
};
|
||||||
@@ -156,8 +154,14 @@ export default async function fileRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
|
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
|
||||||
|
|
||||||
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) {
|
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
|
||||||
await writeManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid, payload);
|
if (managedFile) {
|
||||||
|
await writeManagedConfig(
|
||||||
|
serverContext.node,
|
||||||
|
serverContext.serverUuid,
|
||||||
|
managedFile,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
|
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
|
||||||
}
|
}
|
||||||
@@ -182,16 +186,17 @@ export default async function fileRoutes(app: FastifyInstance) {
|
|||||||
await requirePermission(request, orgId, 'files.delete');
|
await requirePermission(request, orgId, 'files.delete');
|
||||||
const serverContext = await getServerContext(app, orgId, serverId);
|
const serverContext = await getServerContext(app, orgId, serverId);
|
||||||
|
|
||||||
const resolvedPaths = paths.flatMap((path) =>
|
// Deleting a managed config also drops the panel's sidecar copy,
|
||||||
isManagedCs2ServerConfigPath(serverContext.gameSlug, path)
|
// otherwise the next start would resurrect the file.
|
||||||
? [
|
const resolvedPaths = paths.flatMap((path) => {
|
||||||
path,
|
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
|
||||||
path.trim().startsWith('/')
|
if (!managedFile) return [path];
|
||||||
? `/${CS2_PERSISTED_SERVER_CFG_PATH}`
|
|
||||||
: CS2_PERSISTED_SERVER_CFG_PATH,
|
return [
|
||||||
]
|
path,
|
||||||
: [path],
|
path.trim().startsWith('/') ? `/${managedFile.shadowPath}` : managedFile.shadowPath,
|
||||||
);
|
];
|
||||||
|
});
|
||||||
|
|
||||||
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths);
|
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths);
|
||||||
return { success: true, paths };
|
return { success: true, paths };
|
||||||
@@ -199,7 +204,11 @@ export default async function fileRoutes(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
async function getServerContext(
|
||||||
|
app: FastifyInstance,
|
||||||
|
orgId: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<{
|
||||||
serverUuid: string;
|
serverUuid: string;
|
||||||
gameSlug: string;
|
gameSlug: string;
|
||||||
node: DaemonNodeConnection;
|
node: DaemonNodeConnection;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { GameAutomationRule, PowerAction, ServerAutomationEvent } from '@so
|
|||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requirePermission } from '../../lib/permissions.js';
|
import { requirePermission } from '../../lib/permissions.js';
|
||||||
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
||||||
|
import type { PaginationQuery } from '../../lib/pagination.js';
|
||||||
import { createAuditLog } from '../../lib/audit.js';
|
import { createAuditLog } from '../../lib/audit.js';
|
||||||
import {
|
import {
|
||||||
deleteFivemQbCoreDatabase,
|
deleteFivemQbCoreDatabase,
|
||||||
@@ -26,7 +27,7 @@ import {
|
|||||||
type DaemonNodeConnection,
|
type DaemonNodeConnection,
|
||||||
type DaemonPortMapping,
|
type DaemonPortMapping,
|
||||||
} from '../../lib/daemon.js';
|
} from '../../lib/daemon.js';
|
||||||
import { reapplyManagedCs2ServerConfig } from '../../lib/cs2-server-config.js';
|
import { sustainManagedConfigsAfterStart } from '../../lib/managed-config.js';
|
||||||
import {
|
import {
|
||||||
ServerParamSchema,
|
ServerParamSchema,
|
||||||
CreateServerSchema,
|
CreateServerSchema,
|
||||||
@@ -146,6 +147,14 @@ function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequ
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (slug === 'ark-se') {
|
||||||
|
return [
|
||||||
|
{ key: 'ark-raw-udp', label: 'Raw UDP Socket Port', protocols: ['udp'] },
|
||||||
|
{ key: 'ark-query', label: 'Steam Query Port', protocols: ['udp'] },
|
||||||
|
{ key: 'ark-rcon', label: 'RCON Port', protocols: ['tcp'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,18 +197,38 @@ function applyGameRuntimeEnvironment(
|
|||||||
additionalPortsRaw: unknown,
|
additionalPortsRaw: unknown,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const slug = gameSlug.trim().toLowerCase();
|
const slug = gameSlug.trim().toLowerCase();
|
||||||
if (slug !== 'satisfactory') return environment;
|
|
||||||
|
|
||||||
const additionalPorts = normalizeAdditionalServerPorts(additionalPortsRaw);
|
const additionalPorts = normalizeAdditionalServerPorts(additionalPortsRaw);
|
||||||
const messagingPort = additionalPorts.find(
|
const findPort = (key: string, protocol: PortProtocol) =>
|
||||||
(port) => port.key === 'satisfactory-messaging' && port.protocol === 'tcp',
|
additionalPorts.find((port) => port.key === key && port.protocol === protocol);
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
if (slug === 'satisfactory') {
|
||||||
...environment,
|
const messagingPort = findPort('satisfactory-messaging', 'tcp');
|
||||||
SERVERGAMEPORT: String(allocationPort),
|
|
||||||
SERVERMESSAGINGPORT: String(messagingPort?.hostPort ?? 8888),
|
return {
|
||||||
};
|
...environment,
|
||||||
|
SERVERGAMEPORT: String(allocationPort),
|
||||||
|
SERVERMESSAGINGPORT: String(messagingPort?.hostPort ?? 8888),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug === 'ark-se') {
|
||||||
|
// The ARK image binds exactly the ports it is told about, so the
|
||||||
|
// allocations have to be mirrored into its environment.
|
||||||
|
const rconPort = findPort('ark-rcon', 'tcp')?.hostPort ?? 27020;
|
||||||
|
const adminPassword = environment.ADMIN_PASSWORD ?? '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...environment,
|
||||||
|
GAME_CLIENT_PORT: String(allocationPort),
|
||||||
|
UDP_SOCKET_PORT: String(findPort('ark-raw-udp', 'udp')?.hostPort ?? allocationPort + 1),
|
||||||
|
SERVER_LIST_PORT: String(findPort('ark-query', 'udp')?.hostPort ?? 27015),
|
||||||
|
RCON_PORT: String(rconPort),
|
||||||
|
// The daemon reads these when it opens an RCON console session.
|
||||||
|
RCON_PASSWORD: adminPassword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDaemonPorts(
|
function buildDaemonPorts(
|
||||||
@@ -231,6 +260,19 @@ function buildDaemonPorts(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (slug === 'ark-se') {
|
||||||
|
// Host and container ports match because the image is configured with the
|
||||||
|
// very same numbers via its environment.
|
||||||
|
return [
|
||||||
|
{ host_port: allocationPort, container_port: allocationPort, protocol: 'udp' },
|
||||||
|
...additionalPorts.map((port) => ({
|
||||||
|
host_port: port.hostPort,
|
||||||
|
container_port: port.hostPort,
|
||||||
|
protocol: port.protocol,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (slug === 'cs2' || slug === 'csgo' || slug === 'fivem') {
|
if (slug === 'cs2' || slug === 'csgo' || slug === 'fivem') {
|
||||||
return [
|
return [
|
||||||
{ host_port: allocationPort, container_port: containerPort, protocol: 'udp' },
|
{ host_port: allocationPort, container_port: containerPort, protocol: 'udp' },
|
||||||
@@ -512,6 +554,11 @@ async function syncServerInstallStatus(
|
|||||||
'Synchronized install status from daemon',
|
'Synchronized install status from daemon',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (mapped === 'running') {
|
||||||
|
// First boot resets configs the same way a restart does.
|
||||||
|
sustainManagedConfigAfterPowerStart(app, node, serverId, serverUuid, gameSlug);
|
||||||
|
}
|
||||||
|
|
||||||
if (mapped === 'running' || mapped === 'stopped') {
|
if (mapped === 'running' || mapped === 'stopped') {
|
||||||
if (needsManagedProvisioning) {
|
if (needsManagedProvisioning) {
|
||||||
void runManagedInstallProvisioning(app, {
|
void runManagedInstallProvisioning(app, {
|
||||||
@@ -543,30 +590,35 @@ async function syncServerInstallStatus(
|
|||||||
app.log.warn({ serverId, serverUuid }, 'Timed out while waiting for daemon install completion');
|
app.log.warn({ serverId, serverUuid }, 'Timed out while waiting for daemon install completion');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sustainCs2ServerConfigAfterPowerStart(
|
/**
|
||||||
|
* Keep panel-managed config files alive across a start.
|
||||||
|
*
|
||||||
|
* Steam-based images re-validate the game install on every start and put their
|
||||||
|
* own `server.cfg` back afterwards — often many minutes in, long after a fixed
|
||||||
|
* short retry window would have given up. So the watcher polls for drift over a
|
||||||
|
* long window and stops once the file has stayed put (or the server stops).
|
||||||
|
*/
|
||||||
|
function sustainManagedConfigAfterPowerStart(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
node: DaemonNodeConnection,
|
node: DaemonNodeConnection,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
serverUuid: string,
|
serverUuid: string,
|
||||||
gameSlug: string,
|
gameSlug: string,
|
||||||
): Promise<void> {
|
): void {
|
||||||
if (gameSlug.trim().toLowerCase() !== 'cs2') return;
|
sustainManagedConfigsAfterStart(app, {
|
||||||
|
node,
|
||||||
|
serverId,
|
||||||
|
serverUuid,
|
||||||
|
gameSlug,
|
||||||
|
isServerActive: async () => {
|
||||||
|
const [row] = await app.db
|
||||||
|
.select({ status: servers.status })
|
||||||
|
.from(servers)
|
||||||
|
.where(eq(servers.id, serverId));
|
||||||
|
|
||||||
const attempts = 6;
|
return row?.status === 'running' || row?.status === 'installing';
|
||||||
const intervalMs = 10_000;
|
},
|
||||||
|
});
|
||||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
||||||
await sleep(intervalMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await reapplyManagedCs2ServerConfig(node, serverUuid);
|
|
||||||
} catch (error) {
|
|
||||||
app.log.warn(
|
|
||||||
{ error, serverId, serverUuid, attempt },
|
|
||||||
'Failed to reapply managed CS2 server.cfg after power start',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function serverRoutes(app: FastifyInstance) {
|
export default async function serverRoutes(app: FastifyInstance) {
|
||||||
@@ -586,7 +638,7 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||||||
const { orgId } = request.params as { orgId: string };
|
const { orgId } = request.params as { orgId: string };
|
||||||
await requirePermission(request, orgId, 'server.read');
|
await requirePermission(request, orgId, 'server.read');
|
||||||
|
|
||||||
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
|
const [totalResult] = await app.db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
@@ -827,6 +879,9 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||||||
),
|
),
|
||||||
ports: buildDaemonPorts(game.slug, allocation.port, game.defaultPort, additionalServerPorts),
|
ports: buildDaemonPorts(game.slug, allocation.port, game.defaultPort, additionalServerPorts),
|
||||||
install_plugin_urls: [],
|
install_plugin_urls: [],
|
||||||
|
data_path: game.containerDataPath ?? '',
|
||||||
|
stop_command: game.stopCommand ?? '',
|
||||||
|
stop_timeout_seconds: game.stopTimeoutSeconds ?? 0,
|
||||||
};
|
};
|
||||||
let createdServerResponse = server;
|
let createdServerResponse = server;
|
||||||
|
|
||||||
@@ -1195,6 +1250,9 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||||||
gameDefaultPort: games.defaultPort,
|
gameDefaultPort: games.defaultPort,
|
||||||
gameSlug: games.slug,
|
gameSlug: games.slug,
|
||||||
gameStartupCommand: games.startupCommand,
|
gameStartupCommand: games.startupCommand,
|
||||||
|
gameStopCommand: games.stopCommand,
|
||||||
|
gameStopTimeoutSeconds: games.stopTimeoutSeconds,
|
||||||
|
gameContainerDataPath: games.containerDataPath,
|
||||||
gameEnvironmentVars: games.environmentVars,
|
gameEnvironmentVars: games.environmentVars,
|
||||||
})
|
})
|
||||||
.from(servers)
|
.from(servers)
|
||||||
@@ -1256,6 +1314,9 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||||||
current.gameDefaultPort,
|
current.gameDefaultPort,
|
||||||
current.additionalPorts,
|
current.additionalPorts,
|
||||||
),
|
),
|
||||||
|
data_path: current.gameContainerDataPath ?? '',
|
||||||
|
stop_command: current.gameStopCommand ?? '',
|
||||||
|
stop_timeout_seconds: current.gameStopTimeoutSeconds ?? 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
nextStatus = mapDaemonStatus(response.status);
|
nextStatus = mapDaemonStatus(response.status);
|
||||||
@@ -1412,9 +1473,14 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||||||
nodeFqdn: nodes.fqdn,
|
nodeFqdn: nodes.fqdn,
|
||||||
nodeGrpcPort: nodes.grpcPort,
|
nodeGrpcPort: nodes.grpcPort,
|
||||||
nodeDaemonToken: nodes.daemonToken,
|
nodeDaemonToken: nodes.daemonToken,
|
||||||
|
gameSlug: games.slug,
|
||||||
|
gameStopCommand: games.stopCommand,
|
||||||
|
gameStopTimeoutSeconds: games.stopTimeoutSeconds,
|
||||||
|
gameAutomationRules: games.automationRules,
|
||||||
})
|
})
|
||||||
.from(servers)
|
.from(servers)
|
||||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||||
|
.innerJoin(games, eq(servers.gameId, games.id))
|
||||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||||
if (!server) throw AppError.notFound('Server not found');
|
if (!server) throw AppError.notFound('Server not found');
|
||||||
|
|
||||||
@@ -1422,16 +1488,17 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||||||
throw AppError.badRequest('Cannot send power action to a suspended server');
|
throw AppError.badRequest('Cannot send power action to a suspended server');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nodeConnection: DaemonNodeConnection = {
|
||||||
|
fqdn: server.nodeFqdn,
|
||||||
|
grpcPort: server.nodeGrpcPort,
|
||||||
|
daemonToken: server.nodeDaemonToken,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await daemonSetPowerState(
|
await daemonSetPowerState(nodeConnection, server.uuid, action, {
|
||||||
{
|
stopCommand: server.gameStopCommand,
|
||||||
fqdn: server.nodeFqdn,
|
stopTimeoutSeconds: server.gameStopTimeoutSeconds,
|
||||||
grpcPort: server.nodeGrpcPort,
|
});
|
||||||
daemonToken: server.nodeDaemonToken,
|
|
||||||
},
|
|
||||||
server.uuid,
|
|
||||||
action,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.error(
|
app.log.error(
|
||||||
{ error, serverId: server.id, serverUuid: server.uuid, action },
|
{ error, serverId: server.id, serverUuid: server.uuid, action },
|
||||||
@@ -1457,41 +1524,22 @@ export default async function serverRoutes(app: FastifyInstance) {
|
|||||||
.where(eq(servers.id, serverId));
|
.where(eq(servers.id, serverId));
|
||||||
|
|
||||||
if (action === 'start' || action === 'restart') {
|
if (action === 'start' || action === 'restart') {
|
||||||
const [serverWithGame] = await app.db
|
sustainManagedConfigAfterPowerStart(
|
||||||
.select({
|
app,
|
||||||
gameSlug: games.slug,
|
nodeConnection,
|
||||||
automationRules: games.automationRules,
|
serverId,
|
||||||
})
|
server.uuid,
|
||||||
.from(servers)
|
server.gameSlug,
|
||||||
.innerJoin(games, eq(servers.gameId, games.id))
|
);
|
||||||
.where(eq(servers.id, serverId));
|
|
||||||
|
|
||||||
if (serverWithGame) {
|
void runServerAutomationEvent(app, {
|
||||||
void sustainCs2ServerConfigAfterPowerStart(
|
serverId,
|
||||||
app,
|
serverUuid: server.uuid,
|
||||||
{
|
gameSlug: server.gameSlug,
|
||||||
fqdn: server.nodeFqdn,
|
event: 'server.power.started',
|
||||||
grpcPort: server.nodeGrpcPort,
|
node: nodeConnection,
|
||||||
daemonToken: server.nodeDaemonToken,
|
automationRulesRaw: server.gameAutomationRules,
|
||||||
},
|
});
|
||||||
serverId,
|
|
||||||
server.uuid,
|
|
||||||
serverWithGame.gameSlug,
|
|
||||||
);
|
|
||||||
|
|
||||||
void runServerAutomationEvent(app, {
|
|
||||||
serverId,
|
|
||||||
serverUuid: server.uuid,
|
|
||||||
gameSlug: serverWithGame.gameSlug,
|
|
||||||
event: 'server.power.started',
|
|
||||||
node: {
|
|
||||||
fqdn: server.nodeFqdn,
|
|
||||||
grpcPort: server.nodeGrpcPort,
|
|
||||||
daemonToken: server.nodeDaemonToken,
|
|
||||||
},
|
|
||||||
automationRulesRaw: serverWithGame.automationRules,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ export default async function playerRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
async function getServerContext(
|
||||||
|
app: FastifyInstance,
|
||||||
|
orgId: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<{
|
||||||
serverUuid: string;
|
serverUuid: string;
|
||||||
node: DaemonNodeConnection;
|
node: DaemonNodeConnection;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ import {
|
|||||||
daemonWriteFile,
|
daemonWriteFile,
|
||||||
type DaemonNodeConnection,
|
type DaemonNodeConnection,
|
||||||
} from '../../lib/daemon.js';
|
} from '../../lib/daemon.js';
|
||||||
import {
|
import { searchSpigetPlugins, getSpigetResource, getSpigetDownloadUrl } from '../../lib/spiget.js';
|
||||||
searchSpigetPlugins,
|
|
||||||
getSpigetResource,
|
|
||||||
getSpigetDownloadUrl,
|
|
||||||
} from '../../lib/spiget.js';
|
|
||||||
import { resolveArtifactDownloadUrl } from '../../lib/cdn.js';
|
import { resolveArtifactDownloadUrl } from '../../lib/cdn.js';
|
||||||
import * as unzipper from 'unzipper';
|
import * as unzipper from 'unzipper';
|
||||||
|
|
||||||
@@ -255,10 +251,20 @@ function parseBooleanLike(input: unknown): boolean | null {
|
|||||||
}
|
}
|
||||||
if (typeof input === 'string') {
|
if (typeof input === 'string') {
|
||||||
const normalized = input.trim().toLowerCase();
|
const normalized = input.trim().toLowerCase();
|
||||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
if (
|
||||||
|
normalized === 'true' ||
|
||||||
|
normalized === '1' ||
|
||||||
|
normalized === 'yes' ||
|
||||||
|
normalized === 'on'
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
if (
|
||||||
|
normalized === 'false' ||
|
||||||
|
normalized === '0' ||
|
||||||
|
normalized === 'no' ||
|
||||||
|
normalized === 'off'
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,10 +344,12 @@ function validateInstallOptions(
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseBestRelease<T extends {
|
function chooseBestRelease<
|
||||||
channel: string;
|
T extends {
|
||||||
isPublished: boolean;
|
channel: string;
|
||||||
}>(releases: T[], autoChannel: ReleaseChannel): T | null {
|
isPublished: boolean;
|
||||||
|
},
|
||||||
|
>(releases: T[], autoChannel: ReleaseChannel): T | null {
|
||||||
for (const release of releases) {
|
for (const release of releases) {
|
||||||
if (!release.isPublished) continue;
|
if (!release.isPublished) continue;
|
||||||
const releaseChannel = resolveChannel(release.channel);
|
const releaseChannel = resolveChannel(release.channel);
|
||||||
@@ -394,11 +402,7 @@ async function getServerPluginContext(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPluginForGame(
|
async function getPluginForGame(app: FastifyInstance, pluginId: string, gameId: string) {
|
||||||
app: FastifyInstance,
|
|
||||||
pluginId: string,
|
|
||||||
gameId: string,
|
|
||||||
) {
|
|
||||||
const plugin = await app.db.query.plugins.findFirst({
|
const plugin = await app.db.query.plugins.findFirst({
|
||||||
where: and(eq(plugins.id, pluginId), eq(plugins.gameId, gameId)),
|
where: and(eq(plugins.id, pluginId), eq(plugins.gameId, gameId)),
|
||||||
});
|
});
|
||||||
@@ -422,10 +426,7 @@ async function getPluginReleaseForPlugin(
|
|||||||
return release;
|
return release;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listPublishedPluginReleases(
|
async function listPublishedPluginReleases(app: FastifyInstance, pluginId: string) {
|
||||||
app: FastifyInstance,
|
|
||||||
pluginId: string,
|
|
||||||
) {
|
|
||||||
return app.db
|
return app.db
|
||||||
.select()
|
.select()
|
||||||
.from(pluginReleases)
|
.from(pluginReleases)
|
||||||
@@ -484,17 +485,16 @@ async function downloadPluginArtifact(downloadUrl: string): Promise<Buffer> {
|
|||||||
return body;
|
return body;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) throw error;
|
if (error instanceof AppError) throw error;
|
||||||
throw new AppError(
|
throw new AppError(502, 'Unable to download plugin artifact', 'PLUGIN_DOWNLOAD_FAILED');
|
||||||
502,
|
|
||||||
'Unable to download plugin artifact',
|
|
||||||
'PLUGIN_DOWNLOAD_FAILED',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractZipArtifact(buffer: Buffer, destination: string): Promise<Array<{ path: string; data: Buffer }>> {
|
async function extractZipArtifact(
|
||||||
|
buffer: Buffer,
|
||||||
|
destination: string,
|
||||||
|
): Promise<Array<{ path: string; data: Buffer }>> {
|
||||||
const archive = await unzipper.Open.buffer(buffer);
|
const archive = await unzipper.Open.buffer(buffer);
|
||||||
const files: Array<{ path: string; data: Buffer }> = [];
|
const files: Array<{ path: string; data: Buffer }> = [];
|
||||||
|
|
||||||
@@ -545,15 +545,13 @@ async function insertServerPluginFileRows(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
|
|
||||||
await app.db
|
await app.db.insert(serverPluginFiles).values(
|
||||||
.insert(serverPluginFiles)
|
uniqPaths(paths).map((path) => ({
|
||||||
.values(
|
serverPluginId,
|
||||||
uniqPaths(paths).map((path) => ({
|
path,
|
||||||
serverPluginId,
|
kind,
|
||||||
path,
|
})),
|
||||||
kind,
|
);
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installReleaseArtifacts(
|
async function installReleaseArtifacts(
|
||||||
@@ -643,9 +641,7 @@ async function removeInstalledPluginFiles(
|
|||||||
.from(serverPluginFiles)
|
.from(serverPluginFiles)
|
||||||
.where(eq(serverPluginFiles.serverPluginId, installId));
|
.where(eq(serverPluginFiles.serverPluginId, installId));
|
||||||
|
|
||||||
const candidates = tracked.length > 0
|
const candidates = tracked.length > 0 ? tracked.map((row) => row.path) : fallbackPaths;
|
||||||
? tracked.map((row) => row.path)
|
|
||||||
: fallbackPaths;
|
|
||||||
|
|
||||||
const pathsToDelete = uniqPaths(candidates).filter((path) => !preserveSet.has(path));
|
const pathsToDelete = uniqPaths(candidates).filter((path) => !preserveSet.has(path));
|
||||||
|
|
||||||
@@ -673,17 +669,12 @@ async function syncInstalledPluginConfigFiles(
|
|||||||
.select({ path: serverPluginFiles.path })
|
.select({ path: serverPluginFiles.path })
|
||||||
.from(serverPluginFiles)
|
.from(serverPluginFiles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(serverPluginFiles.serverPluginId, installId), eq(serverPluginFiles.kind, 'config')),
|
||||||
eq(serverPluginFiles.serverPluginId, installId),
|
|
||||||
eq(serverPluginFiles.kind, 'config'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextPaths = uniqPaths(configPaths);
|
const nextPaths = uniqPaths(configPaths);
|
||||||
const nextPathSet = new Set(nextPaths);
|
const nextPathSet = new Set(nextPaths);
|
||||||
const stalePaths = tracked
|
const stalePaths = tracked.map((row) => row.path).filter((path) => !nextPathSet.has(path));
|
||||||
.map((row) => row.path)
|
|
||||||
.filter((path) => !nextPathSet.has(path));
|
|
||||||
|
|
||||||
if (stalePaths.length > 0) {
|
if (stalePaths.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -704,10 +695,7 @@ async function syncInstalledPluginConfigFiles(
|
|||||||
await app.db
|
await app.db
|
||||||
.delete(serverPluginFiles)
|
.delete(serverPluginFiles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(serverPluginFiles.serverPluginId, installId), eq(serverPluginFiles.kind, 'config')),
|
||||||
eq(serverPluginFiles.serverPluginId, installId),
|
|
||||||
eq(serverPluginFiles.kind, 'config'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await insertServerPluginFileRows(app, installId, nextPaths, 'config');
|
await insertServerPluginFileRows(app, installId, nextPaths, 'config');
|
||||||
@@ -830,10 +818,7 @@ async function installPluginForServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = await app.db.query.serverPlugins.findFirst({
|
const existing = await app.db.query.serverPlugins.findFirst({
|
||||||
where: and(
|
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
|
||||||
eq(serverPlugins.serverId, context.serverId),
|
|
||||||
eq(serverPlugins.pluginId, plugin.id),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw AppError.conflict('Plugin is already installed');
|
throw AppError.conflict('Plugin is already installed');
|
||||||
@@ -874,7 +859,9 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
app.get('/', { schema: ParamSchema }, async (request) => {
|
app.get('/', { schema: ParamSchema }, async (request) => {
|
||||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
await requirePermission(request, orgId, 'plugin.read');
|
await requirePermission(request, orgId, 'plugin.read');
|
||||||
const context = await getServerPluginContext(app, orgId, serverId);
|
// Called for its validation of the server and the caller's access to it;
|
||||||
|
// this endpoint does not need the returned context.
|
||||||
|
await getServerPluginContext(app, orgId, serverId);
|
||||||
|
|
||||||
const installed = await app.db
|
const installed = await app.db
|
||||||
.select({
|
.select({
|
||||||
@@ -900,21 +887,22 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
.where(eq(serverPlugins.serverId, serverId));
|
.where(eq(serverPlugins.serverId, serverId));
|
||||||
|
|
||||||
const pluginIds = uniqPaths(installed.map((row) => row.pluginId));
|
const pluginIds = uniqPaths(installed.map((row) => row.pluginId));
|
||||||
const releases = pluginIds.length > 0
|
const releases =
|
||||||
? await app.db
|
pluginIds.length > 0
|
||||||
.select({
|
? await app.db
|
||||||
id: pluginReleases.id,
|
.select({
|
||||||
pluginId: pluginReleases.pluginId,
|
id: pluginReleases.id,
|
||||||
version: pluginReleases.version,
|
pluginId: pluginReleases.pluginId,
|
||||||
channel: pluginReleases.channel,
|
version: pluginReleases.version,
|
||||||
installSchema: pluginReleases.installSchema,
|
channel: pluginReleases.channel,
|
||||||
isPublished: pluginReleases.isPublished,
|
installSchema: pluginReleases.installSchema,
|
||||||
createdAt: pluginReleases.createdAt,
|
isPublished: pluginReleases.isPublished,
|
||||||
})
|
createdAt: pluginReleases.createdAt,
|
||||||
.from(pluginReleases)
|
})
|
||||||
.where(inArray(pluginReleases.pluginId, pluginIds))
|
.from(pluginReleases)
|
||||||
.orderBy(desc(pluginReleases.createdAt))
|
.where(inArray(pluginReleases.pluginId, pluginIds))
|
||||||
: [];
|
.orderBy(desc(pluginReleases.createdAt))
|
||||||
|
: [];
|
||||||
|
|
||||||
const releasesByPlugin = new Map<string, typeof releases>();
|
const releasesByPlugin = new Map<string, typeof releases>();
|
||||||
for (const release of releases) {
|
for (const release of releases) {
|
||||||
@@ -927,15 +915,12 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
plugins: installed.map((row) => {
|
plugins: installed.map((row) => {
|
||||||
const releaseList = releasesByPlugin.get(row.pluginId) ?? [];
|
const releaseList = releasesByPlugin.get(row.pluginId) ?? [];
|
||||||
const currentRelease = row.releaseId
|
const currentRelease = row.releaseId
|
||||||
? releaseList.find((release) => release.id === row.releaseId) ?? null
|
? (releaseList.find((release) => release.id === row.releaseId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const currentChannel = resolveChannel(row.autoUpdateChannel);
|
const currentChannel = resolveChannel(row.autoUpdateChannel);
|
||||||
const latestAllowed = chooseBestRelease(releaseList, currentChannel);
|
const latestAllowed = chooseBestRelease(releaseList, currentChannel);
|
||||||
const updateAvailable = Boolean(
|
const updateAvailable = Boolean(
|
||||||
!row.isPinned &&
|
!row.isPinned && latestAllowed && row.releaseId && latestAllowed.id !== row.releaseId,
|
||||||
latestAllowed &&
|
|
||||||
row.releaseId &&
|
|
||||||
latestAllowed.id !== row.releaseId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1005,25 +990,31 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
.where(eq(serverPlugins.serverId, context.serverId));
|
.where(eq(serverPlugins.serverId, context.serverId));
|
||||||
|
|
||||||
const pluginIds = catalog.map((plugin) => plugin.id);
|
const pluginIds = catalog.map((plugin) => plugin.id);
|
||||||
const releaseRows = pluginIds.length > 0
|
const releaseRows =
|
||||||
? await app.db
|
pluginIds.length > 0
|
||||||
.select({
|
? await app.db
|
||||||
id: pluginReleases.id,
|
.select({
|
||||||
pluginId: pluginReleases.pluginId,
|
id: pluginReleases.id,
|
||||||
version: pluginReleases.version,
|
pluginId: pluginReleases.pluginId,
|
||||||
channel: pluginReleases.channel,
|
version: pluginReleases.version,
|
||||||
artifactType: pluginReleases.artifactType,
|
channel: pluginReleases.channel,
|
||||||
artifactUrl: pluginReleases.artifactUrl,
|
artifactType: pluginReleases.artifactType,
|
||||||
destination: pluginReleases.destination,
|
artifactUrl: pluginReleases.artifactUrl,
|
||||||
fileName: pluginReleases.fileName,
|
destination: pluginReleases.destination,
|
||||||
installSchema: pluginReleases.installSchema,
|
fileName: pluginReleases.fileName,
|
||||||
isPublished: pluginReleases.isPublished,
|
installSchema: pluginReleases.installSchema,
|
||||||
createdAt: pluginReleases.createdAt,
|
isPublished: pluginReleases.isPublished,
|
||||||
})
|
createdAt: pluginReleases.createdAt,
|
||||||
.from(pluginReleases)
|
})
|
||||||
.where(and(inArray(pluginReleases.pluginId, pluginIds), eq(pluginReleases.isPublished, true)))
|
.from(pluginReleases)
|
||||||
.orderBy(desc(pluginReleases.createdAt))
|
.where(
|
||||||
: [];
|
and(
|
||||||
|
inArray(pluginReleases.pluginId, pluginIds),
|
||||||
|
eq(pluginReleases.isPublished, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(pluginReleases.createdAt))
|
||||||
|
: [];
|
||||||
|
|
||||||
const releaseByPlugin = new Map<string, typeof releaseRows>();
|
const releaseByPlugin = new Map<string, typeof releaseRows>();
|
||||||
for (const row of releaseRows) {
|
for (const row of releaseRows) {
|
||||||
@@ -1032,9 +1023,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
releaseByPlugin.set(row.pluginId, list);
|
releaseByPlugin.set(row.pluginId, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
const installedByPluginId = new Map(
|
const installedByPluginId = new Map(installedRows.map((row) => [row.pluginId, row]));
|
||||||
installedRows.map((row) => [row.pluginId, row]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const needle = q?.trim().toLowerCase();
|
const needle = q?.trim().toLowerCase();
|
||||||
const filtered = needle
|
const filtered = needle
|
||||||
@@ -1131,10 +1120,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = await app.db.query.plugins.findFirst({
|
const existing = await app.db.query.plugins.findFirst({
|
||||||
where: and(
|
where: and(eq(plugins.gameId, context.gameId), eq(plugins.slug, normalizedSlug)),
|
||||||
eq(plugins.gameId, context.gameId),
|
|
||||||
eq(plugins.slug, normalizedSlug),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw AppError.conflict('A plugin with this slug already exists for the game');
|
throw AppError.conflict('A plugin with this slug already exists for the game');
|
||||||
@@ -1201,18 +1187,18 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
const context = await getServerPluginContext(app, orgId, serverId);
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
const existing = await getPluginForGame(app, pluginId, context.gameId);
|
const existing = await getPluginForGame(app, pluginId, context.gameId);
|
||||||
|
|
||||||
const nextSlug = body.slug !== undefined
|
const nextSlug =
|
||||||
? toSlug(body.slug)
|
body.slug !== undefined
|
||||||
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
|
? toSlug(body.slug)
|
||||||
|
: body.name !== undefined
|
||||||
|
? toSlug(body.name)
|
||||||
|
: existing.slug;
|
||||||
if (!nextSlug) {
|
if (!nextSlug) {
|
||||||
throw AppError.badRequest('Plugin slug is invalid');
|
throw AppError.badRequest('Plugin slug is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicate = await app.db.query.plugins.findFirst({
|
const duplicate = await app.db.query.plugins.findFirst({
|
||||||
where: and(
|
where: and(eq(plugins.gameId, context.gameId), eq(plugins.slug, nextSlug)),
|
||||||
eq(plugins.gameId, context.gameId),
|
|
||||||
eq(plugins.slug, nextSlug),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
if (duplicate && duplicate.id !== existing.id) {
|
if (duplicate && duplicate.id !== existing.id) {
|
||||||
throw AppError.conflict('A plugin with this slug already exists for the game');
|
throw AppError.conflict('A plugin with this slug already exists for the game');
|
||||||
@@ -1336,16 +1322,16 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
serverId: Type.String({ format: 'uuid' }),
|
serverId: Type.String({ format: 'uuid' }),
|
||||||
pluginId: Type.String({ format: 'uuid' }),
|
pluginId: Type.String({ format: 'uuid' }),
|
||||||
}),
|
}),
|
||||||
body: Type.Optional(Type.Object({
|
body: Type.Optional(
|
||||||
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
Type.Object({
|
||||||
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||||
pinVersion: Type.Optional(Type.Boolean()),
|
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
||||||
autoUpdateChannel: Type.Optional(Type.Union([
|
pinVersion: Type.Optional(Type.Boolean()),
|
||||||
Type.Literal('stable'),
|
autoUpdateChannel: Type.Optional(
|
||||||
Type.Literal('beta'),
|
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
|
||||||
Type.Literal('alpha'),
|
),
|
||||||
])),
|
}),
|
||||||
})),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
@@ -1366,7 +1352,10 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
const plugin = await getPluginForGame(app, pluginId, context.gameId);
|
const plugin = await getPluginForGame(app, pluginId, context.gameId);
|
||||||
|
|
||||||
const existing = await app.db.query.serverPlugins.findFirst({
|
const existing = await app.db.query.serverPlugins.findFirst({
|
||||||
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
|
where: and(
|
||||||
|
eq(serverPlugins.serverId, context.serverId),
|
||||||
|
eq(serverPlugins.pluginId, plugin.id),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw AppError.conflict('Plugin is already installed');
|
throw AppError.conflict('Plugin is already installed');
|
||||||
@@ -1469,7 +1458,10 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
const { resourceId } = request.body as { resourceId: number; options?: Record<string, unknown> };
|
const { resourceId } = request.body as {
|
||||||
|
resourceId: number;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
};
|
||||||
await requirePermission(request, orgId, 'plugin.manage');
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
|
||||||
const context = await getServerPluginContext(app, orgId, serverId);
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
@@ -1506,7 +1498,9 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
plugin = created!;
|
plugin = created!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const releaseVersion = resource.version ? String(resource.version.id) : `spiget-${Date.now()}`;
|
const releaseVersion = resource.version
|
||||||
|
? String(resource.version.id)
|
||||||
|
: `spiget-${Date.now()}`;
|
||||||
let release = await app.db.query.pluginReleases.findFirst({
|
let release = await app.db.query.pluginReleases.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(pluginReleases.pluginId, plugin.id),
|
eq(pluginReleases.pluginId, plugin.id),
|
||||||
@@ -1531,19 +1525,16 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = await app.db.query.serverPlugins.findFirst({
|
const existing = await app.db.query.serverPlugins.findFirst({
|
||||||
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
|
where: and(
|
||||||
|
eq(serverPlugins.serverId, context.serverId),
|
||||||
|
eq(serverPlugins.pluginId, plugin.id),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw AppError.conflict('Plugin is already installed');
|
throw AppError.conflict('Plugin is already installed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const installResult = await installPluginReleaseForServer(
|
const installResult = await installPluginReleaseForServer(app, context, plugin, release, {});
|
||||||
app,
|
|
||||||
context,
|
|
||||||
plugin,
|
|
||||||
release,
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [installed] = await app.db
|
const [installed] = await app.db
|
||||||
.insert(serverPlugins)
|
.insert(serverPlugins)
|
||||||
@@ -1612,9 +1603,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
? normalizeAbsolutePath(filePath)
|
? normalizeAbsolutePath(filePath)
|
||||||
: joinAbsolutePath(pluginInstallDirectory(context.gameSlug), filePath);
|
: joinAbsolutePath(pluginInstallDirectory(context.gameSlug), filePath);
|
||||||
|
|
||||||
let plugin = pluginId
|
let plugin = pluginId ? await getPluginForGame(app, pluginId, context.gameId) : null;
|
||||||
? await getPluginForGame(app, pluginId, context.gameId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
const slug = toSlug(name);
|
const slug = toSlug(name);
|
||||||
@@ -1640,7 +1629,10 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingInstall = await app.db.query.serverPlugins.findFirst({
|
const existingInstall = await app.db.query.serverPlugins.findFirst({
|
||||||
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
|
where: and(
|
||||||
|
eq(serverPlugins.serverId, context.serverId),
|
||||||
|
eq(serverPlugins.pluginId, plugin.id),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
if (existingInstall) {
|
if (existingInstall) {
|
||||||
throw AppError.conflict('Plugin is already installed');
|
throw AppError.conflict('Plugin is already installed');
|
||||||
@@ -1667,7 +1659,12 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
serverId,
|
serverId,
|
||||||
action: 'plugin.install',
|
action: 'plugin.install',
|
||||||
metadata: { pluginId: plugin.id, name: plugin.name, source: 'manual', filePath: normalizedPath },
|
metadata: {
|
||||||
|
pluginId: plugin.id,
|
||||||
|
name: plugin.name,
|
||||||
|
source: 'manual',
|
||||||
|
filePath: normalizedPath,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return installed;
|
return installed;
|
||||||
@@ -1708,24 +1705,27 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
.from(serverPlugins)
|
.from(serverPlugins)
|
||||||
.innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id))
|
.innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id))
|
||||||
.leftJoin(pluginReleases, eq(serverPlugins.releaseId, pluginReleases.id))
|
.leftJoin(pluginReleases, eq(serverPlugins.releaseId, pluginReleases.id))
|
||||||
.where(and(
|
.where(
|
||||||
eq(serverPlugins.id, pluginInstallId),
|
and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)),
|
||||||
eq(serverPlugins.serverId, context.serverId),
|
);
|
||||||
));
|
|
||||||
|
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
throw AppError.notFound('Plugin installation not found');
|
throw AppError.notFound('Plugin installation not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackPath = installed.releaseArtifactUrl
|
const fallbackPath = installed.releaseArtifactUrl
|
||||||
? resolveReleaseFilePath(context.gameSlug, {
|
? resolveReleaseFilePath(
|
||||||
id: installed.pluginId,
|
context.gameSlug,
|
||||||
slug: installed.pluginSlug,
|
{
|
||||||
}, {
|
id: installed.pluginId,
|
||||||
artifactUrl: installed.releaseArtifactUrl,
|
slug: installed.pluginSlug,
|
||||||
destination: installed.releaseDestination,
|
},
|
||||||
fileName: installed.releaseFileName,
|
{
|
||||||
})
|
artifactUrl: installed.releaseArtifactUrl,
|
||||||
|
destination: installed.releaseDestination,
|
||||||
|
fileName: installed.releaseFileName,
|
||||||
|
},
|
||||||
|
)
|
||||||
: pluginFilePath(context.gameSlug, {
|
: pluginFilePath(context.gameSlug, {
|
||||||
id: installed.pluginId,
|
id: installed.pluginId,
|
||||||
slug: installed.pluginSlug,
|
slug: installed.pluginSlug,
|
||||||
@@ -1807,16 +1807,16 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
serverId: Type.String({ format: 'uuid' }),
|
serverId: Type.String({ format: 'uuid' }),
|
||||||
pluginInstallId: Type.String({ format: 'uuid' }),
|
pluginInstallId: Type.String({ format: 'uuid' }),
|
||||||
}),
|
}),
|
||||||
body: Type.Optional(Type.Object({
|
body: Type.Optional(
|
||||||
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
Type.Object({
|
||||||
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||||
pinVersion: Type.Optional(Type.Boolean()),
|
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
||||||
autoUpdateChannel: Type.Optional(Type.Union([
|
pinVersion: Type.Optional(Type.Boolean()),
|
||||||
Type.Literal('stable'),
|
autoUpdateChannel: Type.Optional(
|
||||||
Type.Literal('beta'),
|
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
|
||||||
Type.Literal('alpha'),
|
),
|
||||||
])),
|
}),
|
||||||
})),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
@@ -1845,7 +1845,9 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
autoUpdateChannel: serverPlugins.autoUpdateChannel,
|
autoUpdateChannel: serverPlugins.autoUpdateChannel,
|
||||||
})
|
})
|
||||||
.from(serverPlugins)
|
.from(serverPlugins)
|
||||||
.where(and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)));
|
.where(
|
||||||
|
and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)),
|
||||||
|
);
|
||||||
|
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
throw AppError.notFound('Plugin installation not found');
|
throw AppError.notFound('Plugin installation not found');
|
||||||
@@ -1878,8 +1880,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
const nextPinned = body.pinVersion ?? installed.isPinned;
|
const nextPinned = body.pinVersion ?? installed.isPinned;
|
||||||
const nextAutoUpdateChannel = body.autoUpdateChannel ?? installed.autoUpdateChannel;
|
const nextAutoUpdateChannel = body.autoUpdateChannel ?? installed.autoUpdateChannel;
|
||||||
const hasMetadataChanges =
|
const hasMetadataChanges =
|
||||||
nextPinned !== installed.isPinned ||
|
nextPinned !== installed.isPinned || nextAutoUpdateChannel !== installed.autoUpdateChannel;
|
||||||
nextAutoUpdateChannel !== installed.autoUpdateChannel;
|
|
||||||
|
|
||||||
if (!releaseChanged && !hasOptionChanges && !hasMetadataChanges) {
|
if (!releaseChanged && !hasOptionChanges && !hasMetadataChanges) {
|
||||||
throw AppError.conflict('Plugin is already on the selected release');
|
throw AppError.conflict('Plugin is already on the selected release');
|
||||||
@@ -1934,11 +1935,24 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
|||||||
mergedOptions,
|
mergedOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
const newPaths = uniqPaths([...installResult.artifactPaths, ...installResult.configPaths]);
|
const newPaths = uniqPaths([
|
||||||
|
...installResult.artifactPaths,
|
||||||
|
...installResult.configPaths,
|
||||||
|
]);
|
||||||
await removeInstalledPluginFiles(app, context, installed.installId, [], newPaths);
|
await removeInstalledPluginFiles(app, context, installed.installId, [], newPaths);
|
||||||
|
|
||||||
await insertServerPluginFileRows(app, installed.installId, installResult.artifactPaths, 'artifact');
|
await insertServerPluginFileRows(
|
||||||
await insertServerPluginFileRows(app, installed.installId, installResult.configPaths, 'config');
|
app,
|
||||||
|
installed.installId,
|
||||||
|
installResult.artifactPaths,
|
||||||
|
'artifact',
|
||||||
|
);
|
||||||
|
await insertServerPluginFileRows(
|
||||||
|
app,
|
||||||
|
installed.installId,
|
||||||
|
installResult.configPaths,
|
||||||
|
'config',
|
||||||
|
);
|
||||||
nextInstallOptions = installResult.installOptions;
|
nextInstallOptions = installResult.installOptions;
|
||||||
} else {
|
} else {
|
||||||
const configureResult = await configurePluginReleaseForServer(
|
const configureResult = await configurePluginReleaseForServer(
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ const TaskParamSchema = {
|
|||||||
|
|
||||||
const CreateScheduleBody = Type.Object({
|
const CreateScheduleBody = Type.Object({
|
||||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
action: Type.Union([
|
action: Type.Union([Type.Literal('command'), Type.Literal('power'), Type.Literal('backup')]),
|
||||||
Type.Literal('command'),
|
|
||||||
Type.Literal('power'),
|
|
||||||
Type.Literal('backup'),
|
|
||||||
]),
|
|
||||||
payload: Type.String({ minLength: 1 }),
|
payload: Type.String({ minLength: 1 }),
|
||||||
scheduleType: Type.Union([
|
scheduleType: Type.Union([
|
||||||
Type.Literal('interval'),
|
Type.Literal('interval'),
|
||||||
@@ -131,34 +127,40 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /schedules/:taskId — update a scheduled task
|
// PATCH /schedules/:taskId — update a scheduled task
|
||||||
app.patch('/:taskId', { schema: { ...TaskParamSchema, body: UpdateScheduleBody } }, async (request) => {
|
app.patch(
|
||||||
const { orgId, serverId, taskId } = request.params as {
|
'/:taskId',
|
||||||
orgId: string;
|
{ schema: { ...TaskParamSchema, body: UpdateScheduleBody } },
|
||||||
serverId: string;
|
async (request) => {
|
||||||
taskId: string;
|
const { orgId, serverId, taskId } = request.params as {
|
||||||
};
|
orgId: string;
|
||||||
await requirePermission(request, orgId, 'schedule.manage');
|
serverId: string;
|
||||||
|
taskId: string;
|
||||||
|
};
|
||||||
|
await requirePermission(request, orgId, 'schedule.manage');
|
||||||
|
|
||||||
const body = request.body as Record<string, unknown>;
|
const body = request.body as Record<string, unknown>;
|
||||||
|
|
||||||
const existing = await app.db.query.scheduledTasks.findFirst({
|
const existing = await app.db.query.scheduledTasks.findFirst({
|
||||||
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
|
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
|
||||||
});
|
});
|
||||||
if (!existing) throw AppError.notFound('Scheduled task not found');
|
if (!existing) throw AppError.notFound('Scheduled task not found');
|
||||||
|
|
||||||
// Recompute next run if schedule changed
|
// Recompute next run if schedule changed
|
||||||
const scheduleType = (body.scheduleType as string) || existing.scheduleType;
|
const scheduleType = (body.scheduleType as string) || existing.scheduleType;
|
||||||
const scheduleData = (body.scheduleData as Record<string, unknown>) || (existing.scheduleData as Record<string, unknown>);
|
const scheduleData =
|
||||||
const nextRun = computeNextRun(scheduleType, scheduleData);
|
(body.scheduleData as Record<string, unknown>) ||
|
||||||
|
(existing.scheduleData as Record<string, unknown>);
|
||||||
|
const nextRun = computeNextRun(scheduleType, scheduleData);
|
||||||
|
|
||||||
const [updated] = await app.db
|
const [updated] = await app.db
|
||||||
.update(scheduledTasks)
|
.update(scheduledTasks)
|
||||||
.set({ ...body, nextRunAt: nextRun, updatedAt: new Date() })
|
.set({ ...body, nextRunAt: nextRun, updatedAt: new Date() })
|
||||||
.where(eq(scheduledTasks.id, taskId))
|
.where(eq(scheduledTasks.id, taskId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// DELETE /schedules/:taskId — delete a scheduled task
|
// DELETE /schedules/:taskId — delete a scheduled task
|
||||||
app.delete('/:taskId', { schema: TaskParamSchema }, async (request, reply) => {
|
app.delete('/:taskId', { schema: TaskParamSchema }, async (request, reply) => {
|
||||||
@@ -223,7 +225,11 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
async function getServerContext(
|
||||||
|
app: FastifyInstance,
|
||||||
|
orgId: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<{
|
||||||
serverUuid: string;
|
serverUuid: string;
|
||||||
node: DaemonNodeConnection;
|
node: DaemonNodeConnection;
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ FROM rust:1.83-bookworm AS build
|
|||||||
# Install protoc
|
# Install protoc
|
||||||
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
# build.rs compiles ../../packages/proto/daemon.proto, so the workspace layout
|
||||||
COPY apps/daemon/ .
|
# 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
|
RUN cargo build --release
|
||||||
|
|
||||||
# --- Production ---
|
# --- Production ---
|
||||||
@@ -18,12 +22,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/target/release/gamepanel-daemon /app/gamepanel-daemon
|
COPY --from=build /build/apps/daemon/target/release/gamepanel-daemon /app/gamepanel-daemon
|
||||||
|
|
||||||
# Data directories
|
# Data directories
|
||||||
RUN mkdir -p /var/lib/gamepanel/servers /var/lib/gamepanel/backups /etc/gamepanel
|
RUN mkdir -p /var/lib/gamepanel/servers /var/lib/gamepanel/backups /etc/gamepanel
|
||||||
|
|
||||||
EXPOSE 50051
|
EXPOSE 50051
|
||||||
HEALTHCHECK --interval=30s --timeout=5s CMD /app/gamepanel-daemon --health-check || exit 1
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s CMD /app/gamepanel-daemon --health-check || exit 1
|
||||||
|
|
||||||
CMD ["/app/gamepanel-daemon"]
|
CMD ["/app/gamepanel-daemon"]
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ pub struct DaemonConfig {
|
|||||||
pub docker: DockerConfig,
|
pub docker: DockerConfig,
|
||||||
#[serde(default = "default_data_path")]
|
#[serde(default = "default_data_path")]
|
||||||
pub data_path: PathBuf,
|
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")]
|
#[serde(default = "default_backup_path")]
|
||||||
pub backup_path: PathBuf,
|
pub backup_path: PathBuf,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -92,7 +98,24 @@ grpc_port: 50051
|
|||||||
.to_string()
|
.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)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path prefix the Docker host uses for server data directories.
|
||||||
|
pub fn host_data_path(&self) -> PathBuf {
|
||||||
|
self.host_data_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| self.data_path.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bollard::container::{
|
use bollard::container::{
|
||||||
@@ -14,13 +14,24 @@ use tokio::time::{sleep, Duration};
|
|||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::docker::DockerManager;
|
use crate::docker::DockerManager;
|
||||||
use crate::server::ServerSpec;
|
use crate::server::{ServerRuntime, ServerSpec};
|
||||||
use crate::server::state::ServerState;
|
use crate::server::state::ServerState;
|
||||||
|
|
||||||
/// Container name prefix for all managed game servers.
|
/// Container name prefix for all managed game servers.
|
||||||
const CONTAINER_PREFIX: &str = "gp_";
|
const CONTAINER_PREFIX: &str = "gp_";
|
||||||
const SATISFACTORY_RUN_SH: &str = include_str!("../game/satisfactory_run.sh");
|
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 {
|
pub fn container_name(server_uuid: &str) -> String {
|
||||||
format!("{}{}", CONTAINER_PREFIX, server_uuid)
|
format!("{}{}", CONTAINER_PREFIX, server_uuid)
|
||||||
}
|
}
|
||||||
@@ -47,9 +58,63 @@ fn container_data_path_for_image(image: &str) -> &'static str {
|
|||||||
if normalized.contains("wolveix/satisfactory-server") {
|
if normalized.contains("wolveix/satisfactory-server") {
|
||||||
return "/config";
|
return "/config";
|
||||||
}
|
}
|
||||||
|
if normalized.contains("ark-server") || normalized.contains("ark-survival-evolved") {
|
||||||
|
return "/app";
|
||||||
|
}
|
||||||
"/data"
|
"/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 {
|
fn is_wolveix_satisfactory_image(image: &str) -> bool {
|
||||||
image
|
image
|
||||||
.to_ascii_lowercase()
|
.to_ascii_lowercase()
|
||||||
@@ -69,6 +134,7 @@ impl DockerManager {
|
|||||||
async fn attach_command_stream(
|
async fn attach_command_stream(
|
||||||
&self,
|
&self,
|
||||||
container_name: &str,
|
container_name: &str,
|
||||||
|
container_id: String,
|
||||||
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
||||||
let bollard::container::AttachContainerResults { mut output, input } = self
|
let bollard::container::AttachContainerResults { mut output, input } = self
|
||||||
.client()
|
.client()
|
||||||
@@ -93,28 +159,80 @@ impl DockerManager {
|
|||||||
debug!(container = %name, "Container stdin attach stream ended");
|
debug!(container = %name, "Container stdin attach stream ended");
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Arc::new(crate::docker::manager::CommandStreamHandle::new(input, drain_task)))
|
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(
|
async fn get_or_attach_command_stream(
|
||||||
&self,
|
&self,
|
||||||
server_uuid: &str,
|
server_uuid: &str,
|
||||||
|
container_id: &str,
|
||||||
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
||||||
let name = container_name(server_uuid);
|
let name = container_name(server_uuid);
|
||||||
|
|
||||||
if let Some(existing) = self.command_streams().read().await.get(&name).cloned() {
|
if let Some(existing) = self.command_streams().read().await.get(&name).cloned() {
|
||||||
return Ok(existing);
|
if existing.container_id() == container_id {
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let created = self.attach_command_stream(&name).await?;
|
// 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;
|
let mut streams = self.command_streams().write().await;
|
||||||
if let Some(existing) = streams.get(&name).cloned() {
|
if let Some(existing) = streams.get(&name).cloned() {
|
||||||
created.abort();
|
if existing.container_id() == container_id {
|
||||||
return Ok(existing);
|
created.abort();
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
streams.insert(name, created.clone());
|
// 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)
|
Ok(created)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +320,123 @@ impl DockerManager {
|
|||||||
.await
|
.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.
|
/// Pull a Docker image if not already present.
|
||||||
pub async fn pull_image(&self, image: &str) -> Result<()> {
|
pub async fn pull_image(&self, image: &str) -> Result<()> {
|
||||||
info!(image = %image, "Pulling Docker image");
|
info!(image = %image, "Pulling Docker image");
|
||||||
@@ -230,7 +465,9 @@ impl DockerManager {
|
|||||||
/// Create and configure a container for a game server.
|
/// Create and configure a container for a game server.
|
||||||
pub async fn create_container(&self, spec: &ServerSpec) -> Result<String> {
|
pub async fn create_container(&self, spec: &ServerSpec) -> Result<String> {
|
||||||
let name = container_name(&spec.uuid);
|
let name = container_name(&spec.uuid);
|
||||||
let data_mount_path = container_data_path_for_image(&spec.docker_image);
|
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
|
// Build port bindings
|
||||||
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
||||||
@@ -267,8 +504,7 @@ impl DockerManager {
|
|||||||
network_mode: Some(self.network_name().to_string()),
|
network_mode: Some(self.network_name().to_string()),
|
||||||
binds: Some(vec![format!(
|
binds: Some(vec![format!(
|
||||||
"{}:{}",
|
"{}:{}",
|
||||||
spec.data_path.display()
|
bind_source.display(),
|
||||||
,
|
|
||||||
data_mount_path
|
data_mount_path
|
||||||
)]),
|
)]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -280,6 +516,7 @@ impl DockerManager {
|
|||||||
env: Some(env),
|
env: Some(env),
|
||||||
exposed_ports: Some(exposed_ports),
|
exposed_ports: Some(exposed_ports),
|
||||||
host_config: Some(host_config),
|
host_config: Some(host_config),
|
||||||
|
labels: Some(runtime_labels(spec)),
|
||||||
// Preserve image default working directory when no custom startup command is set.
|
// Preserve image default working directory when no custom startup command is set.
|
||||||
// Some game images rely on their built-in WORKDIR and entrypoint scripts.
|
// Some game images rely on their built-in WORKDIR and entrypoint scripts.
|
||||||
working_dir: if spec.startup_command.is_empty() {
|
working_dir: if spec.startup_command.is_empty() {
|
||||||
@@ -342,6 +579,100 @@ impl DockerManager {
|
|||||||
Ok(())
|
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.
|
/// Kill a container immediately.
|
||||||
pub async fn kill_container(&self, server_uuid: &str) -> Result<()> {
|
pub async fn kill_container(&self, server_uuid: &str) -> Result<()> {
|
||||||
let name = container_name(server_uuid);
|
let name = container_name(server_uuid);
|
||||||
@@ -476,6 +807,12 @@ impl DockerManager {
|
|||||||
.and_then(|cfg| cfg.image.clone())
|
.and_then(|cfg| cfg.image.clone())
|
||||||
.unwrap_or_default();
|
.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
|
let data_mount_path = info
|
||||||
.mounts
|
.mounts
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -484,7 +821,10 @@ impl DockerManager {
|
|||||||
if mount.typ != Some(MountPointTypeEnum::BIND) {
|
if mount.typ != Some(MountPointTypeEnum::BIND) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
mount.source.as_ref().map(PathBuf::from)
|
mount
|
||||||
|
.source
|
||||||
|
.as_ref()
|
||||||
|
.map(|source| self.daemon_data_path(Path::new(source)))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| data_root.join(&uuid));
|
.unwrap_or_else(|| data_root.join(&uuid));
|
||||||
@@ -594,6 +934,7 @@ impl DockerManager {
|
|||||||
data_path: data_mount_path,
|
data_path: data_mount_path,
|
||||||
state,
|
state,
|
||||||
container_id: info.id,
|
container_id: info.id,
|
||||||
|
runtime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,22 +961,57 @@ impl DockerManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a command to a container via a persistent Docker attach stdin stream.
|
/// 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<()> {
|
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
|
||||||
let trimmed = command.trim_end_matches(|ch| ch == '\r' || ch == '\n');
|
let trimmed = command.trim_end_matches(|ch| ch == '\r' || ch == '\n');
|
||||||
let payload = format!("{trimmed}\n");
|
if trimmed.trim().is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Command cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
for _ in 0..2 {
|
let container_id = self.running_container_id(server_uuid).await?;
|
||||||
let stream = self.get_or_attach_command_stream(server_uuid).await?;
|
let image = self
|
||||||
match stream.write_all(payload.as_bytes()).await {
|
.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(()),
|
Ok(_) => return Ok(()),
|
||||||
Err(error) => {
|
Err(rcon_error) => {
|
||||||
debug!(server_uuid = %server_uuid, error = %error, "Failed to write to container stdin, resetting attach stream");
|
debug!(
|
||||||
self.clear_command_stream(server_uuid).await;
|
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})"
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow::anyhow!("failed to write command to container stdin"))
|
match self
|
||||||
|
.send_command_via_stdin(server_uuid, &container_id, trimmed)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(stdin_error) => self
|
||||||
|
.send_command_via_rcon(server_uuid, trimmed)
|
||||||
|
.await
|
||||||
|
.map_err(|rcon_error| {
|
||||||
|
anyhow::anyhow!("stdin failed ({stdin_error}) and RCON failed ({rcon_error})")
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -10,23 +11,37 @@ use tokio::sync::{Mutex, RwLock};
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::config::DockerConfig;
|
use crate::config::DaemonConfig;
|
||||||
|
|
||||||
type AttachedInput = Pin<Box<dyn AsyncWrite + Send>>;
|
type AttachedInput = Pin<Box<dyn AsyncWrite + Send>>;
|
||||||
|
|
||||||
pub(crate) struct CommandStreamHandle {
|
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>,
|
input: Mutex<AttachedInput>,
|
||||||
drain_task: JoinHandle<()>,
|
drain_task: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandStreamHandle {
|
impl CommandStreamHandle {
|
||||||
pub(crate) fn new(input: AttachedInput, drain_task: JoinHandle<()>) -> Self {
|
pub(crate) fn new(
|
||||||
|
container_id: String,
|
||||||
|
input: AttachedInput,
|
||||||
|
drain_task: JoinHandle<()>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
container_id,
|
||||||
input: Mutex::new(input),
|
input: Mutex::new(input),
|
||||||
drain_task,
|
drain_task,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn container_id(&self) -> &str {
|
||||||
|
&self.container_id
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> {
|
pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> {
|
||||||
let mut input = self.input.lock().await;
|
let mut input = self.input.lock().await;
|
||||||
input.write_all(bytes).await?;
|
input.write_all(bytes).await?;
|
||||||
@@ -44,13 +59,15 @@ impl CommandStreamHandle {
|
|||||||
pub struct DockerManager {
|
pub struct DockerManager {
|
||||||
client: Docker,
|
client: Docker,
|
||||||
network_name: String,
|
network_name: String,
|
||||||
|
data_root: PathBuf,
|
||||||
|
host_data_root: PathBuf,
|
||||||
command_streams: Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>,
|
command_streams: Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DockerManager {
|
impl DockerManager {
|
||||||
pub async fn new(config: &DockerConfig) -> Result<Self> {
|
pub async fn new(config: &DaemonConfig) -> Result<Self> {
|
||||||
let client = Docker::connect_with_socket(
|
let client = Docker::connect_with_socket(
|
||||||
&config.socket,
|
&config.docker.socket,
|
||||||
120, // timeout
|
120, // timeout
|
||||||
bollard::API_DEFAULT_VERSION,
|
bollard::API_DEFAULT_VERSION,
|
||||||
)?;
|
)?;
|
||||||
@@ -62,13 +79,25 @@ impl DockerManager {
|
|||||||
"Connected to Docker"
|
"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 {
|
let manager = Self {
|
||||||
client,
|
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())),
|
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)
|
Ok(manager)
|
||||||
}
|
}
|
||||||
@@ -81,6 +110,32 @@ impl DockerManager {
|
|||||||
&self.network_name
|
&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>>>> {
|
pub(crate) fn command_streams(&self) -> &Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>> {
|
||||||
&self.command_streams
|
&self.command_streams
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod rcon;
|
pub mod rcon;
|
||||||
pub mod minecraft;
|
pub mod minecraft;
|
||||||
pub mod cs2;
|
pub mod cs2;
|
||||||
|
pub mod ark;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use tonic::{Request, Response, Status};
|
|||||||
use tracing::{info, error, warn};
|
use tracing::{info, error, warn};
|
||||||
|
|
||||||
use crate::command::CommandDispatcher;
|
use crate::command::CommandDispatcher;
|
||||||
use crate::server::{ServerManager, PortMap};
|
use crate::server::{ServerManager, ServerRuntime, PortMap};
|
||||||
use crate::filesystem::FileSystem;
|
use crate::filesystem::FileSystem;
|
||||||
use crate::backup::BackupManager;
|
use crate::backup::BackupManager;
|
||||||
use crate::managed_mysql::ManagedMysqlManager;
|
use crate::managed_mysql::ManagedMysqlManager;
|
||||||
@@ -110,6 +110,21 @@ impl DaemonServiceImpl {
|
|||||||
Self::env_value(env, keys).and_then(|v| v.parse::<i32>().ok())
|
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 {
|
fn cs2_rcon_password(env: &HashMap<String, String>) -> String {
|
||||||
Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"])
|
Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"])
|
||||||
.unwrap_or_else(|| "changeme".to_string())
|
.unwrap_or_else(|| "changeme".to_string())
|
||||||
@@ -191,6 +206,12 @@ impl DaemonService for DaemonServiceImpl {
|
|||||||
self.check_auth(&request)?;
|
self.check_auth(&request)?;
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
let runtime = ServerRuntime::from_request(
|
||||||
|
req.data_path,
|
||||||
|
req.stop_command,
|
||||||
|
req.stop_timeout_seconds,
|
||||||
|
);
|
||||||
|
|
||||||
self.server_manager
|
self.server_manager
|
||||||
.create_server(
|
.create_server(
|
||||||
req.uuid.clone(),
|
req.uuid.clone(),
|
||||||
@@ -201,6 +222,7 @@ impl DaemonService for DaemonServiceImpl {
|
|||||||
req.startup_command,
|
req.startup_command,
|
||||||
req.environment,
|
req.environment,
|
||||||
Self::map_ports(&req.ports),
|
Self::map_ports(&req.ports),
|
||||||
|
runtime,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Status::from(e))?;
|
.map_err(|e| Status::from(e))?;
|
||||||
@@ -218,6 +240,12 @@ impl DaemonService for DaemonServiceImpl {
|
|||||||
self.check_auth(&request)?;
|
self.check_auth(&request)?;
|
||||||
let req = request.into_inner();
|
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
|
let state = self.server_manager
|
||||||
.update_server(
|
.update_server(
|
||||||
req.uuid.clone(),
|
req.uuid.clone(),
|
||||||
@@ -228,6 +256,7 @@ impl DaemonService for DaemonServiceImpl {
|
|||||||
req.startup_command,
|
req.startup_command,
|
||||||
req.environment,
|
req.environment,
|
||||||
Self::map_ports(&req.ports),
|
Self::map_ports(&req.ports),
|
||||||
|
runtime,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(Status::from)?;
|
.map_err(Status::from)?;
|
||||||
@@ -378,15 +407,29 @@ impl DaemonService for DaemonServiceImpl {
|
|||||||
self.check_auth(&request)?;
|
self.check_auth(&request)?;
|
||||||
let req = request.into_inner();
|
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 => {
|
PowerAction::Start => {
|
||||||
self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?;
|
self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?;
|
||||||
}
|
}
|
||||||
PowerAction::Stop => {
|
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 => {
|
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)?;
|
self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?;
|
||||||
}
|
}
|
||||||
PowerAction::Kill => {
|
PowerAction::Kill => {
|
||||||
@@ -764,12 +807,42 @@ impl DaemonService for DaemonServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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") {
|
} else if image.contains("csgo") || image.contains("cs2") {
|
||||||
max_from_runtime_env = Self::env_i32(&env, &["CS2_MAXPLAYERS", "SRCDS_MAXPLAYERS"])
|
max_from_runtime_env = Self::env_i32(&env, &["CS2_MAXPLAYERS", "SRCDS_MAXPLAYERS"])
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let host = Self::env_value(&env, &["RCON_HOST"])
|
let host = self.rcon_host(&uuid, &env).await;
|
||||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
|
||||||
let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015);
|
let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015);
|
||||||
let password = Self::cs2_rcon_password(&env);
|
let password = Self::cs2_rcon_password(&env);
|
||||||
let address = format!("{}:{}", host, port);
|
let address = format!("{}:{}", host, port);
|
||||||
|
|||||||
+15
-1
@@ -28,6 +28,20 @@ const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
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
|
// Initialize logging
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
@@ -42,7 +56,7 @@ async fn main() -> Result<()> {
|
|||||||
info!(grpc_port = config.grpc_port, "Configuration loaded");
|
info!(grpc_port = config.grpc_port, "Configuration loaded");
|
||||||
|
|
||||||
// Initialize Docker
|
// Initialize Docker
|
||||||
let docker = Arc::new(DockerManager::new(&config.docker).await?);
|
let docker = Arc::new(DockerManager::new(&config).await?);
|
||||||
info!("Docker manager initialized");
|
info!("Docker manager initialized");
|
||||||
|
|
||||||
// Initialize server manager
|
// Initialize server manager
|
||||||
|
|||||||
@@ -128,9 +128,9 @@ impl Scheduler {
|
|||||||
"power" => {
|
"power" => {
|
||||||
match task.payload.as_str() {
|
match task.payload.as_str() {
|
||||||
"start" => self.server_manager.start_server(&task.server_uuid).await?,
|
"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" => {
|
"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;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
self.server_manager.start_server(&task.server_uuid).await?;
|
self.server_manager.start_server(&task.server_uuid).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::os::unix::fs::PermissionsExt;
|
|||||||
use crate::config::DaemonConfig;
|
use crate::config::DaemonConfig;
|
||||||
use crate::docker::DockerManager;
|
use crate::docker::DockerManager;
|
||||||
use crate::error::DaemonError;
|
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.
|
/// Manages all game server instances on this node.
|
||||||
pub struct ServerManager {
|
pub struct ServerManager {
|
||||||
@@ -101,6 +101,7 @@ impl ServerManager {
|
|||||||
startup_command: String,
|
startup_command: String,
|
||||||
environment: HashMap<String, String>,
|
environment: HashMap<String, String>,
|
||||||
ports: Vec<PortMap>,
|
ports: Vec<PortMap>,
|
||||||
|
runtime: ServerRuntime,
|
||||||
) -> Result<(), DaemonError> {
|
) -> Result<(), DaemonError> {
|
||||||
let mut servers = self.servers.write().await;
|
let mut servers = self.servers.write().await;
|
||||||
if servers.contains_key(&uuid) {
|
if servers.contains_key(&uuid) {
|
||||||
@@ -122,6 +123,7 @@ impl ServerManager {
|
|||||||
data_path,
|
data_path,
|
||||||
state: ServerState::Installing,
|
state: ServerState::Installing,
|
||||||
container_id: None,
|
container_id: None,
|
||||||
|
runtime,
|
||||||
};
|
};
|
||||||
|
|
||||||
servers.insert(uuid.clone(), spec);
|
servers.insert(uuid.clone(), spec);
|
||||||
@@ -154,6 +156,7 @@ impl ServerManager {
|
|||||||
startup_command: String,
|
startup_command: String,
|
||||||
environment: HashMap<String, String>,
|
environment: HashMap<String, String>,
|
||||||
ports: Vec<PortMap>,
|
ports: Vec<PortMap>,
|
||||||
|
runtime: ServerRuntime,
|
||||||
) -> Result<ServerState, DaemonError> {
|
) -> Result<ServerState, DaemonError> {
|
||||||
let existing = {
|
let existing = {
|
||||||
let servers = self.servers.read().await;
|
let servers = self.servers.read().await;
|
||||||
@@ -205,6 +208,7 @@ impl ServerManager {
|
|||||||
data_path,
|
data_path,
|
||||||
state: ServerState::Stopped,
|
state: ServerState::Stopped,
|
||||||
container_id: None,
|
container_id: None,
|
||||||
|
runtime: runtime.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if runtime_state
|
if runtime_state
|
||||||
@@ -212,7 +216,20 @@ impl ServerManager {
|
|||||||
.map(Self::is_running_state)
|
.map(Self::is_running_state)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
if let Err(stop_error) = self.docker.stop_container(&uuid, 30).await {
|
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");
|
warn!(uuid = %uuid, error = %stop_error, "Graceful stop failed during server update, forcing kill");
|
||||||
self.docker.kill_container(&uuid).await.map_err(|e| {
|
self.docker.kill_container(&uuid).await.map_err(|e| {
|
||||||
DaemonError::Internal(format!("Failed to stop running container during update: {}", e))
|
DaemonError::Internal(format!("Failed to stop running container during update: {}", e))
|
||||||
@@ -335,9 +352,18 @@ impl ServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stop a server.
|
/// 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 managed = false;
|
||||||
let mut previous_state: Option<ServerState> = None;
|
let mut previous_state: Option<ServerState> = None;
|
||||||
|
let mut spec_runtime = ServerRuntime::default();
|
||||||
{
|
{
|
||||||
let mut servers = self.servers.write().await;
|
let mut servers = self.servers.write().await;
|
||||||
if let Some(spec) = servers.get_mut(uuid) {
|
if let Some(spec) = servers.get_mut(uuid) {
|
||||||
@@ -353,12 +379,29 @@ impl ServerManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
previous_state = Some(spec.state.clone());
|
previous_state = Some(spec.state.clone());
|
||||||
|
spec_runtime = spec.runtime.clone();
|
||||||
spec.state = ServerState::Stopping;
|
spec.state = ServerState::Stopping;
|
||||||
managed = true;
|
managed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = self.docker.stop_container(uuid, 30).await {
|
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 {
|
if managed {
|
||||||
let mut servers = self.servers.write().await;
|
let mut servers = self.servers.write().await;
|
||||||
if let Some(spec) = servers.get_mut(uuid) {
|
if let Some(spec) = servers.get_mut(uuid) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
|
|
||||||
pub use state::{ServerSpec, PortMap};
|
pub use state::{ServerSpec, ServerRuntime, PortMap};
|
||||||
pub use manager::ServerManager;
|
pub use manager::ServerManager;
|
||||||
|
|||||||
@@ -33,6 +33,46 @@ pub struct PortMap {
|
|||||||
pub protocol: String, // "tcp" or "udp"
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ServerSpec {
|
pub struct ServerSpec {
|
||||||
pub uuid: String,
|
pub uuid: String,
|
||||||
@@ -46,6 +86,8 @@ pub struct ServerSpec {
|
|||||||
pub data_path: PathBuf,
|
pub data_path: PathBuf,
|
||||||
pub state: ServerState,
|
pub state: ServerState,
|
||||||
pub container_id: Option<String>,
|
pub container_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub runtime: ServerRuntime,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerSpec {
|
impl ServerSpec {
|
||||||
|
|||||||
+14
-1
@@ -28,9 +28,18 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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
|
# Socket.IO proxy (live console)
|
||||||
location /socket.io/ {
|
location /socket.io/ {
|
||||||
proxy_pass http://api:3000;
|
proxy_pass http://api:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -39,6 +48,10 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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
|
# Static assets caching
|
||||||
|
|||||||
+49
-49
@@ -76,62 +76,62 @@ function AuthGuard() {
|
|||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
|
||||||
{/* Protected routes */}
|
{/* Protected routes */}
|
||||||
<Route element={<AuthGuard />}>
|
<Route element={<AuthGuard />}>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
{/* Organizations */}
|
{/* Organizations */}
|
||||||
<Route path="/" element={<OrganizationsPage />} />
|
<Route path="/" element={<OrganizationsPage />} />
|
||||||
|
|
||||||
{/* Org-scoped routes */}
|
{/* Org-scoped routes */}
|
||||||
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
|
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/org/:orgId/servers" element={<ServersPage />} />
|
<Route path="/org/:orgId/servers" element={<ServersPage />} />
|
||||||
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
|
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
|
||||||
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
|
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
|
||||||
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
|
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
|
||||||
<Route path="/org/:orgId/settings" element={<Navigate to="members" replace />} />
|
<Route path="/org/:orgId/settings" element={<Navigate to="members" replace />} />
|
||||||
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
|
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
|
||||||
|
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
<Route path="/account/security" element={<AccountSecurityPage />} />
|
<Route path="/account/security" element={<AccountSecurityPage />} />
|
||||||
|
|
||||||
{/* Server detail */}
|
{/* Server detail */}
|
||||||
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
|
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
|
||||||
<Route index element={<Navigate to="console" replace />} />
|
<Route index element={<Navigate to="console" replace />} />
|
||||||
<Route path="console" element={<ConsolePage />} />
|
<Route path="console" element={<ConsolePage />} />
|
||||||
<Route path="files" element={<FilesPage />} />
|
<Route path="files" element={<FilesPage />} />
|
||||||
<Route path="config" element={<ConfigPage />} />
|
<Route path="config" element={<ConfigPage />} />
|
||||||
<Route path="databases" element={<DatabasesPage />} />
|
<Route path="databases" element={<DatabasesPage />} />
|
||||||
<Route path="plugins" element={<PluginsPage />} />
|
<Route path="plugins" element={<PluginsPage />} />
|
||||||
<Route path="backups" element={<BackupsPage />} />
|
<Route path="backups" element={<BackupsPage />} />
|
||||||
<Route path="schedules" element={<SchedulesPage />} />
|
<Route path="schedules" element={<SchedulesPage />} />
|
||||||
<Route path="players" element={<PlayersPage />} />
|
<Route path="players" element={<PlayersPage />} />
|
||||||
<Route path="settings" element={<ServerSettingsPage />} />
|
<Route path="settings" element={<ServerSettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Admin */}
|
||||||
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
|
<Route path="/admin/games" element={<AdminGamesPage />} />
|
||||||
|
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
|
||||||
|
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
||||||
|
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Admin */}
|
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
|
||||||
<Route path="/admin/games" element={<AdminGamesPage />} />
|
|
||||||
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
|
|
||||||
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
|
||||||
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
<Toaster position="bottom-right" richColors />
|
<Toaster position="bottom-right" richColors />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { Outlet, useParams, Link, useLocation } from 'react-router';
|
import { Outlet, useParams, Link, useLocation } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2, Database as DatabaseIcon } from 'lucide-react';
|
import {
|
||||||
|
Terminal,
|
||||||
|
FolderOpen,
|
||||||
|
Settings,
|
||||||
|
Calendar,
|
||||||
|
HardDrive,
|
||||||
|
Users,
|
||||||
|
Puzzle,
|
||||||
|
Settings2,
|
||||||
|
Database as DatabaseIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { cn } from '@source/ui';
|
import { cn } from '@source/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -52,9 +62,7 @@ export function ServerLayout() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{server?.name ?? 'Loading...'}</h1>
|
<h1 className="text-2xl font-bold">{server?.name ?? 'Loading...'}</h1>
|
||||||
{server && (
|
{server && <Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>}
|
||||||
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{server && (
|
{server && (
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -96,8 +96,7 @@ function NavSection({ items, currentPath }: { items: NavItem[]; currentPath: str
|
|||||||
return (
|
return (
|
||||||
<nav className="flex flex-col gap-1">
|
<nav className="flex flex-col gap-1">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isActive =
|
const isActive = currentPath === item.href || currentPath.startsWith(item.href + '/');
|
||||||
currentPath === item.href || currentPath.startsWith(item.href + '/');
|
|
||||||
return (
|
return (
|
||||||
<Link key={item.href} to={item.href}>
|
<Link key={item.href} to={item.href}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -93,11 +93,7 @@ export function PowerControls({ serverId, orgId, status }: PowerControlsProps) {
|
|||||||
|
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button size="sm" variant="destructive" disabled={isTransitioning && !isRunning}>
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
disabled={isTransitioning && !isRunning}
|
|
||||||
>
|
|
||||||
<Skull className="h-4 w-4" />
|
<Skull className="h-4 w-4" />
|
||||||
Kill
|
Kill
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ const badgeVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
VariantProps<typeof badgeVariants> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const buttonVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/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',
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
@@ -30,15 +31,16 @@ const buttonVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : 'button';
|
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';
|
Button.displayName = 'Button';
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { cn } from '@source/ui';
|
|||||||
|
|
||||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ 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';
|
Card.displayName = 'Card';
|
||||||
@@ -17,7 +21,11 @@ CardHeader.displayName = 'CardHeader';
|
|||||||
|
|
||||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ 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';
|
CardTitle.displayName = 'CardTitle';
|
||||||
@@ -30,7 +38,9 @@ const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
|
|||||||
CardDescription.displayName = 'CardDescription';
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
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';
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
|||||||
);
|
);
|
||||||
|
|
||||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
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<
|
const DialogTitle = React.forwardRef<
|
||||||
@@ -72,7 +75,11 @@ const DialogDescription = React.forwardRef<
|
|||||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ 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;
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ 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;
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ const ScrollArea = React.forwardRef<
|
|||||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ 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]">
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
|||||||
@@ -111,8 +111,7 @@ async function refreshToken(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string, params?: Record<string, string>) =>
|
get: <T>(path: string, params?: Record<string, string>) => request<T>(path, { params }),
|
||||||
request<T>(path, { params }),
|
|
||||||
|
|
||||||
post: <T>(path: string, body?: unknown) =>
|
post: <T>(path: string, body?: unknown) =>
|
||||||
request<T>(path, {
|
request<T>(path, {
|
||||||
@@ -132,8 +131,7 @@ export const api = {
|
|||||||
body: toRequestBody(body),
|
body: toRequestBody(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: <T>(path: string) =>
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
request<T>(path, { method: 'DELETE' }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ApiError };
|
export { ApiError };
|
||||||
|
|||||||
@@ -186,9 +186,7 @@ export function AdminGamesPage() {
|
|||||||
<Label>Slug</Label>
|
<Label>Slug</Label>
|
||||||
<Input
|
<Input
|
||||||
value={slug}
|
value={slug}
|
||||||
onChange={(e) =>
|
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||||
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))
|
|
||||||
}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +239,10 @@ export function AdminGamesPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
|
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
|
||||||
<p>Port: {game.defaultPort}</p>
|
<p>Port: {game.defaultPort}</p>
|
||||||
<p>Automation: {Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow</p>
|
<p>
|
||||||
|
Automation:{' '}
|
||||||
|
{Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -277,7 +278,8 @@ export function AdminGamesPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>JSON</Label>
|
<Label>JSON</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Supported events: server.created, server.install.completed, server.power.started, server.power.stopped
|
Supported events: server.created, server.install.completed, server.power.started,
|
||||||
|
server.power.stopped
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
value={automationJson}
|
value={automationJson}
|
||||||
|
|||||||
@@ -42,14 +42,20 @@ export function AdminNodesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||||
{node.isOnline ? (
|
{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>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<div className="mt-3 flex gap-4 text-sm">
|
||||||
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
||||||
<span>{formatBytes(node.diskTotal)} Disk</span>
|
<span>{formatBytes(node.diskTotal)} Disk</span>
|
||||||
|
|||||||
@@ -197,12 +197,14 @@ export function AdminPluginsPage() {
|
|||||||
const map = new Map<string, File>();
|
const map = new Map<string, File>();
|
||||||
|
|
||||||
for (const item of prev) {
|
for (const item of prev) {
|
||||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
const relative =
|
||||||
|
(item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of Array.from(incoming)) {
|
for (const item of Array.from(incoming)) {
|
||||||
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
const relative =
|
||||||
|
(item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||||
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,12 +213,8 @@ export function AdminPluginsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createPluginMutation = useMutation({
|
const createPluginMutation = useMutation({
|
||||||
mutationFn: (body: {
|
mutationFn: (body: { gameId: string; name: string; slug?: string; description?: string }) =>
|
||||||
gameId: string;
|
api.post('/admin/plugins', body),
|
||||||
name: string;
|
|
||||||
slug?: string;
|
|
||||||
description?: string;
|
|
||||||
}) => api.post('/admin/plugins', body),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Global plugin created');
|
toast.success('Global plugin created');
|
||||||
setCreatePluginOpen(false);
|
setCreatePluginOpen(false);
|
||||||
@@ -366,15 +364,25 @@ export function AdminPluginsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Name</Label>
|
<Label>Name</Label>
|
||||||
<Input value={createPluginName} onChange={(e) => setCreatePluginName(e.target.value)} required />
|
<Input
|
||||||
|
value={createPluginName}
|
||||||
|
onChange={(e) => setCreatePluginName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Slug (optional)</Label>
|
<Label>Slug (optional)</Label>
|
||||||
<Input value={createPluginSlug} onChange={(e) => setCreatePluginSlug(e.target.value)} />
|
<Input
|
||||||
|
value={createPluginSlug}
|
||||||
|
onChange={(e) => setCreatePluginSlug(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Description (optional)</Label>
|
<Label>Description (optional)</Label>
|
||||||
<Input value={createPluginDescription} onChange={(e) => setCreatePluginDescription(e.target.value)} />
|
<Input
|
||||||
|
value={createPluginDescription}
|
||||||
|
onChange={(e) => setCreatePluginDescription(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={createPluginMutation.isPending}>
|
<Button type="submit" disabled={createPluginMutation.isPending}>
|
||||||
@@ -424,7 +432,9 @@ export function AdminPluginsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedPluginId(plugin.id)}
|
onClick={() => setSelectedPluginId(plugin.id)}
|
||||||
className={`w-full rounded-md border px-3 py-2 text-left transition ${
|
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'
|
selectedPluginId === plugin.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'hover:bg-muted/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -454,11 +464,7 @@ export function AdminPluginsPage() {
|
|||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" /> Clone Latest
|
<Copy className="h-4 w-4" /> Clone Latest
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" onClick={() => openReleaseDialogFrom()} disabled={!selectedPlugin}>
|
||||||
size="sm"
|
|
||||||
onClick={() => openReleaseDialogFrom()}
|
|
||||||
disabled={!selectedPlugin}
|
|
||||||
>
|
|
||||||
<UploadCloud className="h-4 w-4" /> New Release
|
<UploadCloud className="h-4 w-4" /> New Release
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -478,9 +484,12 @@ export function AdminPluginsPage() {
|
|||||||
<Badge variant="secondary">{release.artifactType}</Badge>
|
<Badge variant="secondary">{release.artifactType}</Badge>
|
||||||
{!release.isPublished && <Badge variant="destructive">Unpublished</Badge>}
|
{!release.isPublished && <Badge variant="destructive">Unpublished</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{release.artifactUrl}</p>
|
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
||||||
|
{release.artifactUrl}
|
||||||
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0} fields • Templates:{' '}
|
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0}{' '}
|
||||||
|
fields • Templates:{' '}
|
||||||
{Array.isArray(release.configTemplates) ? release.configTemplates.length : 0}
|
{Array.isArray(release.configTemplates) ? release.configTemplates.length : 0}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -517,7 +526,9 @@ export function AdminPluginsPage() {
|
|||||||
>
|
>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}</DialogTitle>
|
<DialogTitle>
|
||||||
|
Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
@@ -540,7 +551,8 @@ export function AdminPluginsPage() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('version', releaseVersion);
|
formData.append('version', releaseVersion);
|
||||||
formData.append('channel', releaseChannel);
|
formData.append('channel', releaseChannel);
|
||||||
if (releaseDestination.trim()) formData.append('destination', releaseDestination.trim());
|
if (releaseDestination.trim())
|
||||||
|
formData.append('destination', releaseDestination.trim());
|
||||||
if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim());
|
if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim());
|
||||||
if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog);
|
if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog);
|
||||||
if (releaseInstallSchemaFile) {
|
if (releaseInstallSchemaFile) {
|
||||||
@@ -563,8 +575,12 @@ export function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of releaseArtifactFiles) {
|
for (const file of releaseArtifactFiles) {
|
||||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
const relativePath = (file as File & { webkitRelativePath?: string })
|
||||||
formData.append('relativePath', relativePath && relativePath.length > 0 ? relativePath : file.name);
|
.webkitRelativePath;
|
||||||
|
formData.append(
|
||||||
|
'relativePath',
|
||||||
|
relativePath && relativePath.length > 0 ? relativePath : file.name,
|
||||||
|
);
|
||||||
formData.append('files', file, file.name);
|
formData.append('files', file, file.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +608,11 @@ export function AdminPluginsPage() {
|
|||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Version</Label>
|
<Label>Version</Label>
|
||||||
<Input value={releaseVersion} onChange={(e) => setReleaseVersion(e.target.value)} required />
|
<Input
|
||||||
|
value={releaseVersion}
|
||||||
|
onChange={(e) => setReleaseVersion(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Channel</Label>
|
<Label>Channel</Label>
|
||||||
@@ -658,8 +678,8 @@ export function AdminPluginsPage() {
|
|||||||
{releaseInputMode === 'upload' && (
|
{releaseInputMode === 'upload' && (
|
||||||
<div className="space-y-3 rounded-md border p-3">
|
<div className="space-y-3 rounded-md border p-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen otomatik zip
|
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen
|
||||||
yapilip CDN'e yuklenir.
|
otomatik zip yapilip CDN'e yuklenir.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -683,9 +703,16 @@ export function AdminPluginsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-muted-foreground">Selected: {releaseArtifactFiles.length} file(s)</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Selected: {releaseArtifactFiles.length} file(s)
|
||||||
|
</p>
|
||||||
{releaseArtifactFiles.length > 0 && (
|
{releaseArtifactFiles.length > 0 && (
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setReleaseArtifactFiles([])}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setReleaseArtifactFiles([])}
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -693,7 +720,8 @@ export function AdminPluginsPage() {
|
|||||||
{releaseArtifactFiles.length > 0 && (
|
{releaseArtifactFiles.length > 0 && (
|
||||||
<div className="max-h-28 space-y-1 overflow-auto rounded bg-muted/40 p-2 text-xs">
|
<div className="max-h-28 space-y-1 overflow-auto rounded bg-muted/40 p-2 text-xs">
|
||||||
{releaseArtifactFiles.map((file, index) => {
|
{releaseArtifactFiles.map((file, index) => {
|
||||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
const relativePath = (file as File & { webkitRelativePath?: string })
|
||||||
|
.webkitRelativePath;
|
||||||
return (
|
return (
|
||||||
<p key={`${relativePath || file.name}-${index}`} className="truncate">
|
<p key={`${relativePath || file.name}-${index}`} className="truncate">
|
||||||
{relativePath || file.name}
|
{relativePath || file.name}
|
||||||
@@ -745,7 +773,10 @@ export function AdminPluginsPage() {
|
|||||||
/>
|
/>
|
||||||
{releaseInstallSchemaFile && (
|
{releaseInstallSchemaFile && (
|
||||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
<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>
|
<p>
|
||||||
|
File secili: {releaseInstallSchemaFile.name}. Bu dosya, alttaki metni override
|
||||||
|
eder.
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -776,7 +807,10 @@ export function AdminPluginsPage() {
|
|||||||
/>
|
/>
|
||||||
{releaseTemplatesFile && (
|
{releaseTemplatesFile && (
|
||||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
<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>
|
<p>
|
||||||
|
File secili: {releaseTemplatesFile.name}. Bu dosya, alttaki metni override
|
||||||
|
eder.
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -807,7 +841,7 @@ export function AdminPluginsPage() {
|
|||||||
!selectedPlugin
|
!selectedPlugin
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(createReleaseMutation.isPending || createUploadReleaseMutation.isPending)
|
{createReleaseMutation.isPending || createUploadReleaseMutation.isPending
|
||||||
? 'Publishing...'
|
? 'Publishing...'
|
||||||
: 'Publish Release'}
|
: 'Publish Release'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { Shield } from 'lucide-react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { useAuthStore } from '@/stores/auth';
|
||||||
import { ApiError } from '@/lib/api';
|
import { ApiError } from '@/lib/api';
|
||||||
|
|
||||||
@@ -47,7 +54,9 @@ export function LoginPage() {
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{error && (
|
{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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { Shield } from 'lucide-react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { useAuthStore } from '@/stores/auth';
|
||||||
import { ApiError } from '@/lib/api';
|
import { ApiError } from '@/lib/api';
|
||||||
|
|
||||||
@@ -48,7 +55,9 @@ export function RegisterPage() {
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{error && (
|
{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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export function DashboardPage() {
|
|||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<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" />
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -93,17 +93,13 @@ export function NodeDetailPage() {
|
|||||||
const { data: serversData } = useQuery({
|
const { data: serversData } = useQuery({
|
||||||
queryKey: ['node-servers', orgId, nodeId],
|
queryKey: ['node-servers', orgId, nodeId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ data: ServerSummary[] }>(
|
api.get<{ data: ServerSummary[] }>(`/organizations/${orgId}/nodes/${nodeId}/servers`),
|
||||||
`/organizations/${orgId}/nodes/${nodeId}/servers`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: allocData } = useQuery({
|
const { data: allocData } = useQuery({
|
||||||
queryKey: ['allocations', orgId, nodeId],
|
queryKey: ['allocations', orgId, nodeId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ data: Allocation[] }>(
|
api.get<{ data: Allocation[] }>(`/organizations/${orgId}/nodes/${nodeId}/allocations`),
|
||||||
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const allocations = allocData?.data ?? [];
|
const allocations = allocData?.data ?? [];
|
||||||
@@ -142,12 +138,10 @@ export function NodeDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const memPercent = stats && stats.memoryTotal > 0
|
const memPercent =
|
||||||
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
|
stats && stats.memoryTotal > 0 ? Math.round((stats.memoryUsed / stats.memoryTotal) * 100) : 0;
|
||||||
: 0;
|
const diskPercent =
|
||||||
const diskPercent = stats && stats.diskTotal > 0
|
stats && stats.diskTotal > 0 ? Math.round((stats.diskUsed / stats.diskTotal) * 100) : 0;
|
||||||
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -167,9 +161,13 @@ export function NodeDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||||
{node.isOnline ? (
|
{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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,9 +195,7 @@ export function NodeDetailPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{stats
|
{stats ? `${formatBytes(stats.memoryUsed)} / ${formatBytes(stats.memoryTotal)}` : '—'}
|
||||||
? `${formatBytes(stats.memoryUsed)} / ${formatBytes(stats.memoryTotal)}`
|
|
||||||
: '—'}
|
|
||||||
</div>
|
</div>
|
||||||
<Progress value={memPercent} className="mt-2 h-2" />
|
<Progress value={memPercent} className="mt-2 h-2" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -212,9 +208,7 @@ export function NodeDetailPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{stats
|
{stats ? `${formatBytes(stats.diskUsed)} / ${formatBytes(stats.diskTotal)}` : '—'}
|
||||||
? `${formatBytes(stats.diskUsed)} / ${formatBytes(stats.diskTotal)}`
|
|
||||||
: '—'}
|
|
||||||
</div>
|
</div>
|
||||||
<Progress value={diskPercent} className="mt-2 h-2" />
|
<Progress value={diskPercent} className="mt-2 h-2" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -248,9 +242,7 @@ export function NodeDetailPage() {
|
|||||||
<InfoRow label="gRPC Port" value={String(node.grpcPort)} />
|
<InfoRow label="gRPC Port" value={String(node.grpcPort)} />
|
||||||
<InfoRow label="Total Memory" value={formatBytes(node.memoryTotal)} />
|
<InfoRow label="Total Memory" value={formatBytes(node.memoryTotal)} />
|
||||||
<InfoRow label="Total Disk" value={formatBytes(node.diskTotal)} />
|
<InfoRow label="Total Disk" value={formatBytes(node.diskTotal)} />
|
||||||
{node.daemonVersion && (
|
{node.daemonVersion && <InfoRow label="Daemon Version" value={node.daemonVersion} />}
|
||||||
<InfoRow label="Daemon Version" value={node.daemonVersion} />
|
|
||||||
)}
|
|
||||||
<InfoRow label="Created" value={new Date(node.createdAt).toLocaleDateString()} />
|
<InfoRow label="Created" value={new Date(node.createdAt).toLocaleDateString()} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -277,9 +269,7 @@ export function NodeDetailPage() {
|
|||||||
<p className="text-xs text-muted-foreground">{srv.gameName}</p>
|
<p className="text-xs text-muted-foreground">{srv.gameName}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge variant={srv.status === 'running' ? 'default' : 'outline'}>
|
||||||
variant={srv.status === 'running' ? 'default' : 'outline'}
|
|
||||||
>
|
|
||||||
{srv.status}
|
{srv.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -373,7 +363,10 @@ export function NodeDetailPage() {
|
|||||||
/** Parse port input like "25565, 25566-25570, 27015" into flat number array */
|
/** Parse port input like "25565, 25566-25570, 27015" into flat number array */
|
||||||
function parsePorts(input: string): number[] {
|
function parsePorts(input: string): number[] {
|
||||||
const ports: number[] = [];
|
const ports: number[] = [];
|
||||||
const parts = input.split(',').map((s) => s.trim()).filter(Boolean);
|
const parts = input
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.includes('-')) {
|
if (part.includes('-')) {
|
||||||
const [startStr, endStr] = part.split('-');
|
const [startStr, endStr] = part.split('-');
|
||||||
|
|||||||
@@ -183,16 +183,8 @@ export function NodesPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Daemon Token</Label>
|
<Label>Daemon Token</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input readOnly value={createdToken} className="font-mono text-xs" />
|
||||||
readOnly
|
<Button variant="outline" size="icon" onClick={handleCopyToken}>
|
||||||
value={createdToken}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleCopyToken}
|
|
||||||
>
|
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="h-4 w-4 text-green-500" />
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
@@ -201,7 +193,8 @@ export function NodesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Use this token in your daemon configuration file (config.yml) to authenticate with the panel.
|
Use this token in your daemon configuration file (config.yml) to authenticate with the
|
||||||
|
panel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -213,28 +206,34 @@ export function NodesPage() {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<Link key={node.id} to={`/org/${orgId}/nodes/${node.id}`}>
|
<Link key={node.id} to={`/org/${orgId}/nodes/${node.id}`}>
|
||||||
<Card className="transition-colors hover:bg-muted/50 cursor-pointer">
|
<Card className="transition-colors hover:bg-muted/50 cursor-pointer">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Network className="h-5 w-5 text-primary" />
|
<Network className="h-5 w-5 text-primary" />
|
||||||
<CardTitle className="text-base">{node.name}</CardTitle>
|
<CardTitle className="text-base">{node.name}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||||
{node.isOnline ? (
|
{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</>
|
</>
|
||||||
)}
|
) : (
|
||||||
</Badge>
|
<>
|
||||||
</CardHeader>
|
<WifiOff className="mr-1 h-3 w-3" /> Offline
|
||||||
<CardContent>
|
</>
|
||||||
<p className="text-sm text-muted-foreground">{node.fqdn}:{node.daemonPort}</p>
|
)}
|
||||||
<div className="mt-3 flex gap-4 text-sm">
|
</Badge>
|
||||||
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
</CardHeader>
|
||||||
<span>{formatBytes(node.diskTotal)} Disk</span>
|
<CardContent>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">
|
||||||
</CardContent>
|
{node.fqdn}:{node.daemonPort}
|
||||||
</Card>
|
</p>
|
||||||
|
<div className="mt-3 flex gap-4 text-sm">
|
||||||
|
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
||||||
|
<span>{formatBytes(node.diskTotal)} Disk</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,16 +54,13 @@ export function BackupsPage() {
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['backups', orgId, serverId],
|
queryKey: ['backups', orgId, serverId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ backups: Backup[] }>(
|
api.get<{ backups: Backup[] }>(`/organizations/${orgId}/servers/${serverId}/backups`),
|
||||||
`/organizations/${orgId}/servers/${serverId}/backups`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (backupId: string) =>
|
mutationFn: (backupId: string) =>
|
||||||
api.delete(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}`),
|
api.delete(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}`),
|
||||||
onSuccess: () =>
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoreMutation = useMutation({
|
const restoreMutation = useMutation({
|
||||||
@@ -75,8 +72,7 @@ export function BackupsPage() {
|
|||||||
const lockMutation = useMutation({
|
const lockMutation = useMutation({
|
||||||
mutationFn: (backupId: string) =>
|
mutationFn: (backupId: string) =>
|
||||||
api.patch(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}/lock`, {}),
|
api.patch(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}/lock`, {}),
|
||||||
onSuccess: () =>
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const backupList = data?.backups ?? [];
|
const backupList = data?.backups ?? [];
|
||||||
@@ -89,7 +85,8 @@ export function BackupsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Backups</h2>
|
<h2 className="text-lg font-semibold">Backups</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
@@ -154,9 +151,7 @@ export function BackupsPage() {
|
|||||||
<span>{formatBytes(backup.sizeBytes)}</span>
|
<span>{formatBytes(backup.sizeBytes)}</span>
|
||||||
<span>{new Date(backup.createdAt).toLocaleString()}</span>
|
<span>{new Date(backup.createdAt).toLocaleString()}</span>
|
||||||
{backup.checksum && (
|
{backup.checksum && (
|
||||||
<span className="font-mono">
|
<span className="font-mono">{backup.checksum.slice(0, 12)}...</span>
|
||||||
{backup.checksum.slice(0, 12)}...
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,9 +233,7 @@ function CreateBackupForm({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [name, setName] = useState(
|
const [name, setName] = useState(`backup-${new Date().toISOString().slice(0, 10)}`);
|
||||||
`backup-${new Date().toISOString().slice(0, 10)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: { name: string }) =>
|
mutationFn: (data: { name: string }) =>
|
||||||
@@ -261,11 +254,7 @@ function CreateBackupForm({
|
|||||||
>
|
>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Backup Name</Label>
|
<Label>Backup Name</Label>
|
||||||
<Input
|
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ interface ConfigDetail {
|
|||||||
raw: string;
|
raw: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeConfigEntries(
|
function mergeConfigEntries(entries: ConfigEntry[], editableKeys: string[] | null): ConfigEntry[] {
|
||||||
entries: ConfigEntry[],
|
|
||||||
editableKeys: string[] | null,
|
|
||||||
): ConfigEntry[] {
|
|
||||||
if (!editableKeys || editableKeys.length === 0) return entries;
|
if (!editableKeys || editableKeys.length === 0) return entries;
|
||||||
|
|
||||||
const existing = new Map(entries.map((entry) => [entry.key, entry]));
|
const existing = new Map(entries.map((entry) => [entry.key, entry]));
|
||||||
@@ -55,9 +52,7 @@ export function ConfigPage() {
|
|||||||
const { data: configsData } = useQuery({
|
const { data: configsData } = useQuery({
|
||||||
queryKey: ['configs', orgId, serverId],
|
queryKey: ['configs', orgId, serverId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ configs: ConfigFile[] }>(
|
api.get<{ configs: ConfigFile[] }>(`/organizations/${orgId}/servers/${serverId}/config`),
|
||||||
`/organizations/${orgId}/servers/${serverId}/config`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const configs = configsData?.configs ?? [];
|
const configs = configsData?.configs ?? [];
|
||||||
@@ -114,9 +109,7 @@ function ConfigEditor({
|
|||||||
const { data: detail } = useQuery({
|
const { data: detail } = useQuery({
|
||||||
queryKey: ['config-detail', orgId, serverId, configIndex],
|
queryKey: ['config-detail', orgId, serverId, configIndex],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<ConfigDetail>(
|
api.get<ConfigDetail>(`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`),
|
||||||
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [entries, setEntries] = useState<ConfigEntry[]>([]);
|
const [entries, setEntries] = useState<ConfigEntry[]>([]);
|
||||||
@@ -128,10 +121,7 @@ function ConfigEditor({
|
|||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (data: { entries: ConfigEntry[] }) =>
|
mutationFn: (data: { entries: ConfigEntry[] }) =>
|
||||||
api.put(
|
api.put(`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`, data),
|
||||||
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
|
|
||||||
data,
|
|
||||||
),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['config-detail', orgId, serverId, configIndex],
|
queryKey: ['config-detail', orgId, serverId, configIndex],
|
||||||
@@ -140,9 +130,7 @@ function ConfigEditor({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateEntry = (key: string, value: string) => {
|
const updateEntry = (key: string, value: string) => {
|
||||||
setEntries((prev) =>
|
setEntries((prev) => prev.map((e) => (e.key === key ? { ...e, value } : e)));
|
||||||
prev.map((e) => (e.key === key ? { ...e, value } : e)),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -171,15 +159,15 @@ function ConfigEditor({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<div key={entry.key} className="grid gap-1.5">
|
<div key={entry.key} className="grid gap-1.5">
|
||||||
<Label className="font-mono text-xs text-muted-foreground">
|
<Label className="font-mono text-xs text-muted-foreground">{entry.key}</Label>
|
||||||
{entry.key}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
value={entry.value}
|
value={entry.value}
|
||||||
onChange={(e) => updateEntry(entry.key, e.target.value)}
|
onChange={(e) => updateEntry(entry.key, e.target.value)}
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { toast } from 'sonner';
|
|||||||
import { ApiError, api } from '@/lib/api';
|
import { ApiError, api } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
@@ -38,9 +45,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
|
<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">
|
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs">{value}</div>
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,9 +64,7 @@ export function DatabasesPage() {
|
|||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['server-databases', orgId, serverId],
|
queryKey: ['server-databases', orgId, serverId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ data: ManagedDatabase[] }>(
|
api.get<{ data: ManagedDatabase[] }>(`/organizations/${orgId}/servers/${serverId}/databases`),
|
||||||
`/organizations/${orgId}/servers/${serverId}/databases`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -270,11 +273,7 @@ export function DatabasesPage() {
|
|||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button size="sm" variant="outline" onClick={() => setEditingDatabase(database)}>
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEditingDatabase(database)}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" /> Edit
|
<RefreshCw className="h-4 w-4" /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -307,7 +306,8 @@ export function DatabasesPage() {
|
|||||||
/>
|
/>
|
||||||
{!database.phpMyAdminUrl ? (
|
{!database.phpMyAdminUrl ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
phpMyAdmin link is not configured. Set `managed_mysql.phpmyadmin_url` in the daemon config for this node.
|
phpMyAdmin link is not configured. Set `managed_mysql.phpmyadmin_url` in the
|
||||||
|
daemon config for this node.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -68,10 +68,7 @@ function joinRemotePath(basePath: string, relativePath: string): string {
|
|||||||
.split('/')
|
.split('/')
|
||||||
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||||
|
|
||||||
const baseSegments = basePath
|
const baseSegments = basePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
.replace(/\\/g, '/')
|
|
||||||
.split('/')
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return `/${[...baseSegments, ...safeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
return `/${[...baseSegments, ...safeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||||
}
|
}
|
||||||
@@ -133,18 +130,16 @@ export function FilesPage() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasUnsavedChanges =
|
const hasUnsavedChanges = !!editingFile && editingFile.content !== editingFile.originalContent;
|
||||||
!!editingFile && editingFile.content !== editingFile.originalContent;
|
|
||||||
const isUploading = !!uploadProgress;
|
const isUploading = !!uploadProgress;
|
||||||
|
|
||||||
const filesQuery = useQuery({
|
const filesQuery = useQuery({
|
||||||
queryKey: ['files', orgId, serverId, currentPath],
|
queryKey: ['files', orgId, serverId, currentPath],
|
||||||
enabled: Boolean(orgId && serverId) && !editingFile,
|
enabled: Boolean(orgId && serverId) && !editingFile,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ files: FileEntry[] }>(
|
api.get<{ files: FileEntry[] }>(`/organizations/${orgId}/servers/${serverId}/files`, {
|
||||||
`/organizations/${orgId}/servers/${serverId}/files`,
|
path: currentPath,
|
||||||
{ path: currentPath },
|
}),
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -528,9 +523,7 @@ export function FilesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && <span className="text-xs text-amber-600">Unsaved changes</span>}
|
||||||
<span className="text-xs text-amber-600">Unsaved changes</span>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={saveCurrentFile}
|
onClick={saveCurrentFile}
|
||||||
@@ -550,9 +543,7 @@ export function FilesPage() {
|
|||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={editingFile.content}
|
value={editingFile.content}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setEditingFile((prev) =>
|
setEditingFile((prev) => (prev ? { ...prev, content: event.target.value } : prev))
|
||||||
prev ? { ...prev, content: event.target.value } : prev,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onKeyDown={handleEditorKeyDown}
|
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"
|
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"
|
||||||
@@ -611,21 +602,11 @@ export function FilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button size="sm" variant="outline" onClick={triggerUploadFiles} disabled={isUploading}>
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={triggerUploadFiles}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
Upload Files
|
Upload Files
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" variant="outline" onClick={triggerUploadFolder} disabled={isUploading}>
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={triggerUploadFolder}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
Upload Folder
|
Upload Folder
|
||||||
</Button>
|
</Button>
|
||||||
@@ -746,11 +727,7 @@ export function FilesPage() {
|
|||||||
{filesQuery.isError && (
|
{filesQuery.isError && (
|
||||||
<div className="space-y-2 py-8 text-center">
|
<div className="space-y-2 py-8 text-center">
|
||||||
<p className="text-sm text-destructive">Failed to load directory</p>
|
<p className="text-sm text-destructive">Failed to load directory</p>
|
||||||
<Button
|
<Button size="sm" variant="outline" onClick={() => filesQuery.refetch()}>
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => filesQuery.refetch()}
|
|
||||||
>
|
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -826,8 +803,7 @@ export function FilesPage() {
|
|||||||
<DialogTitle>{deleteTarget?.isDirectory ? 'Delete Folder' : 'Delete File'}</DialogTitle>
|
<DialogTitle>{deleteTarget?.isDirectory ? 'Delete Folder' : 'Delete File'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Are you sure you want to delete{' '}
|
Are you sure you want to delete <code className="font-mono">{deleteTarget?.path}</code>?
|
||||||
<code className="font-mono">{deleteTarget?.path}</code>?
|
|
||||||
</p>
|
</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ export function PlayersPage() {
|
|||||||
const { data, isLoading, refetch } = useQuery({
|
const { data, isLoading, refetch } = useQuery({
|
||||||
queryKey: ['players', orgId, serverId],
|
queryKey: ['players', orgId, serverId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<PlayerListResponse>(
|
api.get<PlayerListResponse>(`/organizations/${orgId}/servers/${serverId}/players`),
|
||||||
`/organizations/${orgId}/servers/${serverId}/players`,
|
|
||||||
),
|
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,9 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
|||||||
const [installTarget, setInstallTarget] = useState<MarketplacePlugin | null>(null);
|
const [installTarget, setInstallTarget] = useState<MarketplacePlugin | null>(null);
|
||||||
const [installOptions, setInstallOptions] = useState<Record<string, unknown>>({});
|
const [installOptions, setInstallOptions] = useState<Record<string, unknown>>({});
|
||||||
const [installPinVersion, setInstallPinVersion] = useState(false);
|
const [installPinVersion, setInstallPinVersion] = useState(false);
|
||||||
const [installAutoUpdateChannel, setInstallAutoUpdateChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
|
const [installAutoUpdateChannel, setInstallAutoUpdateChannel] = useState<
|
||||||
|
'stable' | 'beta' | 'alpha'
|
||||||
|
>('stable');
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
|
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
|
||||||
@@ -317,7 +319,10 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
|||||||
autoUpdateChannel?: 'stable' | 'beta' | 'alpha';
|
autoUpdateChannel?: 'stable' | 'beta' | 'alpha';
|
||||||
};
|
};
|
||||||
}) =>
|
}) =>
|
||||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`, payload ?? {}),
|
api.post(
|
||||||
|
`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`,
|
||||||
|
payload ?? {},
|
||||||
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Plugin installed');
|
toast.success('Plugin installed');
|
||||||
setInstallDialogOpen(false);
|
setInstallDialogOpen(false);
|
||||||
@@ -364,8 +369,7 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
|||||||
description?: string;
|
description?: string;
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
}) =>
|
}) => api.post(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`, body),
|
||||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`, body),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Marketplace plugin added');
|
toast.success('Marketplace plugin added');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
@@ -655,7 +659,9 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => updateInstallMutation.mutate({ installId: plugin.installId! })}
|
onClick={() =>
|
||||||
|
updateInstallMutation.mutate({ installId: plugin.installId! })
|
||||||
|
}
|
||||||
disabled={updateInstallMutation.isPending}
|
disabled={updateInstallMutation.isPending}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
@@ -893,14 +899,18 @@ function InstalledPlugins({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="font-medium">{plugin.name}</p>
|
<p className="font-medium">{plugin.name}</p>
|
||||||
<Badge variant="outline">{plugin.source}</Badge>
|
<Badge variant="outline">{plugin.source}</Badge>
|
||||||
{plugin.installedVersion && <Badge variant="secondary">v{plugin.installedVersion}</Badge>}
|
{plugin.installedVersion && (
|
||||||
|
<Badge variant="secondary">v{plugin.installedVersion}</Badge>
|
||||||
|
)}
|
||||||
{!plugin.isActive && <Badge variant="secondary">Disabled</Badge>}
|
{!plugin.isActive && <Badge variant="secondary">Disabled</Badge>}
|
||||||
{plugin.status !== 'installed' && (
|
{plugin.status !== 'installed' && (
|
||||||
<Badge variant={plugin.status === 'failed' ? 'destructive' : 'outline'}>
|
<Badge variant={plugin.status === 'failed' ? 'destructive' : 'outline'}>
|
||||||
{plugin.status}
|
{plugin.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{plugin.updateAvailable && <Badge variant="destructive">Update Available</Badge>}
|
{plugin.updateAvailable && (
|
||||||
|
<Badge variant="destructive">Update Available</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{plugin.description && (
|
{plugin.description && (
|
||||||
<p className="text-sm text-muted-foreground">{plugin.description}</p>
|
<p className="text-sm text-muted-foreground">{plugin.description}</p>
|
||||||
@@ -1157,7 +1167,8 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Files sekmesinden dosyayı önce sunucuya yükleyin. Relative path girerseniz oyunun varsayılan plugin dizinine göre çözülür.
|
Files sekmesinden dosyayı önce sunucuya yükleyin. Relative path girerseniz oyunun
|
||||||
|
varsayılan plugin dizinine göre çözülür.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -63,30 +63,25 @@ export function SchedulesPage() {
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['schedules', orgId, serverId],
|
queryKey: ['schedules', orgId, serverId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ tasks: ScheduledTask[] }>(
|
api.get<{ tasks: ScheduledTask[] }>(`/organizations/${orgId}/servers/${serverId}/schedules`),
|
||||||
`/organizations/${orgId}/servers/${serverId}/schedules`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (taskId: string) =>
|
mutationFn: (taskId: string) =>
|
||||||
api.delete(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`),
|
api.delete(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`),
|
||||||
onSuccess: () =>
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const triggerMutation = useMutation({
|
const triggerMutation = useMutation({
|
||||||
mutationFn: (taskId: string) =>
|
mutationFn: (taskId: string) =>
|
||||||
api.post(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}/trigger`, {}),
|
api.post(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}/trigger`, {}),
|
||||||
onSuccess: () =>
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: ({ taskId, isActive }: { taskId: string; isActive: boolean }) =>
|
mutationFn: ({ taskId, isActive }: { taskId: string; isActive: boolean }) =>
|
||||||
api.patch(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`, { isActive }),
|
api.patch(`/organizations/${orgId}/servers/${serverId}/schedules/${taskId}`, { isActive }),
|
||||||
onSuccess: () =>
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['schedules', orgId, serverId] }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasks = data?.tasks ?? [];
|
const tasks = data?.tasks ?? [];
|
||||||
@@ -150,14 +145,10 @@ export function SchedulesPage() {
|
|||||||
{formatSchedule(task.scheduleType, task.scheduleData)}
|
{formatSchedule(task.scheduleType, task.scheduleData)}
|
||||||
</span>
|
</span>
|
||||||
{task.nextRunAt && (
|
{task.nextRunAt && (
|
||||||
<span>
|
<span>Next: {new Date(task.nextRunAt).toLocaleString()}</span>
|
||||||
Next: {new Date(task.nextRunAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{task.lastRunAt && (
|
{task.lastRunAt && (
|
||||||
<span>
|
<span>Last: {new Date(task.lastRunAt).toLocaleString()}</span>
|
||||||
Last: {new Date(task.lastRunAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{task.action === 'command' && (
|
{task.action === 'command' && (
|
||||||
@@ -188,11 +179,7 @@ export function SchedulesPage() {
|
|||||||
}
|
}
|
||||||
title={task.isActive ? 'Pause' : 'Resume'}
|
title={task.isActive ? 'Pause' : 'Resume'}
|
||||||
>
|
>
|
||||||
{task.isActive ? (
|
{task.isActive ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
<Pause className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -243,7 +230,9 @@ function CreateScheduleForm({
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [action, setAction] = useState<'command' | 'power' | 'backup'>('command');
|
const [action, setAction] = useState<'command' | 'power' | 'backup'>('command');
|
||||||
const [payload, setPayload] = useState('');
|
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
|
// Schedule data fields
|
||||||
const [minutes, setMinutes] = useState('60');
|
const [minutes, setMinutes] = useState('60');
|
||||||
@@ -268,7 +257,11 @@ function CreateScheduleForm({
|
|||||||
case 'daily':
|
case 'daily':
|
||||||
return { hour: parseInt(hour, 10), minute: parseInt(minute, 10) };
|
return { hour: parseInt(hour, 10), minute: parseInt(minute, 10) };
|
||||||
case 'weekly':
|
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':
|
case 'cron':
|
||||||
return { expression: cronExpression };
|
return { expression: cronExpression };
|
||||||
}
|
}
|
||||||
@@ -301,7 +294,9 @@ function CreateScheduleForm({
|
|||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Action</Label>
|
<Label>Action</Label>
|
||||||
<Select value={action} onValueChange={(v) => setAction(v as typeof action)}>
|
<Select value={action} onValueChange={(v) => setAction(v as typeof action)}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="command">Run Command</SelectItem>
|
<SelectItem value="command">Run Command</SelectItem>
|
||||||
<SelectItem value="power">Power Action</SelectItem>
|
<SelectItem value="power">Power Action</SelectItem>
|
||||||
@@ -322,7 +317,9 @@ function CreateScheduleForm({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Select value={payload} onValueChange={setPayload}>
|
<Select value={payload} onValueChange={setPayload}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select..." />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="start">Start</SelectItem>
|
<SelectItem value="start">Start</SelectItem>
|
||||||
<SelectItem value="stop">Stop</SelectItem>
|
<SelectItem value="stop">Stop</SelectItem>
|
||||||
@@ -337,8 +334,13 @@ function CreateScheduleForm({
|
|||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Schedule Type</Label>
|
<Label>Schedule Type</Label>
|
||||||
<Select value={scheduleType} onValueChange={(v) => setScheduleType(v as typeof scheduleType)}>
|
<Select
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
value={scheduleType}
|
||||||
|
onValueChange={(v) => setScheduleType(v as typeof scheduleType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="interval">Interval</SelectItem>
|
<SelectItem value="interval">Interval</SelectItem>
|
||||||
<SelectItem value="daily">Daily</SelectItem>
|
<SelectItem value="daily">Daily</SelectItem>
|
||||||
@@ -366,10 +368,14 @@ function CreateScheduleForm({
|
|||||||
<div className="col-span-2 grid gap-1.5">
|
<div className="col-span-2 grid gap-1.5">
|
||||||
<Label>Day of Week</Label>
|
<Label>Day of Week</Label>
|
||||||
<Select value={dayOfWeek} onValueChange={setDayOfWeek}>
|
<Select value={dayOfWeek} onValueChange={setDayOfWeek}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DAYS_OF_WEEK.map((day, i) => (
|
{DAYS_OF_WEEK.map((day, i) => (
|
||||||
<SelectItem key={day} value={String(i)}>{day}</SelectItem>
|
<SelectItem key={day} value={String(i)}>
|
||||||
|
{day}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
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';
|
||||||
import { formatBytes } from '@/lib/utils';
|
import { formatBytes } from '@/lib/utils';
|
||||||
|
|
||||||
interface ServerDetail {
|
interface ServerDetail {
|
||||||
@@ -244,9 +250,13 @@ export function ServerSettingsPage() {
|
|||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [startupOverride, setStartupOverride] = useState('');
|
const [startupOverride, setStartupOverride] = useState('');
|
||||||
const [environmentFields, setEnvironmentFields] = useState<EnvironmentField[]>([]);
|
const [environmentFields, setEnvironmentFields] = useState<EnvironmentField[]>([]);
|
||||||
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>('server.install.completed');
|
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>(
|
||||||
|
'server.install.completed',
|
||||||
|
);
|
||||||
const [forceAutomationRun, setForceAutomationRun] = useState(false);
|
const [forceAutomationRun, setForceAutomationRun] = useState(false);
|
||||||
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(null);
|
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const { data: gamesData } = useQuery({
|
const { data: gamesData } = useQuery({
|
||||||
queryKey: ['games'],
|
queryKey: ['games'],
|
||||||
@@ -290,7 +300,10 @@ export function ServerSettingsPage() {
|
|||||||
|
|
||||||
const automationRunMutation = useMutation({
|
const automationRunMutation = useMutation({
|
||||||
mutationFn: (body: { event: AutomationEvent; force: boolean }) =>
|
mutationFn: (body: { event: AutomationEvent; force: boolean }) =>
|
||||||
api.post<AutomationRunResponse>(`/organizations/${orgId}/servers/${serverId}/automation/run`, body),
|
api.post<AutomationRunResponse>(
|
||||||
|
`/organizations/${orgId}/servers/${serverId}/automation/run`,
|
||||||
|
body,
|
||||||
|
),
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
setLastAutomationResult(response.result);
|
setLastAutomationResult(response.result);
|
||||||
if (response.result.workflowsFailed > 0 || response.result.actionFailures > 0) {
|
if (response.result.workflowsFailed > 0 || response.result.actionFailures > 0) {
|
||||||
@@ -442,7 +455,7 @@ export function ServerSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{environmentFields.map((field, index) => (
|
{environmentFields.map((field, index) =>
|
||||||
field.isCustom ? (
|
field.isCustom ? (
|
||||||
<div
|
<div
|
||||||
key={`custom-${index}`}
|
key={`custom-${index}`}
|
||||||
@@ -477,7 +490,8 @@ export function ServerSettingsPage() {
|
|||||||
{field.label}
|
{field.label}
|
||||||
</Label>
|
</Label>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
Default: <span className="font-mono">{field.defaultValue || 'empty'}</span>
|
Default:{' '}
|
||||||
|
<span className="font-mono">{field.defaultValue || 'empty'}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{field.inputType === 'boolean' ? (
|
{field.inputType === 'boolean' ? (
|
||||||
@@ -513,16 +527,13 @@ export function ServerSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button onClick={saveStartupSettings} disabled={updateMutation.isPending || !server}>
|
||||||
onClick={saveStartupSettings}
|
|
||||||
disabled={updateMutation.isPending || !server}
|
|
||||||
>
|
|
||||||
{updateMutation.isPending ? 'Applying...' : 'Save Startup Settings'}
|
{updateMutation.isPending ? 'Applying...' : 'Save Startup Settings'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -536,7 +547,10 @@ export function ServerSettingsPage() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Event</Label>
|
<Label>Event</Label>
|
||||||
<Select value={automationEvent} onValueChange={(value) => setAutomationEvent(value as AutomationEvent)}>
|
<Select
|
||||||
|
value={automationEvent}
|
||||||
|
onValueChange={(value) => setAutomationEvent(value as AutomationEvent)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -560,7 +574,9 @@ export function ServerSettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => automationRunMutation.mutate({ event: automationEvent, force: forceAutomationRun })}
|
onClick={() =>
|
||||||
|
automationRunMutation.mutate({ event: automationEvent, force: forceAutomationRun })
|
||||||
|
}
|
||||||
disabled={automationRunMutation.isPending}
|
disabled={automationRunMutation.isPending}
|
||||||
>
|
>
|
||||||
{automationRunMutation.isPending ? 'Running...' : 'Run Automation Event'}
|
{automationRunMutation.isPending ? 'Running...' : 'Run Automation Event'}
|
||||||
@@ -601,8 +617,12 @@ export function ServerSettingsPage() {
|
|||||||
<p className="text-sm font-medium text-destructive">Failure Details</p>
|
<p className="text-sm font-medium text-destructive">Failure Details</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{lastAutomationResult.failures.slice(0, 5).map((failure, index) => (
|
{lastAutomationResult.failures.slice(0, 5).map((failure, index) => (
|
||||||
<p key={`${failure.workflowId}-${failure.actionId ?? 'workflow'}-${index}`} className="text-xs text-destructive">
|
<p
|
||||||
[{failure.workflowId}{failure.actionId ? ` > ${failure.actionId}` : ''}] {failure.message}
|
key={`${failure.workflowId}-${failure.actionId ?? 'workflow'}-${index}`}
|
||||||
|
className="text-xs text-destructive"
|
||||||
|
>
|
||||||
|
[{failure.workflowId}
|
||||||
|
{failure.actionId ? ` > ${failure.actionId}` : ''}] {failure.message}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -611,20 +631,22 @@ export function ServerSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length === 0 && (
|
{automationRunMutation.isSuccess &&
|
||||||
<p className="text-xs text-green-600">Automation run completed successfully.</p>
|
lastAutomationResult &&
|
||||||
)}
|
lastAutomationResult.failures.length === 0 && (
|
||||||
|
<p className="text-xs text-green-600">Automation run completed successfully.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length > 0 && (
|
{automationRunMutation.isSuccess &&
|
||||||
<p className="text-xs text-destructive">
|
lastAutomationResult &&
|
||||||
Automation run completed with {lastAutomationResult.failures.length} error(s).
|
lastAutomationResult.failures.length > 0 && (
|
||||||
</p>
|
<p className="text-xs text-destructive">
|
||||||
)}
|
Automation run completed with {lastAutomationResult.failures.length} error(s).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{automationRunMutation.isError && (
|
{automationRunMutation.isError && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">Failed to run automation event.</p>
|
||||||
Failed to run automation event.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -639,7 +661,9 @@ export function ServerSettingsPage() {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!window.confirm('Delete this server permanently? This action cannot be undone.')) {
|
if (
|
||||||
|
!window.confirm('Delete this server permanently? This action cannot be undone.')
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deleteMutation.mutate();
|
deleteMutation.mutate();
|
||||||
|
|||||||
@@ -64,17 +64,47 @@ interface AdditionalPortRequirement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] {
|
function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] {
|
||||||
if (gameSlug.trim().toLowerCase() !== 'satisfactory') return [];
|
const slug = gameSlug.trim().toLowerCase();
|
||||||
|
|
||||||
return [
|
if (slug === 'satisfactory') {
|
||||||
{
|
return [
|
||||||
key: 'satisfactory-messaging',
|
{
|
||||||
label: 'Messaging Port',
|
key: 'satisfactory-messaging',
|
||||||
defaultPort: 8888,
|
label: 'Messaging Port',
|
||||||
protocols: ['tcp'],
|
defaultPort: 8888,
|
||||||
description: 'Required by the Satisfactory server messaging API.',
|
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() {
|
export function CreateServerPage() {
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} 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 {
|
interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -78,21 +84,14 @@ export function MembersPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (memberId: string) =>
|
mutationFn: (memberId: string) => api.delete(`/organizations/${orgId}/members/${memberId}`),
|
||||||
api.delete(`/organizations/${orgId}/members/${memberId}`),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['members', orgId] });
|
queryClient.invalidateQueries({ queryKey: ['members', orgId] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ memberId, preset }: { memberId: string; preset: MembershipPreset }) =>
|
||||||
memberId,
|
|
||||||
preset,
|
|
||||||
}: {
|
|
||||||
memberId: string;
|
|
||||||
preset: MembershipPreset;
|
|
||||||
}) =>
|
|
||||||
api.patch(`/organizations/${orgId}/members/${memberId}`, buildPresetPayload(preset)),
|
api.patch(`/organizations/${orgId}/members/${memberId}`, buildPresetPayload(preset)),
|
||||||
onMutate: ({ memberId }) => {
|
onMutate: ({ memberId }) => {
|
||||||
setUpdatingMemberId(memberId);
|
setUpdatingMemberId(memberId);
|
||||||
|
|||||||
@@ -4,9 +4,18 @@
|
|||||||
api_url: "http://api:3000"
|
api_url: "http://api:3000"
|
||||||
node_token: "CHANGE_ME_GENERATE_A_SECURE_TOKEN"
|
node_token: "CHANGE_ME_GENERATE_A_SECURE_TOKEN"
|
||||||
grpc_port: 50051
|
grpc_port: 50051
|
||||||
|
|
||||||
|
# Path inside the daemon container.
|
||||||
data_path: "/var/lib/gamepanel/servers"
|
data_path: "/var/lib/gamepanel/servers"
|
||||||
backup_path: "/var/lib/gamepanel/backups"
|
backup_path: "/var/lib/gamepanel/backups"
|
||||||
|
|
||||||
|
# The same directory as seen by the Docker host. Game containers are created
|
||||||
|
# through the host's Docker socket, so their bind mounts resolve against the
|
||||||
|
# host filesystem — if this does not match, the panel and the game server end
|
||||||
|
# up writing to two different directories. docker-compose.yml also supplies
|
||||||
|
# this as the DAEMON_HOST_DATA_PATH environment variable, which wins.
|
||||||
|
host_data_path: "/var/lib/gamepanel/servers"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
socket: "/var/run/docker.sock"
|
socket: "/var/run/docker.sock"
|
||||||
network: "gamepanel_nw"
|
network: "gamepanel_nw"
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# GamePanel — deployment from pre-built images.
|
||||||
|
#
|
||||||
|
# Unlike docker-compose.yml (which builds from source), every service here
|
||||||
|
# references a published image. That makes the stack deployable from a control
|
||||||
|
# panel that only writes a compose file plus an .env — WebPanel's "Custom
|
||||||
|
# Compose" screen, Portainer stacks, or a bare `docker compose up -d` on a
|
||||||
|
# server that has no checkout of this repository.
|
||||||
|
#
|
||||||
|
# Images are published by .github/workflows/ci.yml on every `v*` tag.
|
||||||
|
#
|
||||||
|
# REGISTRY=gits.hibna.com.tr/hibna TAG=v0.1.0 docker compose \
|
||||||
|
# -f docker-compose.panel.yml up -d
|
||||||
|
#
|
||||||
|
# Two files must exist on the host before the first start:
|
||||||
|
# /etc/gamepanel/daemon-config.yml — node_token must match DAEMON_TOKEN
|
||||||
|
# /var/lib/gamepanel/{servers,backups}
|
||||||
|
#
|
||||||
|
# Serves plain HTTP on ${HOST_PORT}. Put a reverse proxy in front of it for TLS
|
||||||
|
# and a domain; WebPanel does this for you when you install with a domain.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# --- PostgreSQL ---
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-gamepanel}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-gamepanel}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# --- Redis (rate limiting, session cache) ---
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:?set REDIS_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
expose:
|
||||||
|
- "6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# --- Schema migration + seed (runs to completion, then exits) ---
|
||||||
|
#
|
||||||
|
# A service that exits is not a failure here: `docker compose up --wait` —
|
||||||
|
# what WebPanel runs — treats a `service_completed_successfully` dependency
|
||||||
|
# correctly and reports the stack as healthy once api and web are up.
|
||||||
|
migrate:
|
||||||
|
image: ${REGISTRY:?set REGISTRY}/gamepanel-migrate:${TAG:?set TAG}
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-gamepanel}
|
||||||
|
|
||||||
|
# --- API ---
|
||||||
|
api:
|
||||||
|
image: ${REGISTRY}/gamepanel-api:${TAG}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-gamepanel}
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
|
PORT: 3000
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET}
|
||||||
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?set JWT_REFRESH_SECRET}
|
||||||
|
# Must match the address browsers use, otherwise the SPA's requests are
|
||||||
|
# rejected by CORS.
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:8096}
|
||||||
|
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-100}
|
||||||
|
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-60000}
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
|
||||||
|
# --- Web (nginx + SPA, also reverse-proxies /api and /socket.io) ---
|
||||||
|
#
|
||||||
|
# The published port is named HOST_PORT on purpose: WebPanel picks the port
|
||||||
|
# to reverse-proxy from that name. With two differently named *_PORT values
|
||||||
|
# and no HOST_PORT it cannot tell which one to publish and refuses to bind a
|
||||||
|
# domain.
|
||||||
|
web:
|
||||||
|
image: ${REGISTRY}/gamepanel-web:${TAG}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-8096}:80"
|
||||||
|
|
||||||
|
# --- Daemon ---
|
||||||
|
#
|
||||||
|
# Single-host setup: the API reaches the daemon over the compose network as
|
||||||
|
# `daemon:50051`, so nothing needs to be published. For a *remote* node, run
|
||||||
|
# this service on that machine instead and publish 50051 there.
|
||||||
|
#
|
||||||
|
# This container controls the host's Docker engine through the socket below.
|
||||||
|
# That is root-equivalent access to every container on the machine, panel
|
||||||
|
# containers included — prefer a dedicated node for the daemon.
|
||||||
|
daemon:
|
||||||
|
image: ${REGISTRY}/gamepanel-daemon:${TAG}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
environment:
|
||||||
|
DAEMON_CONFIG: /etc/gamepanel/config.yml
|
||||||
|
# Game containers are created through the host's Docker socket, so their
|
||||||
|
# bind mounts are resolved by the *host*, not by this container.
|
||||||
|
DAEMON_HOST_DATA_PATH: ${DAEMON_DATA_PATH:-/var/lib/gamepanel/servers}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ${DAEMON_DATA_PATH:-/var/lib/gamepanel/servers}:/var/lib/gamepanel/servers
|
||||||
|
- ${DAEMON_BACKUP_PATH:-/var/lib/gamepanel/backups}:/var/lib/gamepanel/backups
|
||||||
|
# Absolute path: a panel-managed deployment has no checkout of this repo,
|
||||||
|
# so the relative ./daemon-config.yml of docker-compose.yml is not there.
|
||||||
|
- ${DAEMON_CONFIG_FILE:-/etc/gamepanel/daemon-config.yml}:/etc/gamepanel/config.yml:ro
|
||||||
|
expose:
|
||||||
|
- "50051"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
+42
-14
@@ -1,3 +1,12 @@
|
|||||||
|
# GamePanel — single-host deployment.
|
||||||
|
#
|
||||||
|
# ./scripts/install.sh generates .env with fresh secrets
|
||||||
|
# docker compose up -d builds and starts everything
|
||||||
|
#
|
||||||
|
# Serves plain HTTP on ${WEB_PORT} by design. Put your own reverse proxy
|
||||||
|
# (Caddy, nginx, Traefik, Cloudflare Tunnel…) in front of it for TLS and a
|
||||||
|
# domain — see INSTALLATION.md.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# --- PostgreSQL ---
|
# --- PostgreSQL ---
|
||||||
postgres:
|
postgres:
|
||||||
@@ -10,8 +19,9 @@ services:
|
|||||||
POSTGRES_DB: ${DB_NAME:-gamepanel}
|
POSTGRES_DB: ${DB_NAME:-gamepanel}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
ports:
|
# Not published by default — only the API needs it. Set DB_PORT to expose it.
|
||||||
- "${DB_PORT:-5432}:5432"
|
expose:
|
||||||
|
- "5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -26,14 +36,28 @@ services:
|
|||||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-gamepanel}
|
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-gamepanel}
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
ports:
|
expose:
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
- "6379"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-gamepanel}", "ping"]
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-gamepanel}", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# --- Schema migration + seed (runs to completion, then exits) ---
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
|
target: migrate
|
||||||
|
container_name: gamepanel-migrate
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD:-gamepanel}@postgres:5432/${DB_NAME:-gamepanel}
|
||||||
|
|
||||||
# --- API ---
|
# --- API ---
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
@@ -46,21 +70,23 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD:-gamepanel}@postgres:5432/${DB_NAME:-gamepanel}
|
DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD:-gamepanel}@postgres:5432/${DB_NAME:-gamepanel}
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD:-gamepanel}@redis:6379
|
REDIS_URL: redis://:${REDIS_PASSWORD:-gamepanel}@redis:6379
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env — run ./scripts/install.sh}
|
||||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?set JWT_REFRESH_SECRET in .env — run ./scripts/install.sh}
|
||||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost}
|
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost}
|
||||||
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-100}
|
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-100}
|
||||||
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-60000}
|
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-60000}
|
||||||
ports:
|
expose:
|
||||||
- "${API_PORT:-3000}:3000"
|
- "3000"
|
||||||
|
|
||||||
# --- Web (nginx + SPA) ---
|
# --- Web (nginx + SPA, also reverse-proxies /api and /socket.io) ---
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -83,13 +109,17 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
privileged: true
|
|
||||||
environment:
|
environment:
|
||||||
DAEMON_CONFIG: /etc/gamepanel/config.yml
|
DAEMON_CONFIG: /etc/gamepanel/config.yml
|
||||||
|
# Game containers are created through the host's Docker socket, so their
|
||||||
|
# bind mounts are resolved by the *host*, not by this container. Without
|
||||||
|
# this the daemon and the game would end up looking at two different
|
||||||
|
# directories and every file written from the panel would vanish.
|
||||||
|
DAEMON_HOST_DATA_PATH: ${DAEMON_DATA_PATH:-/var/lib/gamepanel/servers}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- daemon_data:/var/lib/gamepanel/servers
|
- ${DAEMON_DATA_PATH:-/var/lib/gamepanel/servers}:/var/lib/gamepanel/servers
|
||||||
- daemon_backups:/var/lib/gamepanel/backups
|
- ${DAEMON_BACKUP_PATH:-/var/lib/gamepanel/backups}:/var/lib/gamepanel/backups
|
||||||
- ./daemon-config.yml:/etc/gamepanel/config.yml:ro
|
- ./daemon-config.yml:/etc/gamepanel/config.yml:ro
|
||||||
ports:
|
ports:
|
||||||
- "${DAEMON_GRPC_PORT:-50051}:50051"
|
- "${DAEMON_GRPC_PORT:-50051}:50051"
|
||||||
@@ -97,5 +127,3 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
daemon_data:
|
|
||||||
daemon_backups:
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
-- Per-game shutdown controls and container mount overrides.
|
||||||
|
ALTER TABLE "games"
|
||||||
|
ADD COLUMN IF NOT EXISTS "stop_timeout_seconds" integer NOT NULL DEFAULT 30;
|
||||||
|
|
||||||
|
ALTER TABLE "games"
|
||||||
|
ADD COLUMN IF NOT EXISTS "container_data_path" text;
|
||||||
|
|
||||||
|
-- Mount points the daemon previously derived from the image name. Storing them
|
||||||
|
-- makes the mapping visible and editable instead of hardcoded.
|
||||||
|
UPDATE "games" SET "container_data_path" = '/home/steam/cs2-dedicated' WHERE "slug" = 'cs2' AND "container_data_path" IS NULL;
|
||||||
|
UPDATE "games" SET "container_data_path" = '/config' WHERE "slug" IN ('fivem', 'satisfactory') AND "container_data_path" IS NULL;
|
||||||
|
UPDATE "games" SET "container_data_path" = '/data' WHERE "slug" IN ('minecraft-java', 'minecraft-bedrock', 'terraria', 'rust') AND "container_data_path" IS NULL;
|
||||||
|
|
||||||
|
-- Shutdown budgets. Source servers quit instantly once they get the command;
|
||||||
|
-- ARK and Satisfactory need to flush a world save first.
|
||||||
|
UPDATE "games" SET "stop_timeout_seconds" = 60 WHERE "slug" IN ('minecraft-java', 'minecraft-bedrock');
|
||||||
|
UPDATE "games" SET "stop_timeout_seconds" = 120 WHERE "slug" = 'satisfactory';
|
||||||
|
|
||||||
|
-- ARK: Survival Evolved
|
||||||
|
INSERT INTO "games" (
|
||||||
|
"slug",
|
||||||
|
"name",
|
||||||
|
"docker_image",
|
||||||
|
"default_port",
|
||||||
|
"config_files",
|
||||||
|
"automation_rules",
|
||||||
|
"startup_command",
|
||||||
|
"stop_command",
|
||||||
|
"stop_timeout_seconds",
|
||||||
|
"container_data_path",
|
||||||
|
"environment_vars",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'ark-se',
|
||||||
|
'ARK: Survival Evolved',
|
||||||
|
'hermsi/ark-server:latest',
|
||||||
|
7777,
|
||||||
|
'[
|
||||||
|
{
|
||||||
|
"path": "server/ShooterGame/Saved/Config/LinuxServer/GameUserSettings.ini",
|
||||||
|
"parser": "properties",
|
||||||
|
"editableKeys": [
|
||||||
|
"SessionName",
|
||||||
|
"ServerPassword",
|
||||||
|
"ServerAdminPassword",
|
||||||
|
"MaxPlayers",
|
||||||
|
"ServerPVE",
|
||||||
|
"ServerCrosshair",
|
||||||
|
"ServerHardcore",
|
||||||
|
"AllowThirdPersonPlayer",
|
||||||
|
"ShowMapPlayerLocation",
|
||||||
|
"GlobalVoiceChat",
|
||||||
|
"ProximityChat",
|
||||||
|
"NoTributeDownloads",
|
||||||
|
"AllowAnyoneBabyImprintCuddle",
|
||||||
|
"DifficultyOffset",
|
||||||
|
"XPMultiplier",
|
||||||
|
"TamingSpeedMultiplier",
|
||||||
|
"HarvestAmountMultiplier",
|
||||||
|
"DayCycleSpeedScale",
|
||||||
|
"NightTimeSpeedScale",
|
||||||
|
"PlayerCharacterWaterDrainMultiplier",
|
||||||
|
"PlayerCharacterFoodDrainMultiplier",
|
||||||
|
"StructureDamageMultiplier",
|
||||||
|
"StructureResistanceMultiplier",
|
||||||
|
"RCONEnabled",
|
||||||
|
"RCONPort"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "server/ShooterGame/Saved/Config/LinuxServer/Game.ini",
|
||||||
|
"parser": "properties"
|
||||||
|
}
|
||||||
|
]'::jsonb,
|
||||||
|
'[]'::jsonb,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
300,
|
||||||
|
'/app',
|
||||||
|
'[
|
||||||
|
{
|
||||||
|
"key": "SESSION_NAME",
|
||||||
|
"default": "SourceGamePanel ARK Server",
|
||||||
|
"description": "Server name shown in the ARK server browser",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "SERVER_MAP",
|
||||||
|
"default": "TheIsland",
|
||||||
|
"description": "Map to load (TheIsland, TheCenter, Ragnarok, Valguero, CrystalIsles, ...)",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ADMIN_PASSWORD",
|
||||||
|
"default": "",
|
||||||
|
"description": "Admin/RCON password. Required — RCON console commands use it.",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "SERVER_PASSWORD",
|
||||||
|
"default": "",
|
||||||
|
"description": "Password players need to join. Leave empty for a public server.",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "MAX_PLAYERS",
|
||||||
|
"default": "20",
|
||||||
|
"description": "Maximum player count",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "UPDATE_ON_START",
|
||||||
|
"label": "Update on start",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Run a SteamCMD update every time the server starts",
|
||||||
|
"required": false,
|
||||||
|
"inputType": "boolean",
|
||||||
|
"enabledLabel": "Aktif",
|
||||||
|
"disabledLabel": "Pasif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "BACKUP_ON_STOP",
|
||||||
|
"label": "Backup on stop",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Create a world backup during shutdown (makes stopping slower)",
|
||||||
|
"required": false,
|
||||||
|
"inputType": "boolean",
|
||||||
|
"enabledLabel": "Aktif",
|
||||||
|
"disabledLabel": "Pasif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "WARN_ON_STOP",
|
||||||
|
"label": "Warn players on stop",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Broadcast a shutdown warning to connected players before stopping",
|
||||||
|
"required": false,
|
||||||
|
"inputType": "boolean",
|
||||||
|
"enabledLabel": "Aktif",
|
||||||
|
"disabledLabel": "Pasif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "PRE_UPDATE_BACKUP",
|
||||||
|
"label": "Backup before update",
|
||||||
|
"default": "true",
|
||||||
|
"description": "Back the world up before applying a SteamCMD update",
|
||||||
|
"required": false,
|
||||||
|
"inputType": "boolean",
|
||||||
|
"enabledLabel": "Aktif",
|
||||||
|
"disabledLabel": "Pasif"
|
||||||
|
}
|
||||||
|
]'::jsonb,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT ("slug") DO NOTHING;
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"db:generate": "dotenv -e ../../.env -- drizzle-kit generate",
|
"db:generate": "dotenv -e ../../.env -- drizzle-kit generate",
|
||||||
"db:migrate": "dotenv -e ../../.env -- drizzle-kit migrate",
|
"db:push": "dotenv -e ../../.env -- drizzle-kit push --force",
|
||||||
|
"db:migrate": "pnpm db:push && dotenv -e ../../.env -- tsx src/migrate.ts",
|
||||||
|
"db:migrate:data": "dotenv -e ../../.env -- tsx src/migrate.ts",
|
||||||
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
|
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
|
||||||
"db:studio": "dotenv -e ../../.env -- drizzle-kit studio"
|
"db:studio": "dotenv -e ../../.env -- drizzle-kit studio"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the hand-written data migrations in `drizzle/*.sql`.
|
||||||
|
*
|
||||||
|
* Table structure itself comes from `drizzle-kit push`, which diffs the live
|
||||||
|
* database against `src/schema` — that keeps a fresh install working without
|
||||||
|
* shipping a full generated migration chain. These SQL files carry the data
|
||||||
|
* changes push cannot know about (default game rows, automation rule updates).
|
||||||
|
*
|
||||||
|
* Every file is recorded in `gamepanel_data_migrations`, so re-running is safe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MIGRATIONS_TABLE = 'gamepanel_data_migrations';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.error('DATABASE_URL is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'drizzle');
|
||||||
|
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = (await readdir(migrationsDir)).filter((file) => file.endsWith('.sql')).sort();
|
||||||
|
} catch {
|
||||||
|
console.log('No data migrations directory found, nothing to apply.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log('No data migrations to apply.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = postgres(databaseUrl, { max: 1 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql.unsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
||||||
|
name text PRIMARY KEY,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const applied = await sql.unsafe<{ name: string }[]>(`SELECT name FROM ${MIGRATIONS_TABLE}`);
|
||||||
|
const appliedNames = new Set(applied.map((row) => row.name));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (appliedNames.has(file)) continue;
|
||||||
|
|
||||||
|
const contents = await readFile(path.join(migrationsDir, file), 'utf8');
|
||||||
|
if (!contents.trim()) continue;
|
||||||
|
|
||||||
|
console.log(`Applying data migration: ${file}`);
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
await tx.unsafe(contents);
|
||||||
|
await tx.unsafe(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`, [file]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Data migrations up to date.');
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Data migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -10,6 +10,11 @@ export const games = pgTable('games', {
|
|||||||
automationRules: jsonb('automation_rules').default([]).notNull(),
|
automationRules: jsonb('automation_rules').default([]).notNull(),
|
||||||
startupCommand: text('startup_command').notNull(),
|
startupCommand: text('startup_command').notNull(),
|
||||||
stopCommand: text('stop_command'),
|
stopCommand: text('stop_command'),
|
||||||
|
/// Total budget for a graceful shutdown before the container is killed.
|
||||||
|
stopTimeoutSeconds: integer('stop_timeout_seconds').default(30).notNull(),
|
||||||
|
/// Mount point of the server data directory inside the container. Null means
|
||||||
|
/// "let the daemon derive it from the image".
|
||||||
|
containerDataPath: text('container_data_path'),
|
||||||
environmentVars: jsonb('environment_vars').default([]).notNull(),
|
environmentVars: jsonb('environment_vars').default([]).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
|||||||
@@ -14,9 +14,20 @@ import { servers } from './servers';
|
|||||||
import { users } from './users';
|
import { users } from './users';
|
||||||
|
|
||||||
export const pluginSourceEnum = pgEnum('plugin_source', ['spiget', 'manual']);
|
export const pluginSourceEnum = pgEnum('plugin_source', ['spiget', 'manual']);
|
||||||
export const pluginReleaseChannelEnum = pgEnum('plugin_release_channel', ['stable', 'beta', 'alpha']);
|
export const pluginReleaseChannelEnum = pgEnum('plugin_release_channel', [
|
||||||
export const pluginReleaseArtifactTypeEnum = pgEnum('plugin_release_artifact_type', ['file', 'zip']);
|
'stable',
|
||||||
export const pluginInstallStatusEnum = pgEnum('plugin_install_status', ['installed', 'updating', 'failed']);
|
'beta',
|
||||||
|
'alpha',
|
||||||
|
]);
|
||||||
|
export const pluginReleaseArtifactTypeEnum = pgEnum('plugin_release_artifact_type', [
|
||||||
|
'file',
|
||||||
|
'zip',
|
||||||
|
]);
|
||||||
|
export const pluginInstallStatusEnum = pgEnum('plugin_install_status', [
|
||||||
|
'installed',
|
||||||
|
'updating',
|
||||||
|
'failed',
|
||||||
|
]);
|
||||||
|
|
||||||
export const plugins = pgTable('plugins', {
|
export const plugins = pgTable('plugins', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ import { servers } from './servers';
|
|||||||
|
|
||||||
export const scheduleActionEnum = pgEnum('schedule_action', ['command', 'power', 'backup']);
|
export const scheduleActionEnum = pgEnum('schedule_action', ['command', 'power', 'backup']);
|
||||||
|
|
||||||
export const scheduleTypeEnum = pgEnum('schedule_type', [
|
export const scheduleTypeEnum = pgEnum('schedule_type', ['interval', 'daily', 'weekly', 'cron']);
|
||||||
'interval',
|
|
||||||
'daily',
|
|
||||||
'weekly',
|
|
||||||
'cron',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const scheduledTasks = pgTable('scheduled_tasks', {
|
export const scheduledTasks = pgTable('scheduled_tasks', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { pgTable, uuid, varchar, text, integer, timestamp } from 'drizzle-orm/pg-core';
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
varchar,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
timestamp,
|
|
||||||
} from 'drizzle-orm/pg-core';
|
|
||||||
import { servers } from './servers';
|
import { servers } from './servers';
|
||||||
|
|
||||||
export const serverDatabases = pgTable('server_databases', {
|
export const serverDatabases = pgTable('server_databases', {
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ async function seed() {
|
|||||||
defaultPort: 25565,
|
defaultPort: 25565,
|
||||||
startupCommand: '/start',
|
startupCommand: '/start',
|
||||||
stopCommand: 'stop',
|
stopCommand: 'stop',
|
||||||
|
stopTimeoutSeconds: 60,
|
||||||
|
containerDataPath: '/data',
|
||||||
configFiles: [
|
configFiles: [
|
||||||
{
|
{
|
||||||
path: 'server.properties',
|
path: 'server.properties',
|
||||||
@@ -147,6 +149,8 @@ async function seed() {
|
|||||||
defaultPort: 27015,
|
defaultPort: 27015,
|
||||||
startupCommand: '',
|
startupCommand: '',
|
||||||
stopCommand: 'quit',
|
stopCommand: 'quit',
|
||||||
|
stopTimeoutSeconds: 30,
|
||||||
|
containerDataPath: '/home/steam/cs2-dedicated',
|
||||||
configFiles: [
|
configFiles: [
|
||||||
{
|
{
|
||||||
path: 'game/csgo/cfg/server.cfg',
|
path: 'game/csgo/cfg/server.cfg',
|
||||||
@@ -332,6 +336,8 @@ async function seed() {
|
|||||||
defaultPort: 19132,
|
defaultPort: 19132,
|
||||||
startupCommand: '',
|
startupCommand: '',
|
||||||
stopCommand: 'stop',
|
stopCommand: 'stop',
|
||||||
|
stopTimeoutSeconds: 60,
|
||||||
|
containerDataPath: '/data',
|
||||||
configFiles: [
|
configFiles: [
|
||||||
{
|
{
|
||||||
path: 'server.properties',
|
path: 'server.properties',
|
||||||
@@ -366,6 +372,8 @@ async function seed() {
|
|||||||
defaultPort: 7777,
|
defaultPort: 7777,
|
||||||
startupCommand: '',
|
startupCommand: '',
|
||||||
stopCommand: 'exit',
|
stopCommand: 'exit',
|
||||||
|
stopTimeoutSeconds: 45,
|
||||||
|
containerDataPath: '/data',
|
||||||
configFiles: [
|
configFiles: [
|
||||||
{
|
{
|
||||||
path: 'serverconfig.txt',
|
path: 'serverconfig.txt',
|
||||||
@@ -391,6 +399,8 @@ async function seed() {
|
|||||||
defaultPort: 28015,
|
defaultPort: 28015,
|
||||||
startupCommand: '',
|
startupCommand: '',
|
||||||
stopCommand: 'quit',
|
stopCommand: 'quit',
|
||||||
|
stopTimeoutSeconds: 60,
|
||||||
|
containerDataPath: '/data',
|
||||||
configFiles: [],
|
configFiles: [],
|
||||||
environmentVars: [
|
environmentVars: [
|
||||||
{
|
{
|
||||||
@@ -421,6 +431,8 @@ async function seed() {
|
|||||||
defaultPort: 7777,
|
defaultPort: 7777,
|
||||||
startupCommand: '',
|
startupCommand: '',
|
||||||
stopCommand: 'quit',
|
stopCommand: 'quit',
|
||||||
|
stopTimeoutSeconds: 120,
|
||||||
|
containerDataPath: '/config',
|
||||||
configFiles: [],
|
configFiles: [],
|
||||||
automationRules: [],
|
automationRules: [],
|
||||||
environmentVars: [
|
environmentVars: [
|
||||||
@@ -461,6 +473,8 @@ async function seed() {
|
|||||||
defaultPort: 30120,
|
defaultPort: 30120,
|
||||||
startupCommand: '',
|
startupCommand: '',
|
||||||
stopCommand: 'quit',
|
stopCommand: 'quit',
|
||||||
|
stopTimeoutSeconds: 30,
|
||||||
|
containerDataPath: '/config',
|
||||||
configFiles: [
|
configFiles: [
|
||||||
{
|
{
|
||||||
path: 'server.cfg',
|
path: 'server.cfg',
|
||||||
@@ -486,6 +500,129 @@ async function seed() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: 'ark-se',
|
||||||
|
name: 'ARK: Survival Evolved',
|
||||||
|
dockerImage: 'hermsi/ark-server:latest',
|
||||||
|
defaultPort: 7777,
|
||||||
|
startupCommand: '',
|
||||||
|
// The image traps SIGTERM and runs `arkmanager stop --saveworld`, so a
|
||||||
|
// long SIGTERM budget is the correct shutdown path here.
|
||||||
|
stopCommand: '',
|
||||||
|
stopTimeoutSeconds: 300,
|
||||||
|
containerDataPath: '/app',
|
||||||
|
configFiles: [
|
||||||
|
{
|
||||||
|
path: 'server/ShooterGame/Saved/Config/LinuxServer/GameUserSettings.ini',
|
||||||
|
parser: 'properties',
|
||||||
|
editableKeys: [
|
||||||
|
'SessionName',
|
||||||
|
'ServerPassword',
|
||||||
|
'ServerAdminPassword',
|
||||||
|
'MaxPlayers',
|
||||||
|
'ServerPVE',
|
||||||
|
'ServerCrosshair',
|
||||||
|
'ServerHardcore',
|
||||||
|
'AllowThirdPersonPlayer',
|
||||||
|
'ShowMapPlayerLocation',
|
||||||
|
'GlobalVoiceChat',
|
||||||
|
'ProximityChat',
|
||||||
|
'NoTributeDownloads',
|
||||||
|
'AllowAnyoneBabyImprintCuddle',
|
||||||
|
'DifficultyOffset',
|
||||||
|
'XPMultiplier',
|
||||||
|
'TamingSpeedMultiplier',
|
||||||
|
'HarvestAmountMultiplier',
|
||||||
|
'DayCycleSpeedScale',
|
||||||
|
'NightTimeSpeedScale',
|
||||||
|
'PlayerCharacterWaterDrainMultiplier',
|
||||||
|
'PlayerCharacterFoodDrainMultiplier',
|
||||||
|
'StructureDamageMultiplier',
|
||||||
|
'StructureResistanceMultiplier',
|
||||||
|
'RCONEnabled',
|
||||||
|
'RCONPort',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'server/ShooterGame/Saved/Config/LinuxServer/Game.ini',
|
||||||
|
parser: 'properties',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
automationRules: [],
|
||||||
|
environmentVars: [
|
||||||
|
{
|
||||||
|
key: 'SESSION_NAME',
|
||||||
|
default: 'SourceGamePanel ARK Server',
|
||||||
|
description: 'Server name shown in the ARK server browser',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SERVER_MAP',
|
||||||
|
default: 'TheIsland',
|
||||||
|
description:
|
||||||
|
'Map to load (TheIsland, TheCenter, Ragnarok, Valguero, CrystalIsles, ...)',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ADMIN_PASSWORD',
|
||||||
|
default: '',
|
||||||
|
description: 'Admin/RCON password. Required — RCON console commands use it.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SERVER_PASSWORD',
|
||||||
|
default: '',
|
||||||
|
description: 'Password players need to join. Leave empty for a public server.',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'MAX_PLAYERS',
|
||||||
|
default: '20',
|
||||||
|
description: 'Maximum player count',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'UPDATE_ON_START',
|
||||||
|
label: 'Update on start',
|
||||||
|
default: 'false',
|
||||||
|
description: 'Run a SteamCMD update every time the server starts',
|
||||||
|
required: false,
|
||||||
|
inputType: 'boolean',
|
||||||
|
enabledLabel: 'Aktif',
|
||||||
|
disabledLabel: 'Pasif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'BACKUP_ON_STOP',
|
||||||
|
label: 'Backup on stop',
|
||||||
|
default: 'false',
|
||||||
|
description: 'Create a world backup during shutdown (makes stopping slower)',
|
||||||
|
required: false,
|
||||||
|
inputType: 'boolean',
|
||||||
|
enabledLabel: 'Aktif',
|
||||||
|
disabledLabel: 'Pasif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'WARN_ON_STOP',
|
||||||
|
label: 'Warn players on stop',
|
||||||
|
default: 'false',
|
||||||
|
description: 'Broadcast a shutdown warning to connected players before stopping',
|
||||||
|
required: false,
|
||||||
|
inputType: 'boolean',
|
||||||
|
enabledLabel: 'Aktif',
|
||||||
|
disabledLabel: 'Pasif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'PRE_UPDATE_BACKUP',
|
||||||
|
label: 'Backup before update',
|
||||||
|
default: 'true',
|
||||||
|
description: 'Back the world up before applying a SteamCMD update',
|
||||||
|
required: false,
|
||||||
|
inputType: 'boolean',
|
||||||
|
enabledLabel: 'Aktif',
|
||||||
|
disabledLabel: 'Pasif',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
])
|
])
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ message CreateServerRequest {
|
|||||||
map<string, string> environment = 7;
|
map<string, string> environment = 7;
|
||||||
repeated PortMapping ports = 8;
|
repeated PortMapping ports = 8;
|
||||||
repeated string install_plugin_urls = 9;
|
repeated string install_plugin_urls = 9;
|
||||||
|
// Mount point of the server data directory inside the container.
|
||||||
|
// Empty means "derive from the image".
|
||||||
|
string data_path = 10;
|
||||||
|
// In-game command that shuts the server down cleanly (e.g. "stop", "quit").
|
||||||
|
string stop_command = 11;
|
||||||
|
// Total budget for a graceful shutdown before the container is killed.
|
||||||
|
int32 stop_timeout_seconds = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UpdateServerRequest {
|
message UpdateServerRequest {
|
||||||
@@ -55,6 +62,9 @@ message UpdateServerRequest {
|
|||||||
string startup_command = 6;
|
string startup_command = 6;
|
||||||
map<string, string> environment = 7;
|
map<string, string> environment = 7;
|
||||||
repeated PortMapping ports = 8;
|
repeated PortMapping ports = 8;
|
||||||
|
string data_path = 9;
|
||||||
|
string stop_command = 10;
|
||||||
|
int32 stop_timeout_seconds = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ServerResponse {
|
message ServerResponse {
|
||||||
@@ -106,6 +116,10 @@ enum PowerAction {
|
|||||||
message PowerRequest {
|
message PowerRequest {
|
||||||
string uuid = 1;
|
string uuid = 1;
|
||||||
PowerAction action = 2;
|
PowerAction action = 2;
|
||||||
|
// Optional per-request overrides. When empty the daemon falls back to the
|
||||||
|
// values captured when the container was created.
|
||||||
|
string stop_command = 3;
|
||||||
|
int32 stop_timeout_seconds = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Server Status ===
|
// === Server Status ===
|
||||||
|
|||||||
@@ -4,7 +4,5 @@
|
|||||||
const moduleUrl = (import.meta as ImportMeta & { url: string }).url;
|
const moduleUrl = (import.meta as ImportMeta & { url: string }).url;
|
||||||
|
|
||||||
export const PROTO_PATH = decodeURIComponent(
|
export const PROTO_PATH = decodeURIComponent(
|
||||||
moduleUrl
|
moduleUrl.replace(/^file:\/\//, '').replace(/\/src\/index\.(ts|js)$/, '/daemon.proto'),
|
||||||
.replace(/^file:\/\//, '')
|
|
||||||
.replace(/\/src\/index\.(ts|js)$/, '/daemon.proto'),
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# GamePanel — one-shot Docker setup.
|
||||||
|
#
|
||||||
|
# ./scripts/install.sh
|
||||||
|
# docker compose up -d --build
|
||||||
|
#
|
||||||
|
# Writes a .env with generated secrets and a daemon-config.yml with a matching
|
||||||
|
# node token. Deliberately does NOT set up TLS or a domain: the panel serves
|
||||||
|
# plain HTTP and you put your own reverse proxy in front of it.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
ENV_FILE=".env"
|
||||||
|
DAEMON_CONFIG="daemon-config.yml"
|
||||||
|
|
||||||
|
random_hex() {
|
||||||
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
|
openssl rand -hex "$1"
|
||||||
|
else
|
||||||
|
head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
echo "$ENV_FILE already exists — leaving it untouched."
|
||||||
|
else
|
||||||
|
echo "Generating $ENV_FILE ..."
|
||||||
|
|
||||||
|
JWT_SECRET="$(random_hex 64)"
|
||||||
|
JWT_REFRESH_SECRET="$(random_hex 64)"
|
||||||
|
DB_PASSWORD="$(random_hex 24)"
|
||||||
|
REDIS_PASSWORD="$(random_hex 24)"
|
||||||
|
DAEMON_TOKEN="$(random_hex 32)"
|
||||||
|
WEB_PORT="${WEB_PORT:-80}"
|
||||||
|
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
# Generated by scripts/install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
|
# --- Database ---
|
||||||
|
DB_USER=gamepanel
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
DB_NAME=gamepanel
|
||||||
|
|
||||||
|
# --- Redis ---
|
||||||
|
REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
# --- API ---
|
||||||
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
||||||
|
RATE_LIMIT_MAX=100
|
||||||
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
|
||||||
|
# --- Web ---
|
||||||
|
# Port the panel listens on. Point your reverse proxy here for TLS/domain.
|
||||||
|
WEB_PORT=${WEB_PORT}
|
||||||
|
# Must match the origin users open in the browser, otherwise CORS blocks login.
|
||||||
|
CORS_ORIGIN=http://localhost:${WEB_PORT}
|
||||||
|
|
||||||
|
# --- Daemon ---
|
||||||
|
DAEMON_GRPC_PORT=50051
|
||||||
|
DAEMON_TOKEN=${DAEMON_TOKEN}
|
||||||
|
# Host directories holding game server files. These are bind-mounted, and the
|
||||||
|
# daemon passes the same paths to the host Docker engine when it creates game
|
||||||
|
# containers — they must be real host paths.
|
||||||
|
DAEMON_DATA_PATH=/var/lib/gamepanel/servers
|
||||||
|
DAEMON_BACKUP_PATH=/var/lib/gamepanel/backups
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
echo " secrets generated"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a; . "./$ENV_FILE"; set +a
|
||||||
|
|
||||||
|
DATA_PATH="${DAEMON_DATA_PATH:-/var/lib/gamepanel/servers}"
|
||||||
|
BACKUP_PATH="${DAEMON_BACKUP_PATH:-/var/lib/gamepanel/backups}"
|
||||||
|
|
||||||
|
echo "Creating host data directories ..."
|
||||||
|
mkdir -p "$DATA_PATH" "$BACKUP_PATH" 2>/dev/null || {
|
||||||
|
echo " could not create $DATA_PATH / $BACKUP_PATH — rerun with sudo, or set"
|
||||||
|
echo " DAEMON_DATA_PATH / DAEMON_BACKUP_PATH in $ENV_FILE to a writable path."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -f "$DAEMON_CONFIG" ] && ! grep -q 'CHANGE_ME_GENERATE_A_SECURE_TOKEN' "$DAEMON_CONFIG"; then
|
||||||
|
echo "$DAEMON_CONFIG already configured — leaving it untouched."
|
||||||
|
else
|
||||||
|
# A pre-existing .env may predate DAEMON_TOKEN; mint one and record it so the
|
||||||
|
# panel and the daemon agree on the same value.
|
||||||
|
if [ -z "${DAEMON_TOKEN:-}" ]; then
|
||||||
|
DAEMON_TOKEN="$(random_hex 32)"
|
||||||
|
printf '\nDAEMON_TOKEN=%s\n' "$DAEMON_TOKEN" >> "$ENV_FILE"
|
||||||
|
echo "Added a generated DAEMON_TOKEN to $ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Writing $DAEMON_CONFIG ..."
|
||||||
|
cat > "$DAEMON_CONFIG" <<EOF
|
||||||
|
# Daemon configuration — mounted into the daemon container.
|
||||||
|
# Generated by scripts/install.sh; safe to edit.
|
||||||
|
|
||||||
|
api_url: "http://api:3000"
|
||||||
|
node_token: "${DAEMON_TOKEN}"
|
||||||
|
grpc_port: 50051
|
||||||
|
|
||||||
|
# Path inside the daemon container.
|
||||||
|
data_path: "/var/lib/gamepanel/servers"
|
||||||
|
backup_path: "/var/lib/gamepanel/backups"
|
||||||
|
|
||||||
|
# Same directory as seen by the Docker host. Game containers are created
|
||||||
|
# through the host's Docker socket, so their bind mounts resolve against the
|
||||||
|
# host filesystem — if this is wrong, files written from the panel never reach
|
||||||
|
# the game server. docker-compose.yml also passes this as DAEMON_HOST_DATA_PATH.
|
||||||
|
host_data_path: "${DATA_PATH}"
|
||||||
|
|
||||||
|
docker:
|
||||||
|
socket: "/var/run/docker.sock"
|
||||||
|
network: "gamepanel_nw"
|
||||||
|
network_subnet: "172.18.0.0/16"
|
||||||
|
|
||||||
|
# Optional node-local MySQL/MariaDB for server databases. Remove this block if
|
||||||
|
# you do not need managed databases.
|
||||||
|
# managed_mysql:
|
||||||
|
# url: "mysql://root:change-me@127.0.0.1:3306/mysql"
|
||||||
|
# connection_host: "CHANGE_ME_REACHABLE_FROM_GAME_CONTAINERS"
|
||||||
|
# connection_port: 3306
|
||||||
|
# phpmyadmin_url: "http://127.0.0.1:8080/"
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Setup complete.
|
||||||
|
|
||||||
|
1. docker compose up -d --build
|
||||||
|
2. open http://localhost:${WEB_PORT:-80}
|
||||||
|
3. sign in as admin@gamepanel.local / admin123 and change the password
|
||||||
|
|
||||||
|
Register the node in the panel with:
|
||||||
|
FQDN host.docker.internal (or this host's IP/hostname)
|
||||||
|
gRPC port ${DAEMON_GRPC_PORT:-50051}
|
||||||
|
Token the DAEMON_TOKEN value in .env
|
||||||
|
|
||||||
|
TLS and domains are intentionally not configured — put your own reverse proxy
|
||||||
|
in front of port ${WEB_PORT:-80}. See INSTALLATION.md.
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user