5 Commits

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

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

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

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

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

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

No behaviour change: eslint and tsc are both clean.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:39:58 +03:00
hibna 11924416a9 fix: something 2026-08-02 20:26:54 +03:00
91 changed files with 3982 additions and 1781 deletions
+11
View File
@@ -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
+59 -1
View File
@@ -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
View File
@@ -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/
+4
View File
@@ -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
+217 -96
View File
@@ -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
`daemon-config.yml` with a matching node token, and creates the host data
directories. It never overwrites files that already exist, so it is safe to
re-run.
Then open `http://<server-ip>:80` and sign in with
`admin@gamepanel.local` / `admin123` — change the password immediately.
### 2.2 What gets started
| Service | Port | Description | | Service | Port | Description |
|---------|------|-------------| | ---------- | -------------------------- | -------------------------------------------------- |
| `postgres` | 5432 | PostgreSQL database | | `postgres` | internal | PostgreSQL database |
| `redis` | 6379 | Rate limiting & cache | | `redis` | internal | Rate limiting & cache |
| `api` | 3000 | Fastify REST API | | `migrate` | — | Applies the schema + seed, then exits |
| `web` | 80 | nginx + React SPA | | `api` | internal | Fastify REST API |
| `daemon` | 50051 | Rust gRPC daemon | | `web` | `WEB_PORT` (80) | nginx + React SPA, proxies `/api` and `/socket.io` |
| `daemon` | `DAEMON_GRPC_PORT` (50051) | Rust gRPC daemon |
### 2.4 Initialize Database 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 (`drizzle-kit push`, the data migrations, the seed) are idempotent.
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 ### 2.3 Register the node
docker compose exec api sh -c 'cd /app && node apps/api/dist/index.js'
```
For the initial setup, the easiest approach is: In the panel, create a node with:
```bash | Field | Value |
# Run migrations from your host machine pointed at the Docker PostgreSQL | ------------ | -------------------------------------------------- |
DATABASE_URL=postgresql://gamepanel:<your-password>@localhost:5432/gamepanel pnpm db:migrate | FQDN | `host.docker.internal` (or the host's IP/hostname) |
DATABASE_URL=postgresql://gamepanel:<your-password>@localhost:5432/gamepanel pnpm db:seed | 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)
@@ -608,7 +729,7 @@ 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) |
+32 -16
View File
@@ -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
@@ -57,7 +61,7 @@ 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 |
@@ -151,7 +155,7 @@ 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` | — |
@@ -159,16 +163,21 @@ source-gamepanel/
| 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/register` | Create account |
| POST | `/api/auth/login` | Login (returns JWT + refresh cookie) | | POST | `/api/auth/login` | Login (returns JWT + refresh cookie) |
| POST | `/api/auth/refresh` | Refresh access token | | POST | `/api/auth/refresh` | Refresh access token |
@@ -176,16 +185,18 @@ Many games can be added with a database seed entry alone. Some images still need
| GET | `/api/auth/me` | Current user profile | | GET | `/api/auth/me` | Current user profile |
### Organizations ### Organizations
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | ---------------- | ----------------------------------- | ----------------- |
| GET | `/api/organizations` | List user's orgs | | GET | `/api/organizations` | List user's orgs |
| POST | `/api/organizations` | Create org | | POST | `/api/organizations` | Create org |
| GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD | | GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD |
| GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management | | GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management |
### Servers ### Servers
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | --------------------- | ------------------------------------------- | --------------------------------------- |
| GET/POST | `.../servers` | List / create | | GET/POST | `.../servers` | List / create |
| GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD | | GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD |
| POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) | | POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) |
@@ -196,8 +207,9 @@ Many games can be added with a database seed entry alone. Some images still need
| GET/POST/PATCH/DELETE | `.../servers/:serverId/schedules` | Scheduled tasks | | 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 | `/api/admin/users` | All users |
| GET/POST | `/api/admin/games` | Game management | | GET/POST | `/api/admin/games` | Game management |
| GET | `/api/admin/audit-logs` | Audit trail | | 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.
--- ---
+17
View File
@@ -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
+11 -9
View File
@@ -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,7 +43,12 @@ 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(
(
error: Error & { validation?: unknown; statusCode?: number; code?: string },
_request,
reply,
) => {
if (error instanceof AppError) { if (error instanceof AppError) {
return reply.code(error.statusCode).send({ return reply.code(error.statusCode).send({
error: error.name, error: error.name,
@@ -74,11 +76,11 @@ app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number;
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
View File
@@ -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',
);
} }
} }
-178
View File
@@ -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);
}
+41 -3
View File
@@ -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
View File
@@ -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');
} }
+373
View File
@@ -0,0 +1,373 @@
import type { FastifyInstance } from 'fastify';
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from './daemon.js';
/**
* Some game images run a SteamCMD `app_update ... validate` on every container
* start, which rewrites config files that ship with the game back to their
* stock contents. The panel therefore keeps its own copy of every managed
* config file in a hidden sidecar next to the real one, and restores the real
* file whenever the game resets it.
*/
export interface ManagedConfigFile {
/** Path of the real file, relative to the server data directory. */
path: string;
/** Sidecar holding the panel's copy of record. */
shadowPath: string;
/** Base name of the sidecar, so the file browser can hide it. */
shadowFileName: string;
/** Written when neither the real file nor the sidecar exists yet. */
defaultContent: string;
/**
* Stock contents shipped by the image. When the sidecar is adopted from an
* existing install, contents matching one of these are replaced by
* `defaultContent` instead of being preserved.
*/
imageDefaults: string[];
}
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
hostname "GamePanel CS2 Server" // Set server hostname
sv_cheats 0 // Enable or disable cheats
sv_hibernate_when_empty 0 // Disable server hibernation
// Passwords
rcon_password "" // Set rcon password
sv_password "" // Set server password
// CSTV
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
tv_chatgroupsize 0 // Set the default chat group size
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
tv_debug 0 // CSTV debug info.
tv_delay 0 // CSTV broadcast delay in seconds
tv_delaymapchange 1 // Delays map change until broadcast is complete
tv_deltacache 2 // Enable delta entity bit stream cache
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
tv_maxclients 10 // Maximum client number on CSTV server.
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
tv_overridemaster 0 // Overrides the CSTV master root address.
tv_port 27020 // Host SourceTV port
tv_password "changeme" // CSTV password for clients
tv_relaypassword "changeme" // CSTV password for relay proxies
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
tv_timeout 60 // CSTV connection timeout in seconds.
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
tv_transmitall 1 // Transmit all entities (not only director view)
// Logs
log on // Turns logging 'on' or 'off', defaults to 'on'
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
`;
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
// CS2 Server Config
// ============================================
// ---- Sunucu Bilgileri ----
hostname "SourceGamePanel CS2 Server"
sv_password ""
rcon_password "changeme"
sv_cheats 0
// ---- Topluluk Sunucu Gorunurlugu ----
sv_region 3
sv_tags "competitive,community"
sv_lan 0
sv_steamgroup ""
sv_steamgroup_exclusive 0
// ---- Performans ----
sv_maxrate 0
sv_minrate 64000
sv_max_queries_sec 5
sv_max_queries_window 30
sv_parallel_sendsnapshot 1
net_maxroutable 1200
// ---- Baglanti ----
sv_maxclients 16
sv_timeout 60
// ---- GOTV (Tamamen Kapali) ----
tv_enable 0
tv_autorecord 0
tv_delay 0
tv_maxclients 0
tv_port 0
// ---- Loglama ----
log on
mp_logmoney 0
mp_logdetail 0
mp_logdetail_items 0
sv_logfile 1
// ---- Genel Oyun Ayarlari ----
mp_autokick 0
sv_allow_votes 0
sv_alltalk 0
sv_deadtalk 1
sv_voiceenable 1
`;
const MANAGED_CONFIG_FILES: Record<string, ManagedConfigFile[]> = {
cs2: [
{
path: CS2_SERVER_CFG_PATH,
shadowPath: CS2_PERSISTED_SERVER_CFG_PATH,
shadowFileName: CS2_PERSISTED_SERVER_CFG_FILE,
defaultContent: DEFAULT_CS2_SERVER_CFG,
imageDefaults: [LEGACY_IMAGE_CS2_SERVER_CFG],
},
],
};
function normalizePath(path: string): string {
return path
.trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/\/{2,}/g, '/');
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizeComparableContent(content: string): string {
return content.replace(/\r\n/g, '\n').trim();
}
export function managedConfigFilesForGame(gameSlug: string): ManagedConfigFile[] {
return MANAGED_CONFIG_FILES[gameSlug.trim().toLowerCase()] ?? [];
}
/** The managed file a request path refers to, or `null` if it is not managed. */
export function managedConfigFileFor(gameSlug: string, path: string): ManagedConfigFile | null {
const normalized = normalizePath(path);
return managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null;
}
export function isManagedConfigShadowFile(gameSlug: string, fileName: string): boolean {
const normalized = fileName.trim();
return managedConfigFilesForGame(gameSlug).some((file) => file.shadowFileName === normalized);
}
/**
* Read the panel's copy of a managed config file, adopting whatever is on disk
* the first time around.
*/
export async function readManagedConfig(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
): Promise<string> {
try {
const persisted = await daemonReadFile(node, serverUuid, file.shadowPath);
return persisted.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
try {
const current = await daemonReadFile(node, serverUuid, file.path);
const content = current.data.toString('utf8');
const isStockContent = file.imageDefaults.some(
(stock) => normalizeComparableContent(stock) === normalizeComparableContent(content),
);
const nextContent = isStockContent ? file.defaultContent : content;
await daemonWriteFile(node, serverUuid, file.shadowPath, nextContent);
return nextContent;
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
await daemonWriteFile(node, serverUuid, file.shadowPath, file.defaultContent);
return file.defaultContent;
}
/** Write a managed config file, keeping the panel's copy in sync. */
export async function writeManagedConfig(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
content: string | Buffer,
): Promise<void> {
await daemonWriteFile(node, serverUuid, file.shadowPath, content);
await daemonWriteFile(node, serverUuid, file.path, content);
}
// === Drift watcher ===
/**
* How long to keep watching after a start. This has to outlast the image's own
* update/validate step — for CS2 that is a multi-gigabyte SteamCMD run that can
* easily take 10+ minutes on a cold cache, and it rewrites `server.cfg` when it
* finishes. Watching for only a minute is why edited configs kept coming back.
*/
const SUSTAIN_WINDOW_MS = Number(process.env.MANAGED_CONFIG_SUSTAIN_MS) || 30 * 60_000;
const FAST_INTERVAL_MS = 5_000;
const SLOW_INTERVAL_MS = 20_000;
const FAST_PHASE_MS = 2 * 60_000;
/** Consecutive drift-free polls needed before the watcher stops early. */
const REQUIRED_STABLE_ROUNDS = 6;
/** Never stop early before this much of the window has elapsed. */
const MIN_WATCH_MS = 3 * 60_000;
/** One watcher per server; a newer start supersedes the one already running. */
const activeWatchers = new Map<string, symbol>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function restoreDriftedFile(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
): Promise<boolean> {
const expected = await readManagedConfig(node, serverUuid, file);
let live: string | null = null;
try {
const current = await daemonReadFile(node, serverUuid, file.path);
live = current.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
if (live !== null && normalizeComparableContent(live) === normalizeComparableContent(expected)) {
return false;
}
await daemonWriteFile(node, serverUuid, file.path, expected);
return true;
}
/** Restore every managed config file for a game to the panel's copy. */
export async function reapplyManagedConfigs(
node: DaemonNodeConnection,
serverUuid: string,
gameSlug: string,
): Promise<void> {
for (const file of managedConfigFilesForGame(gameSlug)) {
await restoreDriftedFile(node, serverUuid, file);
}
}
/**
* Watch a server's managed config files after a start and put the panel's
* version back whenever the game overwrites it.
*
* `isServerActive` lets the caller abort once the server leaves the running
* state, so a stopped server never gets its files rewritten behind its back.
*/
export function sustainManagedConfigsAfterStart(
app: FastifyInstance,
options: {
node: DaemonNodeConnection;
serverId: string;
serverUuid: string;
gameSlug: string;
isServerActive: () => Promise<boolean>;
},
): void {
const files = managedConfigFilesForGame(options.gameSlug);
if (files.length === 0) return;
const token = Symbol(options.serverId);
activeWatchers.set(options.serverId, token);
void (async () => {
const startedAt = Date.now();
const deadline = startedAt + SUSTAIN_WINDOW_MS;
let stableRounds = 0;
try {
while (Date.now() < deadline) {
const elapsed = Date.now() - startedAt;
await sleep(elapsed < FAST_PHASE_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
if (activeWatchers.get(options.serverId) !== token) return;
let active: boolean;
try {
active = await options.isServerActive();
} catch (error) {
app.log.warn(
{ error, serverId: options.serverId },
'Managed config watcher could not read server status',
);
continue;
}
if (!active) {
app.log.debug(
{ serverId: options.serverId },
'Managed config watcher stopping: server is no longer running',
);
return;
}
let drifted = false;
for (const file of files) {
try {
if (await restoreDriftedFile(options.node, options.serverUuid, file)) {
drifted = true;
app.log.info(
{
serverId: options.serverId,
serverUuid: options.serverUuid,
gameSlug: options.gameSlug,
path: file.path,
},
'Restored managed config file after the game reset it',
);
}
} catch (error) {
app.log.warn(
{
error,
serverId: options.serverId,
serverUuid: options.serverUuid,
path: file.path,
},
'Failed to restore managed config file',
);
}
}
stableRounds = drifted ? 0 : stableRounds + 1;
if (stableRounds >= REQUIRED_STABLE_ROUNDS && Date.now() - startedAt >= MIN_WATCH_MS) {
return;
}
}
} finally {
if (activeWatchers.get(options.serverId) === token) {
activeWatchers.delete(options.serverId);
}
}
})();
}
+10 -1
View File
@@ -5,7 +5,16 @@ export const PaginationQuerySchema = Type.Object({
perPage: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })), 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;
+5 -2
View File
@@ -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 -4
View File
@@ -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) {
+17 -22
View File
@@ -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(
+4 -2
View File
@@ -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)',
+23 -11
View File
@@ -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,8 +101,9 @@ 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' });
@@ -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,
+55 -32
View File
@@ -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,7 +268,10 @@ 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(
'/games/:gameId',
{ schema: { ...GameIdParamSchema, ...UpdateGameSchema } },
async (request) => {
const { gameId } = request.params as { gameId: string }; const { gameId } = request.params as { gameId: string };
const body = request.body as Record<string, unknown>; const body = request.body as Record<string, unknown>;
@@ -281,7 +284,8 @@ export default async function adminRoutes(app: FastifyInstance) {
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,7 +582,10 @@ export default async function adminRoutes(app: FastifyInstance) {
}; };
}); });
app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => { app.patch(
'/plugins/:pluginId',
{ schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } },
async (request) => {
const { pluginId } = request.params as { pluginId: string }; const { pluginId } = request.params as { pluginId: string };
const body = request.body as { const body = request.body as {
name?: string; name?: string;
@@ -593,9 +600,12 @@ export default async function adminRoutes(app: FastifyInstance) {
}); });
if (!existing) throw AppError.notFound('Plugin not found'); if (!existing) throw AppError.notFound('Plugin not found');
const nextSlug = body.slug !== undefined const nextSlug =
body.slug !== undefined
? toSlug(body.slug) ? toSlug(body.slug)
: (body.name !== undefined ? toSlug(body.name) : existing.slug); : body.name !== undefined
? toSlug(body.name)
: existing.slug;
if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid'); 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({
@@ -620,7 +630,8 @@ export default async function adminRoutes(app: FastifyInstance) {
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,7 +650,10 @@ 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(
'/plugins/:pluginId/releases/upload',
{ schema: PluginIdParamSchema },
async (request, reply) => {
const { pluginId } = request.params as { pluginId: string }; const { pluginId } = request.params as { pluginId: string };
const plugin = await app.db.query.plugins.findFirst({ const plugin = await app.db.query.plugins.findFirst({
@@ -711,14 +725,20 @@ export default async function adminRoutes(app: FastifyInstance) {
} }
const channel = parseReleaseChannel(fields.channel); const channel = parseReleaseChannel(fields.channel);
const destination = typeof fields.destination === 'string' && fields.destination.trim().length > 0 const destination =
typeof fields.destination === 'string' && fields.destination.trim().length > 0
? fields.destination.trim() ? fields.destination.trim()
: null; : null;
const changelog = typeof fields.changelog === 'string' && fields.changelog.trim().length > 0 const changelog =
typeof fields.changelog === 'string' && fields.changelog.trim().length > 0
? fields.changelog ? fields.changelog
: null; : null;
const isPublished = parseOptionalBoolean(fields.isPublished) ?? true; const isPublished = parseOptionalBoolean(fields.isPublished) ?? true;
const installSchema = parseJsonArrayInput(fields.installSchema, installSchemaFile, 'installSchema'); const installSchema = parseJsonArrayInput(
fields.installSchema,
installSchemaFile,
'installSchema',
);
const configTemplates = parseJsonArrayInput( const configTemplates = parseJsonArrayInput(
fields.configTemplates, fields.configTemplates,
configTemplatesFile, configTemplatesFile,
@@ -793,9 +813,13 @@ export default async function adminRoutes(app: FastifyInstance) {
pointer: uploaded.artifactPointer, pointer: uploaded.artifactPointer,
}, },
}); });
}); },
);
app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => { app.post(
'/plugins/:pluginId/releases',
{ schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } },
async (request, reply) => {
const { pluginId } = request.params as { pluginId: string }; const { pluginId } = request.params as { pluginId: string };
const body = request.body as { const body = request.body as {
version: string; version: string;
@@ -818,12 +842,13 @@ export default async function adminRoutes(app: FastifyInstance) {
let baseRelease: typeof pluginReleases.$inferSelect | null = null; let baseRelease: typeof pluginReleases.$inferSelect | null = null;
if (body.cloneFromReleaseId) { if (body.cloneFromReleaseId) {
baseRelease = await app.db.query.pluginReleases.findFirst({ baseRelease =
(await app.db.query.pluginReleases.findFirst({
where: and( where: and(
eq(pluginReleases.id, body.cloneFromReleaseId), eq(pluginReleases.id, body.cloneFromReleaseId),
eq(pluginReleases.pluginId, pluginId), eq(pluginReleases.pluginId, pluginId),
), ),
}) ?? null; })) ?? null;
if (!baseRelease) { if (!baseRelease) {
throw AppError.notFound('Clone source release not found'); throw AppError.notFound('Clone source release not found');
} }
@@ -848,7 +873,8 @@ export default async function adminRoutes(app: FastifyInstance) {
.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);
+19 -5
View File
@@ -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(
Type.Array(
Type.Object({
label: Type.String({ minLength: 1, maxLength: 255 }), label: Type.String({ minLength: 1, maxLength: 255 }),
value: 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 })),
+1 -1
View File
@@ -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';
+1 -4
View File
@@ -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 };
}); });
+14 -18
View File
@@ -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 =
typeof body?.eventType === 'string'
? body.eventType ? body.eventType
: (typeof body?.type === 'string' ? body.type : 'unknown'); : 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(
and(
eq(servers.nodeId, node.id), eq(servers.nodeId, node.id),
eq(scheduledTasks.isActive, true), eq(scheduledTasks.isActive, true),
lte(scheduledTasks.nextRunAt, now), 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');
+1 -3
View File
@@ -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) {
+12 -4
View File
@@ -94,7 +94,10 @@ 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(
'/:nodeId',
{ schema: { ...NodeParamSchema, ...UpdateNodeSchema } },
async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage'); await requirePermission(request, orgId, 'node.manage');
@@ -115,7 +118,8 @@ export default async function nodeRoutes(app: FastifyInstance) {
}); });
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,7 +248,10 @@ 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(
'/:nodeId/allocations',
{ schema: { ...NodeParamSchema, ...CreateAllocationSchema } },
async (request, reply) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage'); await requirePermission(request, orgId, 'node.manage');
@@ -269,5 +276,6 @@ export default async function nodeRoutes(app: FastifyInstance) {
}); });
return reply.code(201).send({ data: created }); return reply.code(201).send({ data: created });
}); },
);
} }
+30 -16
View File
@@ -4,6 +4,7 @@ import { organizations, organizationMembers, users } from '@source/database';
import { AppError } from '../../lib/errors.js'; import { 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,7 +174,10 @@ 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(
'/:orgId/members',
{ schema: { ...OrgIdParamSchema, ...AddMemberSchema } },
async (request, reply) => {
const { orgId } = request.params as { orgId: string }; const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'org.members'); await requirePermission(request, orgId, 'org.members');
@@ -208,22 +212,28 @@ export default async function organizationRoutes(app: FastifyInstance) {
}); });
return reply.code(201).send(member); 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(
'/:orgId/members/:memberId',
{ schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } },
async (request) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string }; const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members'); 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');
@@ -235,10 +245,14 @@ export default async function organizationRoutes(app: FastifyInstance) {
}); });
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(
'/:orgId/members/:memberId',
{ schema: MemberIdParamSchema },
async (request, reply) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string }; const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members'); await requirePermission(request, orgId, 'org.members');
@@ -260,10 +274,9 @@ export default async function organizationRoutes(app: FastifyInstance) {
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,
@@ -272,5 +285,6 @@ export default async function organizationRoutes(app: FastifyInstance) {
}); });
return reply.code(204).send(); return reply.code(204).send();
}); },
);
} }
+6 -9
View File
@@ -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');
} }
+43 -22
View File
@@ -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);
} }
+12 -4
View File
@@ -99,7 +99,10 @@ export default async function databaseRoutes(app: FastifyInstance) {
return { data: databases }; return { data: databases };
}); });
app.post('/', { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, async (request, reply) => { app.post(
'/',
{ schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } },
async (request, reply) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.update'); await requirePermission(request, orgId, 'server.update');
@@ -176,9 +179,13 @@ export default async function databaseRoutes(app: FastifyInstance) {
); );
throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED'); throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED');
} }
}); },
);
app.patch('/:databaseId', { schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } }, async (request) => { app.patch(
'/:databaseId',
{ schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } },
async (request) => {
const { orgId, serverId, databaseId } = request.params as { const { orgId, serverId, databaseId } = request.params as {
databaseId: string; databaseId: string;
orgId: string; orgId: string;
@@ -272,7 +279,8 @@ export default async function databaseRoutes(app: FastifyInstance) {
}); });
return updated; 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 {
+33 -24
View File
@@ -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) => {
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
if (!managedFile) return [path];
return [
path, path,
path.trim().startsWith('/') path.trim().startsWith('/') ? `/${managedFile.shadowPath}` : managedFile.shadowPath,
? `/${CS2_PERSISTED_SERVER_CFG_PATH}` ];
: CS2_PERSISTED_SERVER_CFG_PATH, });
]
: [path],
);
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;
+105 -57
View File
@@ -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,12 +197,12 @@ 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);
);
if (slug === 'satisfactory') {
const messagingPort = findPort('satisfactory-messaging', 'tcp');
return { return {
...environment, ...environment,
@@ -202,6 +211,26 @@ function applyGameRuntimeEnvironment(
}; };
} }
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(
gameSlug: string, gameSlug: string,
allocationPort: number, allocationPort: number,
@@ -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');
} }
try { const nodeConnection: DaemonNodeConnection = {
await daemonSetPowerState(
{
fqdn: server.nodeFqdn, fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort, grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken, daemonToken: server.nodeDaemonToken,
}, };
server.uuid,
action, try {
); await daemonSetPowerState(nodeConnection, server.uuid, action, {
stopCommand: server.gameStopCommand,
stopTimeoutSeconds: server.gameStopTimeoutSeconds,
});
} 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,42 +1524,23 @@ 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({
gameSlug: games.slug,
automationRules: games.automationRules,
})
.from(servers)
.innerJoin(games, eq(servers.gameId, games.id))
.where(eq(servers.id, serverId));
if (serverWithGame) {
void sustainCs2ServerConfigAfterPowerStart(
app, app,
{ nodeConnection,
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
serverId, serverId,
server.uuid, server.uuid,
serverWithGame.gameSlug, server.gameSlug,
); );
void runServerAutomationEvent(app, { void runServerAutomationEvent(app, {
serverId, serverId,
serverUuid: server.uuid, serverUuid: server.uuid,
gameSlug: serverWithGame.gameSlug, gameSlug: server.gameSlug,
event: 'server.power.started', event: 'server.power.started',
node: { node: nodeConnection,
fqdn: server.nodeFqdn, automationRulesRaw: server.gameAutomationRules,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
automationRulesRaw: serverWithGame.automationRules,
}); });
} }
}
await createAuditLog(app.db, request, { await createAuditLog(app.db, request, {
organizationId: orgId, organizationId: orgId,
+5 -1
View File
@@ -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;
}> { }> {
+124 -110
View File
@@ -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<
T extends {
channel: string; channel: string;
isPublished: boolean; isPublished: boolean;
}>(releases: T[], autoChannel: ReleaseChannel): T | null { },
>(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,9 +545,7 @@ 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)
.values(
uniqPaths(paths).map((path) => ({ uniqPaths(paths).map((path) => ({
serverPluginId, serverPluginId,
path, path,
@@ -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,7 +887,8 @@ 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 =
pluginIds.length > 0
? await app.db ? await app.db
.select({ .select({
id: pluginReleases.id, id: pluginReleases.id,
@@ -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,7 +990,8 @@ 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 =
pluginIds.length > 0
? await app.db ? await app.db
.select({ .select({
id: pluginReleases.id, id: pluginReleases.id,
@@ -1021,7 +1007,12 @@ export default async function pluginRoutes(app: FastifyInstance) {
createdAt: pluginReleases.createdAt, createdAt: pluginReleases.createdAt,
}) })
.from(pluginReleases) .from(pluginReleases)
.where(and(inArray(pluginReleases.pluginId, pluginIds), eq(pluginReleases.isPublished, true))) .where(
and(
inArray(pluginReleases.pluginId, pluginIds),
eq(pluginReleases.isPublished, true),
),
)
.orderBy(desc(pluginReleases.createdAt)) .orderBy(desc(pluginReleases.createdAt))
: []; : [];
@@ -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 =
body.slug !== undefined
? toSlug(body.slug) ? toSlug(body.slug)
: (body.name !== undefined ? toSlug(body.name) : existing.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(
Type.Object({
releaseId: Type.Optional(Type.String({ format: 'uuid' })), releaseId: Type.Optional(Type.String({ format: 'uuid' })),
options: Type.Optional(Type.Record(Type.String(), Type.Any())), options: Type.Optional(Type.Record(Type.String(), Type.Any())),
pinVersion: Type.Optional(Type.Boolean()), pinVersion: Type.Optional(Type.Boolean()),
autoUpdateChannel: Type.Optional(Type.Union([ autoUpdateChannel: Type.Optional(
Type.Literal('stable'), Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
Type.Literal('beta'), ),
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(
context.gameSlug,
{
id: installed.pluginId, id: installed.pluginId,
slug: installed.pluginSlug, slug: installed.pluginSlug,
}, { },
{
artifactUrl: installed.releaseArtifactUrl, artifactUrl: installed.releaseArtifactUrl,
destination: installed.releaseDestination, destination: installed.releaseDestination,
fileName: installed.releaseFileName, 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(
Type.Object({
releaseId: Type.Optional(Type.String({ format: 'uuid' })), releaseId: Type.Optional(Type.String({ format: 'uuid' })),
options: Type.Optional(Type.Record(Type.String(), Type.Any())), options: Type.Optional(Type.Record(Type.String(), Type.Any())),
pinVersion: Type.Optional(Type.Boolean()), pinVersion: Type.Optional(Type.Boolean()),
autoUpdateChannel: Type.Optional(Type.Union([ autoUpdateChannel: Type.Optional(
Type.Literal('stable'), Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
Type.Literal('beta'), ),
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(
+15 -9
View File
@@ -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,7 +127,10 @@ 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(
'/:taskId',
{ schema: { ...TaskParamSchema, body: UpdateScheduleBody } },
async (request) => {
const { orgId, serverId, taskId } = request.params as { const { orgId, serverId, taskId } = request.params as {
orgId: string; orgId: string;
serverId: string; serverId: string;
@@ -148,7 +147,9 @@ export default async function scheduleRoutes(app: FastifyInstance) {
// 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 =
(body.scheduleData as Record<string, unknown>) ||
(existing.scheduleData as Record<string, unknown>);
const nextRun = computeNextRun(scheduleType, scheduleData); const nextRun = computeNextRun(scheduleType, scheduleData);
const [updated] = await app.db const [updated] = await app.db
@@ -158,7 +159,8 @@ export default async function scheduleRoutes(app: FastifyInstance) {
.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;
}> { }> {
+8 -4
View File
@@ -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"]
+24 -1
View File
@@ -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())
}
} }
+394 -18
View File
@@ -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() {
if existing.container_id() == container_id {
return Ok(existing); 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() {
if existing.container_id() == container_id {
created.abort(); created.abort();
return Ok(existing); 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})")
}),
}
} }
} }
+61 -6
View File
@@ -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
} }
+80
View File
@@ -0,0 +1,80 @@
use anyhow::Result;
use tracing::info;
use super::rcon::RconClient;
/// Player information from an ARK RCON `ListPlayers` response.
pub struct ArkPlayer {
pub name: String,
pub steamid: String,
}
/// Query an ARK server for its connected players.
pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<Vec<ArkPlayer>> {
let mut client = RconClient::connect(rcon_address, rcon_password).await?;
let response = client.command("ListPlayers").await?;
let players = parse_list_players_response(&response);
info!(count = players.len(), "ARK player list retrieved");
Ok(players)
}
/// Parses lines shaped like `0. PlayerName, 76561198000000000`.
fn parse_list_players_response(response: &str) -> Vec<ArkPlayer> {
let mut players = Vec::new();
for line in response.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// "No Players Connected"
if trimmed.eq_ignore_ascii_case("no players connected") {
break;
}
// Strip the "<index>. " prefix.
let entry = match trimmed.split_once('.') {
Some((index, rest)) if index.trim().chars().all(|c| c.is_ascii_digit()) => rest.trim(),
_ => continue,
};
let (name, steamid) = match entry.rsplit_once(',') {
Some((name, steamid)) => (name.trim(), steamid.trim()),
None => (entry, ""),
};
if name.is_empty() {
continue;
}
players.push(ArkPlayer {
name: name.to_string(),
steamid: steamid.to_string(),
});
}
players
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_connected_players() {
let response = "0. Alper, 76561198000000001\n1. Rezan, 76561198000000002\n";
let players = parse_list_players_response(response);
assert_eq!(players.len(), 2);
assert_eq!(players[0].name, "Alper");
assert_eq!(players[0].steamid, "76561198000000001");
assert_eq!(players[1].name, "Rezan");
}
#[test]
fn handles_empty_server() {
assert!(parse_list_players_response("No Players Connected\n").is_empty());
}
}
+1
View File
@@ -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;
+79 -6
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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?;
} }
+47 -4
View File
@@ -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 -1
View File
@@ -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;
+42
View File
@@ -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
View File
@@ -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
@@ -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">
+1 -2
View File
@@ -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>
+1 -2
View File
@@ -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} />;
+6 -4
View File
@@ -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';
+13 -3
View File
@@ -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';
+9 -2
View File
@@ -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;
+5 -1
View File
@@ -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;
+5 -1
View File
@@ -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>
+2 -4
View File
@@ -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 };
+7 -5
View File
@@ -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}
+9 -3
View File
@@ -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>
+66 -32
View File
@@ -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&apos;e yuklenir. otomatik zip yapilip CDN&apos;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>
+11 -2
View File
@@ -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>
+11 -2
View File
@@ -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>
+3 -1
View File
@@ -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>
+20 -27
View File
@@ -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('-');
+13 -14
View File
@@ -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>
@@ -221,14 +214,20 @@ export function NodesPage() {
</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>
+8 -19
View File
@@ -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}>
+9 -21
View File
@@ -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)}
+13 -13
View File
@@ -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>
+11 -35
View File
@@ -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>
+1 -3
View File
@@ -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,
}); });
+19 -8
View File
@@ -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">
+34 -28
View File
@@ -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>
+46 -22
View File
@@ -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 &&
lastAutomationResult &&
lastAutomationResult.failures.length === 0 && (
<p className="text-xs text-green-600">Automation run completed successfully.</p> <p className="text-xs text-green-600">Automation run completed successfully.</p>
)} )}
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length > 0 && ( {automationRunMutation.isSuccess &&
lastAutomationResult &&
lastAutomationResult.failures.length > 0 && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
Automation run completed with {lastAutomationResult.failures.length} error(s). Automation run completed with {lastAutomationResult.failures.length} error(s).
</p> </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();
+31 -1
View File
@@ -64,8 +64,9 @@ interface AdditionalPortRequirement {
} }
function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] { function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] {
if (gameSlug.trim().toLowerCase() !== 'satisfactory') return []; const slug = gameSlug.trim().toLowerCase();
if (slug === 'satisfactory') {
return [ return [
{ {
key: 'satisfactory-messaging', key: 'satisfactory-messaging',
@@ -77,6 +78,35 @@ function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequ
]; ];
} }
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() {
const { orgId } = useParams(); const { orgId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
+9 -10
View File
@@ -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);
+9
View File
@@ -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"
+141
View File
@@ -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
View File
@@ -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;
+3 -1
View File
@@ -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"
}, },
+76
View File
@@ -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);
});
+5
View File
@@ -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 -3
View File
@@ -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(),
+1 -6
View File
@@ -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', {
+137
View File
@@ -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();
+14
View File
@@ -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 ===
+1 -3
View File
@@ -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'),
); );
+149
View File
@@ -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