24 Commits

Author SHA1 Message Date
hibna d50a7fd049 Install the docker CLI in jobs that build images
CI / Docker Build (push) Has been skipped
CI / Lint & Type Check (push) Successful in 4m4s
CI / Daemon Build & Test (push) Successful in 6m19s
CI / Publish images (push) Failing after 2m15s
The runner executes jobs inside a container that ships no docker client,
so the publish job died on its first command:

  /var/run/act/workflow/1: line 3: docker: command not found

act_runner does mount the host's socket into job containers, so only the
client is missing. Both image jobs now fetch the static binary when it is
absent and then check that the socket answers, because "no client" and
"no daemon" are different problems and the log should say which one it
hit. The docker build-test job needed the same treatment — it is skipped
on tags, so it had never reached that command either.

Verified in a container without a docker client: with the socket mounted
the step installs the client and builds the web image; without it the
step fails with the runner-configuration message instead of a confusing
connection error.
2026-08-02 23:13:55 +03:00
hibna 55e0a3cde6 Make the API and web images build and run
CI / Docker Build (push) Has been skipped
CI / Lint & Type Check (push) Successful in 4m13s
CI / Daemon Build & Test (push) Successful in 6m24s
CI / Publish images (push) Failing after 12s
The image builds were never exercised: the docker job needs lint, and
lint was failing, so nothing downstream of it ever ran. Four defects
had accumulated behind that gate, each fatal on its own.

- pnpm creates no node_modules for a package without dependencies, and
  @source/shared has none. Three COPY lines named that path and failed.
- The production stage copied apps/api/dist, which tsc never wrote:
  tsconfig.base.json sets noEmit and no package overrides it. Rather
  than turn emit on — every @source/* package points main at its
  TypeScript source, and @source/proto derives daemon.proto's location
  from a /src/index.ts module URL — the stage now runs the sources
  through tsx, exactly as the migrate stage has always done.
- tsx lives in apps/api/node_modules/.bin under pnpm's isolated layout,
  so the command only resolves from the package directory.
- Both healthchecks probed localhost, which musl resolves to ::1 while
  the servers bind IPv4. Every probe was refused, so the containers sat
  unhealthy forever — and `docker compose up --wait`, which is how a
  panel installs this stack, waits for healthy.

Also fixed the postgres healthcheck in both compose files. pg_isready
without -h asks over the unix socket, which answers during the image's
init phase before the server listens on TCP; the migrate container then
started and died with ECONNREFUSED against a container Compose had just
called healthy.

Verified by running the full stack from docker-compose.panel.yml with
locally built images: migrations and seed complete, api, web, daemon,
postgres and redis all report healthy, and /api/health answers 200
through the web container's proxy.
2026-08-02 21:48:51 +03:00
hibna cb3a90be35 Build the daemon with Rust 1.97
Dependencies now ship edition 2024, which Cargo 1.83 refuses to parse:

  feature `edition2024` is required
  ... not stabilized in this version of Cargo (1.83.0)

The pin lived in two places and both had to move, or the daemon image
would have failed the same way the CI job did.
2026-08-02 21:48:33 +03:00
hibna 276150a769 Install protoc without sudo on self-hosted runners
CI / Docker Build (push) Has been skipped
CI / Lint & Type Check (push) Successful in 4m35s
CI / Daemon Build & Test (push) Failing after 2m27s
CI / Publish images (push) Has been skipped
The daemon job assumed a GitHub-hosted runner, where the build user is
unprivileged and sudo exists. Our act runner is a container that runs
as root and ships no sudo, so the step died with "sudo: command not
found" before the toolchain was ever installed — which also blocked the
publish job that waits on it.

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

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

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

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

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

No behaviour change: eslint and tsc are both clean.

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:07:00 +03:00
hibna d7d8fd5339 Fix auth flows and add daemon heartbeat endpoint 2026-02-22 09:41:17 +00:00
hibna c926613ee0 chore: initial commit for main 2026-02-22 09:52:38 +03:00
hibna 124e4f8921 chore: initial commit for phase07 2026-02-22 00:25:39 +03:00
hibna 5709d8bc10 chore: initial commit for phase06 2026-02-21 23:46:01 +03:00
hibna 0941a9ba46 chore: initial commit for phase05 2026-02-21 16:59:21 +03:00
hibna 218452706c chore: initial commit for phase04 2026-02-21 15:50:35 +03:00
hibna d0c20581b6 chore: initial commit for phase03 2026-02-21 13:37:46 +03:00
hibna 8eb7c90958 chore: update gitignore for phase02 2026-02-21 13:22:51 +03:00
163 changed files with 35658 additions and 90 deletions
View File
+12
View File
@@ -0,0 +1,12 @@
node_modules
**/node_modules
**/dist
**/target
**/.turbo
.git
.env
.env.*
!.env.example
*.md
.vscode
.idea
+47 -6
View File
@@ -1,17 +1,58 @@
# Database
# =========================================
# GamePanel Environment Configuration
# =========================================
# Copy this file to .env and update values
# cp .env.example .env
# --- Database ---
DATABASE_URL=postgresql://gamepanel:gamepanel@localhost:5432/gamepanel
DB_USER=gamepanel
DB_PASSWORD=gamepanel
DB_NAME=gamepanel
DB_PORT=5432
# API
# --- Redis ---
REDIS_URL=redis://:gamepanel@localhost:6379
REDIS_PASSWORD=gamepanel
REDIS_PORT=6379
# --- API ---
PORT=3000
HOST=0.0.0.0
API_PORT=3000
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
# JWT
JWT_SECRET=change-me-in-production
JWT_REFRESH_SECRET=change-me-in-production-refresh
# --- JWT (CHANGE IN PRODUCTION!) ---
# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWT_SECRET=CHANGE_ME_GENERATE_A_SECURE_64_BYTE_HEX_STRING
JWT_REFRESH_SECRET=CHANGE_ME_GENERATE_ANOTHER_SECURE_64_BYTE_HEX_STRING
# Daemon
# --- Rate Limiting ---
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW_MS=60000
# --- Web ---
WEB_PORT=80
# --- Daemon ---
DAEMON_CONFIG=/etc/gamepanel/config.yml
DAEMON_GRPC_PORT=50051
DAEMON_TOKEN=CHANGE_ME_GENERATE_A_SECURE_TOKEN
# Host directories for game server files and backups. The daemon hands these
# exact paths to the host Docker engine when creating game containers, so they
# must exist on the host — not inside the daemon container.
DAEMON_DATA_PATH=/var/lib/gamepanel/servers
DAEMON_BACKUP_PATH=/var/lib/gamepanel/backups
# --- Managed config persistence ---
# How long the panel keeps restoring panel-managed config files after a start.
# Steam images can re-validate for a long time before overwriting them.
MANAGED_CONFIG_SUSTAIN_MS=1800000
# --- CDN (Plugin Artifacts) ---
CDN_BASE_URL=https://cdn.hibna.com.tr
CDN_API_KEY=
CDN_PLUGIN_BUCKET=gamepanel-plugin-artifacts
CDN_PLUGIN_ARTIFACT_TTL_SECONDS=900
CDN_WEBHOOK_SECRET=
+200
View File
@@ -0,0 +1,200 @@
name: CI
on:
push:
branches: [main, develop]
tags: ["v*"]
pull_request:
branches: [main]
env:
NODE_VERSION: "20"
PNPM_VERSION: "9.15.4"
# 1.85 is the floor: dependencies now ship edition 2024, which older
# Cargo refuses to even parse. Keep this in step with the toolchain
# pinned in apps/daemon/Dockerfile.
RUST_TOOLCHAIN: "1.97"
# Static client only; the daemon comes from the socket the runner mounts.
DOCKER_CLI_VERSION: "29.7.1"
jobs:
# --- Lint + TypeScript Check ---
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: TypeScript check (shared)
run: pnpm --filter @source/shared build
- name: TypeScript check (database)
run: pnpm --filter @source/database build
- name: TypeScript check (API)
run: pnpm --filter @source/api build
- name: TypeScript check (Web)
run: pnpm --filter @source/web build
- name: Lint
run: pnpm lint
- name: Format check
run: pnpm format:check
# --- Rust Daemon ---
daemon:
name: Daemon Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Self-hosted act runners run as root in a container that has no sudo,
# while GitHub-hosted runners need it. Pick whichever exists.
- name: Install protoc
run: |
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
$SUDO apt-get update
$SUDO apt-get install -y protobuf-compiler
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/daemon
- name: Check
working-directory: apps/daemon
run: cargo check
- name: Test
working-directory: apps/daemon
run: cargo test
- name: Clippy
working-directory: apps/daemon
run: cargo clippy -- -D warnings || true
# --- Docker Build Test ---
docker:
name: Docker Build
runs-on: ubuntu-latest
needs: [lint, daemon]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
# The runner executes jobs inside a container that has no docker CLI,
# while act_runner mounts the host's socket at /var/run/docker.sock.
# Install just the client when it is missing, then prove the socket is
# actually reachable — the two failure modes look nothing alike and the
# message should say which one happened.
- name: Ensure docker CLI
run: |
if ! command -v docker >/dev/null 2>&1; then
url="https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz"
if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o /tmp/docker.tgz
else wget -qO /tmp/docker.tgz "$url"; fi
tar -xzf /tmp/docker.tgz -C /usr/local/bin --strip-components=1 docker/docker
fi
docker --version
docker version >/dev/null 2>&1 || {
echo "The docker socket is not reachable from this job."
echo "act_runner must mount it: leave container.docker_host empty in its config.yaml."
exit 1
}
- name: Build API image
run: docker build -f apps/api/Dockerfile -t gamepanel-api:ci .
- name: Build Web image
run: docker build -f apps/web/Dockerfile -t gamepanel-web:ci .
- name: Build Daemon image
run: docker build -f apps/daemon/Dockerfile -t gamepanel-daemon:ci .
# --- Publish images (tags only) ---
#
# docker-compose.panel.yml deploys from these images, so the stack can be
# installed on a server that has no checkout of this repository — that is
# what a control panel needs.
#
# Plain `docker build` + `docker push` on purpose: no buildx or bake, so the
# job runs on the same self-hosted runner as the build test above.
#
# Requires a REGISTRY_TOKEN secret with package write scope. The registry is
# this Gitea instance's own container registry; the panel pulls from it.
publish:
name: Publish images
runs-on: ubuntu-latest
needs: [lint, daemon]
if: startsWith(github.ref, 'refs/tags/v')
env:
REGISTRY: gits.hibna.com.tr/hibna
steps:
- uses: actions/checkout@v4
# The runner executes jobs inside a container that has no docker CLI,
# while act_runner mounts the host's socket at /var/run/docker.sock.
# Install just the client when it is missing, then prove the socket is
# actually reachable — the two failure modes look nothing alike and the
# message should say which one happened.
- name: Ensure docker CLI
run: |
if ! command -v docker >/dev/null 2>&1; then
url="https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz"
if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o /tmp/docker.tgz
else wget -qO /tmp/docker.tgz "$url"; fi
tar -xzf /tmp/docker.tgz -C /usr/local/bin --strip-components=1 docker/docker
fi
docker --version
docker version >/dev/null 2>&1 || {
echo "The docker socket is not reachable from this job."
echo "act_runner must mount it: leave container.docker_host empty in its config.yaml."
exit 1
}
- name: Registry login
run: |
printf '%s' "${{ secrets.REGISTRY_TOKEN }}" |
docker login gits.hibna.com.tr -u "${{ github.actor }}" --password-stdin
- name: Resolve tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> "$GITHUB_ENV"
# The API Dockerfile carries the migration runner as its own stage; it
# has to be pushed as a separate image because docker-compose.panel.yml
# runs it as a one-shot service before the API starts.
- name: API + migrate
run: |
docker build -f apps/api/Dockerfile -t "$REGISTRY/gamepanel-api:$TAG" .
docker build -f apps/api/Dockerfile --target migrate -t "$REGISTRY/gamepanel-migrate:$TAG" .
docker push "$REGISTRY/gamepanel-api:$TAG"
docker push "$REGISTRY/gamepanel-migrate:$TAG"
# VITE_API_URL is baked in at build time: the SPA calls /api on its own
# origin, which the image's nginx proxies to the api service.
- name: Web
run: |
docker build -f apps/web/Dockerfile --build-arg VITE_API_URL=/api -t "$REGISTRY/gamepanel-web:$TAG" .
docker push "$REGISTRY/gamepanel-web:$TAG"
- name: Daemon
run: |
docker build -f apps/daemon/Dockerfile -t "$REGISTRY/gamepanel-daemon:$TAG" .
docker push "$REGISTRY/gamepanel-daemon:$TAG"
+6 -1
View File
@@ -7,6 +7,7 @@ dist/
.env
.env.local
.env.*.local
daemon-dev.yml
# IDE
.idea/
@@ -22,7 +23,10 @@ Thumbs.db
apps/daemon/target/
# Database
packages/database/drizzle/
# Hand-written data migrations in drizzle/*.sql are part of the repo — the
# schema itself is applied with `drizzle-kit push`, so only drizzle-kit's local
# snapshot files are noise.
packages/database/drizzle/meta/*_snapshot.json
# Common JS/TS
coverage/
@@ -36,3 +40,4 @@ build/
# Claude
.claude/
plans.md
+4
View File
@@ -3,3 +3,7 @@ dist
.turbo
pnpm-lock.yaml
apps/daemon/target
# Captured bring-up reports, not maintained sources — reflowing them would
# only churn a record of what happened.
conduit-bringup-artifacts
+751
View File
@@ -0,0 +1,751 @@
# Installation Guide
This guide covers three deployment methods:
1. **Development Setup** — for local development
2. **Docker Production** — single-command deployment with Docker Compose
3. **Manual Production** — step-by-step on Ubuntu 22.04+
---
## Prerequisites
### All Methods
- Git
- A PostgreSQL 16+ database (or use the included Docker Compose)
### Development
- **Node.js** 20+ ([nodejs.org](https://nodejs.org))
- **pnpm** 9.15+ (`corepack enable && corepack prepare pnpm@9.15.4 --activate`)
- **Rust** 1.83+ ([rustup.rs](https://rustup.rs))
- **protoc** (Protocol Buffers compiler) — required for the daemon's gRPC build
- **Docker** — for running PostgreSQL and Redis locally
### Docker Production
- **Docker** 24+ with Docker Compose v2
- At least **2 GB RAM** and **10 GB disk** for the panel itself
- Additional resources for game servers on daemon nodes
---
## 1. Development Setup
### 1.1 Clone and Install
```bash
git clone https://github.com/your-org/source-gamepanel.git
cd source-gamepanel
pnpm install
```
### 1.2 Environment Configuration
```bash
cp .env.example .env
```
Edit `.env` and set at minimum:
```env
# Generate secure secrets:
# node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWT_SECRET=<your-64-byte-hex>
JWT_REFRESH_SECRET=<another-64-byte-hex>
# Database (defaults work with docker-compose.dev.yml)
DATABASE_URL=postgresql://gamepanel:gamepanel@localhost:5432/gamepanel
```
### 1.3 Start Infrastructure
```bash
# Start PostgreSQL + Redis
docker compose -f docker-compose.dev.yml up -d
```
### 1.4 Database Setup
```bash
# Sync the schema from packages/database/src/schema, then apply the
# hand-written data migrations in packages/database/drizzle/*.sql
pnpm db:migrate
# Seed admin user and default games
pnpm db:seed
```
All three steps are idempotent, so re-running them after a `git pull` is the
normal way to pick up schema and default-game changes.
After seeding, you'll have:
- **Admin account**: `admin@gamepanel.local` / `admin123`
- **Games**: Minecraft Java & Bedrock, CS2, Terraria, Rust, Satisfactory,
FiveM, ARK: Survival Evolved
### 1.5 Start Development Servers
```bash
# Start API (port 3000) + Web (port 5173) via Turborepo
pnpm dev
```
The web dev server proxies `/api` and `/socket.io` requests to the API automatically.
Open **http://localhost:5173** in your browser.
### 1.6 Daemon (Optional)
The Rust daemon manages Docker containers on game server nodes. For development you can run it locally:
```bash
# Ensure protoc is installed
protoc --version # Should show libprotoc 3.x or higher
# If not installed:
# Ubuntu: sudo apt install protobuf-compiler
# macOS: brew install protobuf
# Windows: choco install protoc (or download from GitHub releases)
cd apps/daemon
cargo run
```
The daemon reads its config from `/etc/gamepanel/config.yml` or the path in `DAEMON_CONFIG` env var. For development, it falls back to defaults (API at localhost:3000, dev token).
### 1.7 Useful Commands
```bash
pnpm build # Build all packages
pnpm lint # ESLint across all packages
pnpm format # Prettier format
pnpm format:check # Check formatting without modifying
pnpm db:studio # Open Drizzle Studio (visual DB browser)
# Daemon
cd apps/daemon
cargo test # Run unit tests (3 tests: Minecraft parser, CS2 parser)
cargo clippy # Rust linter
cargo build --release # Production build
```
---
## 2. Docker Production Deployment
The whole panel comes up with two commands. TLS and domain handling are
deliberately **not** included — the panel serves plain HTTP and you put your own
reverse proxy in front of it (see 2.6).
### 2.1 Install
```bash
git clone https://github.com/your-org/source-gamepanel.git
cd source-gamepanel
./scripts/install.sh
docker compose up -d --build
```
`scripts/install.sh` generates `.env` with fresh secrets, writes a
`daemon-config.yml` with a matching node token, and creates the host data
directories. It never overwrites files that already exist, so it is safe to
re-run.
Then open `http://<server-ip>:80` and sign in with
`admin@gamepanel.local` / `admin123` — change the password immediately.
### 2.2 What gets started
| Service | Port | Description |
| ---------- | -------------------------- | -------------------------------------------------- |
| `postgres` | internal | PostgreSQL database |
| `redis` | internal | Rate limiting & cache |
| `migrate` | — | Applies the schema + seed, then exits |
| `api` | internal | Fastify REST API |
| `web` | `WEB_PORT` (80) | nginx + React SPA, proxies `/api` and `/socket.io` |
| `daemon` | `DAEMON_GRPC_PORT` (50051) | Rust gRPC daemon |
Only `web` and `daemon` publish ports. Postgres, Redis and the API stay on the
internal Compose network.
The `migrate` service runs on every `docker compose up`; all three of its steps
(`drizzle-kit push`, the data migrations, the seed) are idempotent.
### 2.3 Register the node
In the panel, create a node with:
| Field | Value |
| ------------ | -------------------------------------------------- |
| FQDN | `host.docker.internal` (or the host's IP/hostname) |
| gRPC port | the `DAEMON_GRPC_PORT` from `.env` |
| Daemon token | the `DAEMON_TOKEN` from `.env` |
### 2.4 Where game server files live
`DAEMON_DATA_PATH` in `.env` (default `/var/lib/gamepanel/servers`) is a **host**
directory. The daemon runs in a container but creates game containers through
the host's Docker socket, so their bind mounts are resolved by the host, not by
the daemon container.
That is why the same path is passed twice — once as the daemon's own bind mount
and once as `DAEMON_HOST_DATA_PATH`. If you change `DAEMON_DATA_PATH`, both
follow automatically. Do not replace the bind mount with a named volume: the
daemon and the game servers would then read and write two different
directories, and files edited in the panel would never reach the game.
### 2.5 Verify
```bash
docker compose ps
docker compose logs -f api
docker compose logs -f daemon
curl -s http://localhost/api/health
# {"status":"ok","timestamp":"..."}
```
### 2.6 TLS, domain and reverse proxy
The panel intentionally ships without certificate handling. Terminate TLS in
whatever proxy you already run and forward to `WEB_PORT`. WebSocket upgrades
must be forwarded too, otherwise the live console will not connect.
Set `CORS_ORIGIN` in `.env` to the exact origin users open in the browser, then
`docker compose up -d` to apply it.
Caddy (`Caddyfile`):
```
panel.example.com {
reverse_proxy 127.0.0.1:80
}
```
nginx:
```nginx
server {
listen 443 ssl;
server_name panel.example.com;
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
If the proxy runs on the same host, bind the panel to loopback only by setting
`WEB_PORT=127.0.0.1:8080` in `.env`.
### 2.7 Updating
```bash
git pull
docker compose up -d --build
```
The `migrate` service applies schema and seed changes on every start, so no
extra step is needed.
### 2.8 Upgrading from a pre-`install.sh` deployment
Older `docker-compose.yml` versions stored the daemon's server directory in a
named volume (`daemon_data`). That never matched what the game containers
actually used: their bind mounts were resolved by the host, so the real game
files ended up in `/var/lib/gamepanel/servers` on the host while the panel read
and wrote the named volume. Editing a config in the panel appeared to work and
then had no effect, and files could look like they reset themselves.
The compose file now bind-mounts the host directory directly, so after
upgrading, the panel sees the same files the game servers do. Nothing needs to
be moved — the game files were already on the host.
If you had put files into the old named volume through the panel and want them
back, copy them out before removing it:
```bash
docker run --rm -v gamepanel_daemon_data:/from -v /var/lib/gamepanel/servers:/to alpine sh -c 'cp -an /from/. /to/'
docker volume rm gamepanel_daemon_data gamepanel_daemon_backups
```
Also note that `postgres`, `redis` and `api` no longer publish host ports; only
`web` and `daemon` do. If you were proxying straight to `API_PORT`, point your
proxy at `WEB_PORT` instead — nginx forwards `/api` and `/socket.io`.
---
## 3. Manual Production Setup (Ubuntu 22.04+)
### 3.1 System Dependencies
```bash
sudo apt update && sudo apt upgrade -y
# Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# pnpm
corepack enable
corepack prepare pnpm@9.15.4 --activate
# PostgreSQL 16
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt update
sudo apt install -y postgresql-16
# Redis
sudo apt install -y redis-server
# Docker (for game containers)
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
# Rust (for daemon)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
# protoc (for gRPC)
sudo apt install -y protobuf-compiler
# nginx (reverse proxy)
sudo apt install -y nginx certbot python3-certbot-nginx
```
### 3.2 Database Setup
```bash
sudo -u postgres psql << 'EOF'
CREATE USER gamepanel WITH PASSWORD 'your-strong-password';
CREATE DATABASE gamepanel OWNER gamepanel;
GRANT ALL PRIVILEGES ON DATABASE gamepanel TO gamepanel;
EOF
```
### 3.3 Redis Configuration
```bash
sudo sed -i 's/# requirepass foobared/requirepass your-redis-password/' /etc/redis/redis.conf
sudo systemctl restart redis-server
```
### 3.4 Application Setup
```bash
# Clone
cd /opt
sudo git clone https://github.com/your-org/source-gamepanel.git
sudo chown -R $USER:$USER source-gamepanel
cd source-gamepanel
# Install
pnpm install
# Environment
cp .env.example .env
nano .env # Set all production values
# Build
pnpm build
# Database
pnpm db:migrate
pnpm db:seed
# Build daemon
cd apps/daemon
cargo build --release
sudo cp target/release/gamepanel-daemon /usr/local/bin/
```
### 3.5 Daemon Configuration
```bash
sudo mkdir -p /etc/gamepanel /var/lib/gamepanel/{servers,backups}
sudo tee /etc/gamepanel/config.yml << 'EOF'
api_url: "http://127.0.0.1:3000"
node_token: "generate-a-secure-token-here"
grpc_port: 50051
data_path: "/var/lib/gamepanel/servers"
backup_path: "/var/lib/gamepanel/backups"
docker:
socket: "/var/run/docker.sock"
network: "gamepanel_nw"
network_subnet: "172.18.0.0/16"
EOF
```
### 3.6 Systemd Services
**API Service:**
```bash
sudo tee /etc/systemd/system/gamepanel-api.service << 'EOF'
[Unit]
Description=GamePanel API
After=network.target postgresql.service redis-server.service
Requires=postgresql.service
[Service]
Type=simple
User=gamepanel
WorkingDirectory=/opt/source-gamepanel
ExecStart=/usr/bin/node apps/api/dist/index.js
Restart=always
RestartSec=5
EnvironmentFile=/opt/source-gamepanel/.env
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
EOF
```
**Daemon Service:**
```bash
sudo tee /etc/systemd/system/gamepanel-daemon.service << 'EOF'
[Unit]
Description=GamePanel Daemon
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
ExecStart=/usr/local/bin/gamepanel-daemon
Restart=always
RestartSec=5
Environment=DAEMON_CONFIG=/etc/gamepanel/config.yml
Environment=RUST_LOG=info
[Install]
WantedBy=multi-user.target
EOF
```
**Enable and start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now gamepanel-api
sudo systemctl enable --now gamepanel-daemon
```
### 3.7 Web Build + nginx
```bash
# Build the SPA
cd /opt/source-gamepanel/apps/web
pnpm build # outputs to dist/
# Copy to nginx
sudo mkdir -p /var/www/gamepanel
sudo cp -r dist/* /var/www/gamepanel/
```
**nginx site config:**
```bash
sudo tee /etc/nginx/sites-available/gamepanel << 'EOF'
server {
listen 80;
server_name panel.yourdomain.com;
root /var/www/gamepanel;
index index.html;
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
# API proxy
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Socket.IO
location /socket.io/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# Static assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/gamepanel /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
```
### 3.8 TLS with Let's Encrypt
```bash
sudo certbot --nginx -d panel.yourdomain.com
```
Certbot will automatically configure nginx for HTTPS and set up auto-renewal.
### 3.9 Firewall
```bash
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw allow 50051/tcp # gRPC (daemon)
# Open game server port ranges as needed:
sudo ufw allow 25565/tcp # Minecraft
sudo ufw allow 27015/tcp # CS2
sudo ufw enable
```
---
## 4. Control Panel Deployment (pre-built images)
Sections 2 and 3 build from a checkout on the server. A control panel does not
have one: it writes a compose file and an `.env` into its own project directory
and runs `docker compose up`. Anything with a `build:` stanza fails there —
the build context simply is not on disk.
`docker-compose.panel.yml` exists for that case. Every service references a
published image, so the stack installs on a server that has never seen this
repository. It was written against [WebPanel](https://gits.hibna.com.tr/hibna/Source-WebPanel)
but nothing in it is panel-specific.
### 4.1 Publish the images
`.github/workflows/ci.yml` pushes four images to this Gitea instance's own
container registry on every `v*` tag:
| Image | Contents |
| ------------------- | -------------------------------------------------------------------- |
| `gamepanel-api` | Fastify API |
| `gamepanel-migrate` | The API Dockerfile's `migrate` stage, run once before the API starts |
| `gamepanel-web` | SPA + nginx, built with `VITE_API_URL=/api` |
| `gamepanel-daemon` | Rust daemon |
Add a `REGISTRY_TOKEN` repository secret with package write scope, then:
```bash
git tag v0.1.0 && git push origin v0.1.0
```
### 4.2 Prepare the host
```bash
sudo mkdir -p /etc/gamepanel /var/lib/gamepanel/servers /var/lib/gamepanel/backups
sudo cp daemon-config.yml /etc/gamepanel/daemon-config.yml
sudo sed -i 's/CHANGE_ME_GENERATE_A_SECURE_TOKEN/'"$(openssl rand -hex 32)"'/' /etc/gamepanel/daemon-config.yml
```
Note the token you generated — the panel needs the same value when you register
the node. If the panel has a file manager, both steps can be done from it.
### 4.3 Install
Paste `docker-compose.panel.yml` into the panel's custom-compose screen and set:
| Variable | Example | Notes |
| ---------------------------------- | --------------------------- | ------------------------------------------------ |
| `REGISTRY` | `gits.hibna.com.tr/hibna` | Namespace holding the four images |
| `TAG` | `v0.1.0` | The tag you pushed |
| `HOST_PORT` | `8096` | **Not 80** if the panel's own web server owns it |
| `DB_PASSWORD`, `REDIS_PASSWORD` | `openssl rand -hex 24` | |
| `JWT_SECRET`, `JWT_REFRESH_SECRET` | `openssl rand -hex 64` | |
| `CORS_ORIGIN` | `https://panel.example.com` | Must match the address the browser uses |
The published port is called `HOST_PORT` because panels commonly reverse-proxy
"the" port of an installation and need to know which one that is when a stack
publishes more than one.
If the registry is private, the host needs `docker login` once — panels pull
anonymously otherwise. On Gitea the package can also be made public while the
repository stays private.
### 4.4 Notes
- **The daemon holds the Docker socket.** That is root-equivalent access to
every container on the machine, the panel's own containers included. Running
the daemon on a separate node — which the multi-node architecture is built
for — keeps the game hosts and the control plane apart.
- **Nothing publishes gRPC on a single host.** The API reaches the daemon over
the compose network as `daemon:50051`. A remote node runs the `daemon`
service on its own machine and publishes `50051` there.
- **Game server ports** are opened by the daemon on the host; a panel with a
default-deny firewall needs an explicit rule for the range you hand out.
---
## Post-Installation
### First Login
1. Open your panel URL in a browser
2. Login with: `admin@gamepanel.local` / `admin123`
3. **Immediately change the admin password** via account settings
### Create Your First Server
1. **Create an Organization** — Click "New Organization" on the home page
2. **Add a Node** — Go to Nodes, add your daemon node (FQDN + ports)
3. **Add Allocations** — Assign IP:port pairs to the node
4. **Create a Server** — Use the creation wizard: pick a game, node, and resources
5. **Start the Server** — Use the power controls on the console page
### Adding a Remote Daemon Node
On the remote machine:
```bash
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install the daemon binary
scp user@panel-server:/usr/local/bin/gamepanel-daemon /usr/local/bin/
# Configure
mkdir -p /etc/gamepanel /var/lib/gamepanel/{servers,backups}
cat > /etc/gamepanel/config.yml << EOF
api_url: "https://panel.yourdomain.com"
node_token: "<token-from-panel>"
grpc_port: 50051
EOF
# Create systemd service (same as above)
# Start it
systemctl enable --now gamepanel-daemon
```
Then add the node in the panel with the remote machine's FQDN.
---
## Troubleshooting
### API won't start
- Check `DATABASE_URL` is correct and PostgreSQL is running
- Ensure migrations have been applied: `pnpm db:migrate`
- Check logs: `journalctl -u gamepanel-api -f` or `docker compose logs api`
### Daemon can't connect
- Verify `api_url` in daemon config points to the API
- Check `node_token` matches what's stored in the panel's nodes table
- Ensure the daemon's gRPC port (50051) is open
### Web shows blank page
- Build the SPA: `pnpm --filter @source/web build`
- Check nginx config: `sudo nginx -t`
- Verify API proxy is working: `curl http://localhost:3000/api/health`
### Docker permission denied
- Ensure the daemon user is in the `docker` group: `usermod -aG docker <user>`
- Or run the daemon with appropriate privileges
### protoc not found (daemon build)
- Ubuntu: `sudo apt install protobuf-compiler`
- macOS: `brew install protobuf`
- Or download from [github.com/protocolbuffers/protobuf/releases](https://github.com/protocolbuffers/protobuf/releases)
---
## Updating
### Docker
```bash
cd /opt/source-gamepanel
git pull
docker compose up -d --build
```
### Manual
```bash
cd /opt/source-gamepanel
git pull
pnpm install
pnpm build
pnpm db:migrate
# Rebuild daemon
cd apps/daemon && cargo build --release
sudo cp target/release/gamepanel-daemon /usr/local/bin/
# Rebuild web
cd ../web && pnpm build
sudo cp -r dist/* /var/www/gamepanel/
# Restart services
sudo systemctl restart gamepanel-api gamepanel-daemon
sudo systemctl reload nginx
```
---
## Environment Variables Reference
| Variable | Default | Description |
| ---------------------- | --------------------------- | --------------------------------------- |
| `DATABASE_URL` | — | PostgreSQL connection string |
| `DB_USER` | `gamepanel` | PostgreSQL username (Docker) |
| `DB_PASSWORD` | `gamepanel` | PostgreSQL password (Docker) |
| `DB_NAME` | `gamepanel` | Database name (Docker) |
| `DB_PORT` | `5432` | PostgreSQL exposed port |
| `REDIS_URL` | — | Redis connection string |
| `REDIS_PASSWORD` | `gamepanel` | Redis password |
| `PORT` | `3000` | API listen port |
| `HOST` | `0.0.0.0` | API listen host |
| `NODE_ENV` | `development` | Environment mode |
| `JWT_SECRET` | — | **Required.** Access token signing key |
| `JWT_REFRESH_SECRET` | — | **Required.** Refresh token signing key |
| `CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin |
| `RATE_LIMIT_MAX` | `100` | Max requests per window |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit window (ms) |
| `WEB_PORT` | `80` | Web nginx exposed port |
| `API_PORT` | `3000` | API exposed port (Docker) |
| `DAEMON_CONFIG` | `/etc/gamepanel/config.yml` | Daemon config file path |
| `DAEMON_GRPC_PORT` | `50051` | Daemon gRPC exposed port |
+316
View File
@@ -0,0 +1,316 @@
# GamePanel
Modern, open-source game server management panel built with a multi-tenant SaaS architecture. Inspired by Pterodactyl, enhanced with features like plugin management, visual task scheduler, live player tracking, and an in-browser config editor.
---
## Features
### Core
- **Multi-Tenant Organizations** — Isolated environments with role-based access control (Admin / User + custom JSONB permissions)
- **Docker Container Management** — Full lifecycle: create, start, stop, restart, kill, delete
- **Multi-Node Architecture** — Distribute game servers across multiple daemon nodes with health monitoring
- **Live Console** — xterm.js terminal with Socket.IO streaming, command history support
- **File Manager** — Browse, view, edit, create, and delete server files with path jail security
- **Server Creation Wizard** — 3-step guided flow: Basic Info, Node & Allocation, Resources
### Game-Specific
- **Config Editor** — Tab-based UI with parsers for `.properties`, `.json`, `.yaml`, and Source Engine `.cfg` formats
- **Plugin Management** — Spiget API integration for Minecraft, manual install for other games, toggle/uninstall
- **Player Tracking** — Live player list via RCON protocol (Minecraft `list`, CS2 `status`)
### Advanced
- **Scheduled Tasks** — Visual scheduler with interval, daily, weekly, and cron expression support
- **Backup System** — Create, restore, lock/unlock, delete backups with CDN storage integration
- **Audit Logging** — Track all actions across the panel with user, server, and IP metadata
### Operations
- **Rate Limiting** — Configurable per-window request limits
- **Security Headers** — Helmet.js with CSP, XSS protection, content-type sniffing prevention
- **Health Checks** — Built-in endpoints for all services
- **CI/CD** — GitHub Actions pipeline for lint, test, and Docker build
---
## Architecture
```
Browser ─── HTTPS + Socket.IO ──→ Web (React SPA / nginx)
REST + WS
API (Fastify + JWT)
│ │
PostgreSQL gRPC (protobuf)
Daemon (Rust + tonic) × N nodes
Docker API
Game Containers
```
The API acts as a **gateway** between the frontend and daemon nodes. The frontend never communicates directly with daemons.
---
## Tech Stack
| Component | Technology |
| -------------- | ---------------------------------------------- |
| Monorepo | Turborepo + pnpm |
| Frontend | React 19 + Vite 6 + Tailwind CSS 3 + shadcn/ui |
| Backend API | Fastify 5 + TypeBox validation |
| Daemon | Rust + tonic gRPC + bollard (Docker) + tokio |
| Database | PostgreSQL 16 + Drizzle ORM |
| Auth | JWT (access + refresh) + Argon2id |
| Realtime | Socket.IO (frontend ↔ API) |
| Panel ↔ Daemon | gRPC with protobuf |
| Containers | Docker |
| CI/CD | GitHub Actions |
---
## Monorepo Structure
```
source-gamepanel/
├── apps/
│ ├── api/ # Fastify REST API
│ │ ├── src/
│ │ │ ├── index.ts # App entry, plugin registration
│ │ │ ├── plugins/ # DB, auth plugins
│ │ │ ├── lib/ # Errors, JWT, permissions, pagination,
│ │ │ │ config parsers, Spiget client, schedule utils
│ │ │ └── routes/
│ │ │ ├── auth/ # Register, login, refresh, logout, me
│ │ │ ├── organizations/ # CRUD + members
│ │ │ ├── nodes/ # CRUD + allocations
│ │ │ ├── servers/ # CRUD + power, config, plugins, backups, schedules
│ │ │ └── admin/ # Users, games, audit logs (super admin)
│ │ └── Dockerfile
│ │
│ ├── web/ # React SPA
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── ui/ # 13 shadcn/ui components
│ │ │ │ ├── layout/ # AppLayout, ServerLayout, Sidebar, Header
│ │ │ │ ├── server/ # PowerControls
│ │ │ │ └── error-boundary.tsx
│ │ │ ├── pages/
│ │ │ │ ├── auth/ # Login, Register
│ │ │ │ ├── dashboard/ # Stats + server list
│ │ │ │ ├── server/ # Console, Files, Config, Plugins,
│ │ │ │ │ Backups, Schedules, Players, Settings
│ │ │ │ ├── servers/ # Create wizard
│ │ │ │ ├── nodes/ # List + detail (health dashboard)
│ │ │ │ ├── organizations/ # Org list + create
│ │ │ │ ├── admin/ # Users, Games, Audit logs
│ │ │ │ └── settings/ # Members
│ │ │ ├── lib/ # API client, socket, utils
│ │ │ ├── stores/ # Zustand auth store
│ │ │ └── hooks/ # Theme hook
│ │ ├── nginx.conf
│ │ └── Dockerfile
│ │
│ └── daemon/ # Rust daemon
│ ├── src/
│ │ ├── main.rs # gRPC server, heartbeat, scheduler init
│ │ ├── config.rs # YAML config loader
│ │ ├── auth.rs # gRPC token interceptor
│ │ ├── grpc/ # Service implementations
│ │ ├── docker/ # Container lifecycle (bollard)
│ │ ├── server/ # State machine, manager
│ │ ├── filesystem/ # Path jail, CRUD operations
│ │ ├── game/ # RCON client, Minecraft, CS2 modules
│ │ ├── scheduler/ # Task polling + execution
│ │ └── backup/ # tar.gz, CDN upload/download, restore
│ ├── Cargo.toml
│ └── Dockerfile
├── packages/
│ ├── database/ # Drizzle schema + migrations + seed
│ │ └── src/schema/ # 10 tables: users, orgs, nodes, servers,
│ │ allocations, games, backups, plugins,
│ │ schedules, audit_logs
│ ├── shared/ # Types, permissions, roles
│ ├── proto/ # daemon.proto (gRPC service definition)
│ └── ui/ # Base UI utilities (cn, cva)
├── docker-compose.yml # Full production stack
├── docker-compose.dev.yml # Dev: PostgreSQL + Redis only
├── daemon-config.yml # Daemon configuration template
├── .env.example # Environment variables reference
├── .github/workflows/ci.yml # CI/CD pipeline
├── turbo.json
└── pnpm-workspace.yaml
```
---
## Supported Games
| Game | Docker Image | Default Port | Config Format | Plugin Support |
| -------------------------- | ------------------------------- | ------------------------------------------- | ---------------------------------- | ------------------- |
| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual |
| Counter-Strike 2 | `cm2network/cs2` | 27015 | Source `.cfg` (keyvalue) | Manual |
| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — |
| Terraria | `ryshe/terraria` | 7777 | keyvalue | — |
| Rust | `didstopia/rust-server` | 28015 | — | — |
| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — |
| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — |
| ARK: Survival Evolved | `hermsi/ark-server` | 7777/udp + 7778/udp + 27015/udp + 27020/tcp | `GameUserSettings.ini`, `Game.ini` | — |
Most games only need a database seed entry: the container mount point, the
in-game stop command and the shutdown budget are all columns on `games`, so no
daemon change is required for a new image. Games whose process ignores stdin
(Source engine, ARK) get their console commands over RCON automatically.
---
## API Endpoints
### Auth
| Method | Path | Description |
| ------ | -------------------- | ------------------------------------ |
| POST | `/api/auth/register` | Create account |
| POST | `/api/auth/login` | Login (returns JWT + refresh cookie) |
| POST | `/api/auth/refresh` | Refresh access token |
| POST | `/api/auth/logout` | Invalidate session |
| GET | `/api/auth/me` | Current user profile |
### Organizations
| Method | Path | Description |
| ---------------- | ----------------------------------- | ----------------- |
| GET | `/api/organizations` | List user's orgs |
| POST | `/api/organizations` | Create org |
| GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD |
| GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management |
### Servers
| Method | Path | Description |
| --------------------- | ------------------------------------------- | --------------------------------------- |
| GET/POST | `.../servers` | List / create |
| GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD |
| POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) |
| GET/PUT | `.../servers/:serverId/config` | Config read/write |
| GET/POST/DELETE | `.../servers/:serverId/plugins` | Plugin management |
| GET/POST/DELETE | `.../servers/:serverId/backups` | Backup management |
| POST | `.../servers/:serverId/backups/:id/restore` | Restore backup |
| GET/POST/PATCH/DELETE | `.../servers/:serverId/schedules` | Scheduled tasks |
### Admin (Super Admin only)
| Method | Path | Description |
| -------- | ----------------------- | --------------- |
| GET | `/api/admin/users` | All users |
| GET/POST | `/api/admin/games` | Game management |
| GET | `/api/admin/audit-logs` | Audit trail |
---
## Permission System
Dot-notation permissions with hybrid RBAC (role defaults + per-user JSONB overrides):
```
server.create server.read server.update server.delete
console.read console.write
files.read files.write files.delete files.archive
backup.read backup.create backup.restore backup.delete backup.manage
schedule.read schedule.manage
plugin.read plugin.manage
config.read config.write
power.start power.stop power.restart power.kill
node.read node.manage
org.settings org.members
subuser.read subuser.manage
```
---
## Quick Start
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions.
```bash
# Clone
git clone https://github.com/your-org/source-gamepanel.git
cd source-gamepanel
# Environment
cp .env.example .env
# Edit .env — set JWT_SECRET and JWT_REFRESH_SECRET
# Start infrastructure
docker compose -f docker-compose.dev.yml up -d
# Install dependencies
pnpm install
# Run migrations and seed
pnpm db:migrate
pnpm db:seed
# Start development
pnpm dev
```
Open `http://localhost:5173` — login with `admin@gamepanel.local` / `admin123`.
---
## Production Deployment
```bash
git clone https://github.com/your-org/source-gamepanel.git
cd source-gamepanel
# Generates .env with fresh secrets + daemon-config.yml, creates data dirs
./scripts/install.sh
# Builds and starts everything; schema migration and seeding run automatically
docker compose up -d --build
```
Open `http://<server-ip>` and sign in with `admin@gamepanel.local` / `admin123`.
The panel serves plain HTTP on `WEB_PORT` (default 80) and does **not** manage
TLS or domains — put your own reverse proxy in front of it and set
`CORS_ORIGIN` to the origin users actually open. See
[INSTALLATION.md](INSTALLATION.md#26-tls-domain-and-reverse-proxy) for Caddy and
nginx examples.
---
## Development
```bash
pnpm dev # Start all services (API + Web + DB)
pnpm build # Build all packages
pnpm lint # Lint all packages
pnpm format # Format with Prettier
pnpm db:studio # Open Drizzle Studio (DB browser)
pnpm db:generate # Generate migration files
pnpm db:migrate # Apply migrations
pnpm db:seed # Seed admin user + games
# Daemon (separate terminal)
cd apps/daemon
cargo run # Requires protoc installed
cargo test # Run unit tests
cargo clippy # Lint Rust code
```
---
## License
This project is private. All rights reserved.
+86
View File
@@ -0,0 +1,86 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# --- Dependencies ---
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY packages/database/package.json packages/database/
COPY packages/proto/package.json packages/proto/
COPY packages/shared/package.json packages/shared/
COPY packages/ui/package.json packages/ui/
# pnpm creates no node_modules for a workspace package that has no
# dependencies of its own, and @source/shared has none. The COPY lines below
# name that path, so give them an empty directory to find instead of failing
# the build on a path pnpm never made.
RUN pnpm install --frozen-lockfile --prod=false && mkdir -p packages/shared/node_modules
# --- Type check ---
# Not an artifact producer: tsconfig.base.json sets noEmit, so this stage only
# proves the sources compile. The runtime stages below run TypeScript directly.
FROM base AS typecheck
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY . .
RUN pnpm --filter @source/shared build && \
pnpm --filter @source/database build && \
pnpm --filter @source/api build
# --- Migrate + seed (one-shot) ---
# Schema comes from `drizzle-kit push` against src/schema, then the repo's
# data migrations, then the idempotent seed. All three are safe to re-run, so
# this container can start on every `docker compose up`.
FROM base AS migrate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared ./packages/shared
COPY packages/database ./packages/database
WORKDIR /app/packages/database
CMD ["sh", "-c", "pnpm exec drizzle-kit push --force && pnpm exec tsx src/migrate.ts && pnpm exec tsx src/seed.ts"]
# --- Production ---
#
# Runs the TypeScript sources through tsx rather than a compiled bundle, the
# same way the migrate stage above already does.
#
# The workspace packages are consumed as TypeScript: every @source/* package
# points `main` at ./src/index.ts, which is what lets `pnpm dev` and Vite read
# them without a build step. A compiled entry point would resolve those bare
# imports to TypeScript files Node cannot load, and @source/proto derives the
# path of daemon.proto from its own module URL — a rule written for
# `/src/index.ts`. Following that decision here keeps one resolution model for
# development and production instead of two that disagree.
FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY pnpm-workspace.yaml package.json ./
COPY apps/api ./apps/api
COPY packages/database ./packages/database
COPY packages/proto ./packages/proto
COPY packages/shared ./packages/shared
EXPOSE 3000
# 127.0.0.1, not localhost: musl resolves localhost to ::1 first and the
# server binds IPv4, so the probe was refused on every run and the
# container never left the unhealthy state.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
# From the package directory, the same way `pnpm dev` runs it: pnpm's isolated
# node_modules puts tsx in apps/api/node_modules/.bin, not in the workspace
# root, so `pnpm exec` only finds it here.
WORKDIR /app/apps/api
CMD ["pnpm", "exec", "tsx", "src/index.ts"]
+18 -2
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "dotenv -e ../../.env -- tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/"
@@ -12,17 +12,33 @@
"dependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^9.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.0.0",
"@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0",
"@sinclair/typebox": "^0.34.0",
"@source/cdn": "1.4.0",
"@source/database": "workspace:*",
"@source/proto": "workspace:*",
"@source/shared": "workspace:*",
"argon2": "^0.41.0",
"drizzle-orm": "^0.38.0",
"fastify": "^5.2.0",
"fastify-plugin": "^5.0.0",
"pino-pretty": "^13.0.0",
"socket.io": "^4.8.0"
"socket.io": "^4.8.0",
"tar-stream": "^3.1.7",
"unzipper": "^0.12.3",
"yazl": "^3.3.1"
},
"devDependencies": {
"@types/tar-stream": "^3.1.4",
"@types/unzipper": "^0.10.11",
"@types/yazl": "^3.3.0",
"dotenv-cli": "^8.0.0",
"tsx": "^4.19.0"
}
}
+87 -4
View File
@@ -1,26 +1,109 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import dbPlugin from './plugins/db.js';
import authPlugin from './plugins/auth.js';
import socketPlugin from './plugins/socket.js';
import authRoutes from './routes/auth/index.js';
import organizationRoutes from './routes/organizations/index.js';
import internalRoutes from './routes/internal/index.js';
import daemonNodeRoutes from './routes/nodes/daemon.js';
import nodeRoutes from './routes/nodes/index.js';
import serverRoutes from './routes/servers/index.js';
import adminRoutes from './routes/admin/index.js';
import gameRoutes from './routes/games/index.js';
import { AppError } from './lib/errors.js';
const app = Fastify({
logger: {
transport: {
target: 'pino-pretty',
},
transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,
},
});
// Security plugins
await app.register(helmet, {
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? undefined : false,
});
await app.register(cors, {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
});
await app.register(cookie);
await app.register(rateLimit, {
max: Number(process.env.RATE_LIMIT_MAX) || 100,
timeWindow: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000,
});
await app.register(cookie);
await app.register(dbPlugin);
await app.register(authPlugin);
await app.register(socketPlugin);
// Error handler
app.setErrorHandler(
(
error: Error & { validation?: unknown; statusCode?: number; code?: string },
_request,
reply,
) => {
if (error instanceof AppError) {
return reply.code(error.statusCode).send({
error: error.name,
message: error.message,
code: error.code,
});
}
// Fastify validation errors
if (error.validation) {
return reply.code(400).send({
error: 'Validation Error',
message: error.message,
});
}
// Rate limit errors
if (error.statusCode === 429) {
return reply.code(429).send({
error: 'Too Many Requests',
message: 'Rate limit exceeded, please try again later',
});
}
app.log.error(error);
return reply.code(error.statusCode ?? 500).send({
error: 'Internal Server Error',
message:
process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message,
});
},
);
// Routes
app.get('/api/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
await app.register(authRoutes, { prefix: '/api/auth' });
await app.register(organizationRoutes, { prefix: '/api/organizations' });
await app.register(adminRoutes, { prefix: '/api/admin' });
await app.register(gameRoutes, { prefix: '/api/games' });
await app.register(daemonNodeRoutes, { prefix: '/api/nodes' });
await app.register(internalRoutes, { prefix: '/api/internal' });
// Nested org routes: nodes and servers are scoped to an org
await app.register(
async (orgScope) => {
await orgScope.register(nodeRoutes, { prefix: '/nodes' });
await orgScope.register(serverRoutes, { prefix: '/servers' });
},
{ prefix: '/api/organizations/:orgId' },
);
// Start
const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '0.0.0.0';
+23
View File
@@ -0,0 +1,23 @@
import type { FastifyRequest } from 'fastify';
import { auditLogs } from '@source/database';
import type { Database } from '@source/database';
export async function createAuditLog(
db: Database,
request: FastifyRequest,
data: {
organizationId: string;
action: string;
serverId?: string;
metadata?: Record<string, unknown>;
},
) {
await db.insert(auditLogs).values({
organizationId: data.organizationId,
userId: request.user.sub,
serverId: data.serverId,
action: data.action,
metadata: data.metadata ?? {},
ipAddress: request.ip,
});
}
+195
View File
@@ -0,0 +1,195 @@
import { CdnClient, CdnError, type FileInfo } from '@source/cdn';
import { AppError } from './errors.js';
const DEFAULT_PLUGIN_BUCKET = 'gamepanel-plugin-artifacts';
const DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS = 900;
const ARTIFACT_POINTER_PREFIX = 'cdn://file/';
let cachedClient: CdnClient | null = null;
let cachedFingerprint: string | null = null;
function envValue(name: string): string | null {
const value = process.env[name];
if (!value) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
const baseUrl = envValue('CDN_BASE_URL');
const apiKey = envValue('CDN_API_KEY');
if (!baseUrl || !apiKey) return null;
return { baseUrl, apiKey };
}
function getArtifactAccessTtlSeconds(): number {
const raw = Number(
process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS,
);
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
return Math.floor(raw);
}
function getOrCreateClient(): CdnClient | null {
const config = getCdnConfig();
if (!config) return null;
const fingerprint = `${config.baseUrl}::${config.apiKey}`;
if (cachedClient && cachedFingerprint === fingerprint) return cachedClient;
cachedClient = new CdnClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
timeoutMs: 45_000,
retry: {
retries: 2,
retryDelayMs: 250,
maxRetryDelayMs: 2_000,
},
});
cachedFingerprint = fingerprint;
return cachedClient;
}
function requireClient(): CdnClient {
const client = getOrCreateClient();
if (!client) {
throw new AppError(
500,
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
'CDN_NOT_CONFIGURED',
);
}
return client;
}
function toCdnAppError(error: unknown, fallbackMessage: string, fallbackCode: string): AppError {
if (error instanceof AppError) return error;
if (error instanceof CdnError) {
return new AppError(502, `CDN error: ${error.message}`, fallbackCode);
}
return new AppError(502, fallbackMessage, fallbackCode);
}
export function getPluginBucketName(): string {
return envValue('CDN_PLUGIN_BUCKET') ?? DEFAULT_PLUGIN_BUCKET;
}
export async function ensurePrivatePluginBucket(): Promise<string> {
const client = requireClient();
const bucketName = getPluginBucketName();
try {
const bucket = await client.getBucket(bucketName);
if (bucket.isPublic) {
await client.updateBucket(bucketName, { isPublic: false });
}
return bucketName;
} catch (error) {
if (error instanceof CdnError && error.statusCode === 404) {
try {
await client.createBucket(bucketName, {
description: 'GamePanel plugin artifacts',
isPublic: false,
});
return bucketName;
} catch (createError) {
throw toCdnAppError(
createError,
'Failed to create CDN plugin bucket',
'CDN_BUCKET_CREATE_FAILED',
);
}
}
throw toCdnAppError(error, 'Failed to fetch CDN plugin bucket', 'CDN_BUCKET_READ_FAILED');
}
}
export function buildCdnArtifactPointer(fileId: string): string {
return `${ARTIFACT_POINTER_PREFIX}${fileId}`;
}
export function parseCdnArtifactPointer(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith(ARTIFACT_POINTER_PREFIX)) {
const id = trimmed.slice(ARTIFACT_POINTER_PREFIX.length).trim();
return id.length > 0 ? id : null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol === 'cdn:' && parsed.hostname === 'file') {
const candidate = parsed.pathname.replace(/^\/+/, '').trim();
return candidate.length > 0 ? candidate : null;
}
} catch {
return null;
}
return null;
}
export async function uploadPluginArtifact(
content: Uint8Array,
filename: string,
metadata: Record<string, unknown> = {},
): Promise<{ bucket: string; file: FileInfo; artifactPointer: string }> {
const client = requireClient();
const bucket = await ensurePrivatePluginBucket();
try {
const file = await client.upload(content, {
bucket,
filename,
metadata,
});
return {
bucket,
file,
artifactPointer: buildCdnArtifactPointer(file.id),
};
} catch (error) {
throw toCdnAppError(error, 'Failed to upload artifact to CDN', 'CDN_UPLOAD_FAILED');
}
}
export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<string> {
const fileId = parseCdnArtifactPointer(artifactUrl);
if (!fileId) return artifactUrl;
const client = requireClient();
const config = getCdnConfig();
const ttl = getArtifactAccessTtlSeconds();
try {
const access = await client.getFileAccessUrl(fileId, ttl);
if (!access.url || typeof access.url !== 'string') {
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
}
const resolvedUrl = access.url.trim();
if (!resolvedUrl) {
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
}
if (/^https?:\/\//i.test(resolvedUrl)) {
return resolvedUrl;
}
if (!config) {
throw new AppError(
500,
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
'CDN_NOT_CONFIGURED',
);
}
return new URL(resolvedUrl, config.baseUrl).toString();
} catch (error) {
throw toCdnAppError(error, 'Failed to get temporary CDN access URL', 'CDN_ACCESS_URL_FAILED');
}
}
+234
View File
@@ -0,0 +1,234 @@
import type { ConfigParser, ConfigEntry } from '@source/shared';
/**
* Parse a config file content into key-value entries based on the parser type.
*/
export function parseConfig(content: string, parser: ConfigParser): ConfigEntry[] {
switch (parser) {
case 'properties':
return parseProperties(content);
case 'json':
return parseJson(content);
case 'yaml':
return parseYaml(content);
case 'keyvalue':
return parseKeyValue(content);
default:
return [];
}
}
/**
* Serialize key-value entries back into a config file content.
*/
export function serializeConfig(
entries: ConfigEntry[],
parser: ConfigParser,
originalContent?: string,
): string {
switch (parser) {
case 'properties':
return serializeProperties(entries, originalContent);
case 'json':
return serializeJson(entries);
case 'yaml':
return serializeYaml(entries, originalContent);
case 'keyvalue':
return serializeKeyValue(entries, originalContent);
default:
return '';
}
}
// === Properties (Java .properties format) ===
function parseProperties(content: string): ConfigEntry[] {
const entries: ConfigEntry[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
entries.push({
key: trimmed.substring(0, eqIndex).trim(),
value: trimmed.substring(eqIndex + 1).trim(),
});
}
return entries;
}
function serializeProperties(entries: ConfigEntry[], originalContent?: string): string {
if (!originalContent) {
return entries.map((e) => `${e.key}=${e.value}`).join('\n') + '\n';
}
const entryMap = new Map(entries.map((e) => [e.key, e.value]));
const lines = originalContent.split('\n');
const result: string[] = [];
const written = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) {
result.push(line);
continue;
}
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) {
result.push(line);
continue;
}
const key = trimmed.substring(0, eqIndex).trim();
if (entryMap.has(key)) {
result.push(`${key}=${entryMap.get(key)}`);
written.add(key);
} else {
result.push(line);
}
}
// Append new keys
for (const entry of entries) {
if (!written.has(entry.key)) {
result.push(`${entry.key}=${entry.value}`);
}
}
return result.join('\n');
}
// === JSON ===
function parseJson(content: string): ConfigEntry[] {
try {
const obj = JSON.parse(content);
if (typeof obj !== 'object' || Array.isArray(obj)) return [];
return Object.entries(obj).map(([key, value]) => ({
key,
value: typeof value === 'string' ? value : JSON.stringify(value),
}));
} catch {
return [];
}
}
function serializeJson(entries: ConfigEntry[]): string {
const obj: Record<string, unknown> = {};
for (const entry of entries) {
try {
obj[entry.key] = JSON.parse(entry.value);
} catch {
obj[entry.key] = entry.value;
}
}
return JSON.stringify(obj, null, 2) + '\n';
}
// === YAML (simplified — only top-level key: value) ===
function parseYaml(content: string): ConfigEntry[] {
const entries: ConfigEntry[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// Only handle top-level keys (no indentation)
if (line.startsWith(' ') || line.startsWith('\t')) continue;
const colonIndex = trimmed.indexOf(':');
if (colonIndex === -1) continue;
const key = trimmed.substring(0, colonIndex).trim();
const value = trimmed.substring(colonIndex + 1).trim();
if (key) entries.push({ key, value });
}
return entries;
}
function serializeYaml(entries: ConfigEntry[], originalContent?: string): string {
if (!originalContent) {
return entries.map((e) => `${e.key}: ${e.value}`).join('\n') + '\n';
}
const entryMap = new Map(entries.map((e) => [e.key, e.value]));
const lines = originalContent.split('\n');
const result: string[] = [];
const written = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || line.startsWith(' ') || line.startsWith('\t')) {
result.push(line);
continue;
}
const colonIndex = trimmed.indexOf(':');
if (colonIndex === -1) {
result.push(line);
continue;
}
const key = trimmed.substring(0, colonIndex).trim();
if (entryMap.has(key)) {
result.push(`${key}: ${entryMap.get(key)}`);
written.add(key);
} else {
result.push(line);
}
}
for (const entry of entries) {
if (!written.has(entry.key)) {
result.push(`${entry.key}: ${entry.value}`);
}
}
return result.join('\n');
}
// === KeyValue (Source engine cfg: `key "value"` or `key value`) ===
function parseKeyValue(content: string): ConfigEntry[] {
const entries: ConfigEntry[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
// Match: key "value" or key value
const match = trimmed.match(/^(\S+)\s+"([^"]*)"/) || trimmed.match(/^(\S+)\s+(.*)/);
if (match && match[1] && match[2] !== undefined) {
entries.push({ key: match[1], value: match[2] });
}
}
return entries;
}
function serializeKeyValue(entries: ConfigEntry[], originalContent?: string): string {
if (!originalContent) {
return entries.map((e) => `${e.key} "${e.value}"`).join('\n') + '\n';
}
const entryMap = new Map(entries.map((e) => [e.key, e.value]));
const lines = originalContent.split('\n');
const result: string[] = [];
const written = new Set<string>();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) {
result.push(line);
continue;
}
const match = trimmed.match(/^(\S+)\s+/);
const matchKey = match?.[1];
if (matchKey && entryMap.has(matchKey)) {
result.push(`${matchKey} "${entryMap.get(matchKey)}"`);
written.add(matchKey);
} else {
result.push(line);
}
}
for (const entry of entries) {
if (!written.has(entry.key)) {
result.push(`${entry.key} "${entry.value}"`);
}
}
return result.join('\n');
}
+961
View File
@@ -0,0 +1,961 @@
import grpc from '@grpc/grpc-js';
import protoLoader from '@grpc/proto-loader';
import type { PowerAction } from '@source/shared';
import { PROTO_PATH } from '@source/proto';
export interface DaemonNodeConnection {
fqdn: string;
grpcPort: number;
daemonToken: string;
}
export interface DaemonPortMapping {
host_port: number;
container_port: number;
protocol: 'tcp' | 'udp';
}
export interface DaemonCreateServerRequest {
uuid: string;
docker_image: string;
memory_limit: number;
disk_limit: number;
cpu_limit: number;
startup_command: string;
environment: Record<string, string>;
ports: DaemonPortMapping[];
install_plugin_urls: string[];
data_path: string;
stop_command: string;
stop_timeout_seconds: number;
}
export interface DaemonUpdateServerRequest {
uuid: string;
docker_image: string;
memory_limit: number;
disk_limit: number;
cpu_limit: number;
startup_command: string;
environment: Record<string, string>;
ports: DaemonPortMapping[];
data_path: string;
stop_command: string;
stop_timeout_seconds: number;
}
export interface DaemonPowerOptions {
/** In-game shutdown command; lets the daemon skip the SIGTERM wait entirely. */
stopCommand?: string | null;
/** Total graceful-shutdown budget in seconds. */
stopTimeoutSeconds?: number | null;
}
interface DaemonServerResponse {
uuid: string;
status: string;
}
interface DaemonManagedDatabaseCredentialsRaw {
database_name: string;
username: string;
password: string;
host: string;
port: number;
phpmyadmin_url: string;
}
interface DaemonNodeStatusRaw {
version: string;
is_healthy: boolean;
uptime_seconds: number;
active_servers: number;
}
interface DaemonNodeStatsRaw {
cpu_percent: number;
memory_used: number;
memory_total: number;
disk_used: number;
disk_total: number;
}
interface DaemonStatusResponse {
uuid: string;
state: string;
}
interface EmptyResponse {
[key: string]: never;
}
interface DaemonFileListResponseRaw {
files: {
name: string;
path: string;
is_directory: boolean;
size: number;
modified_at: number;
mime_type: string;
}[];
}
interface DaemonFileContentRaw {
data: Uint8Array | Buffer;
mime_type: string;
}
interface DaemonPlayerListRaw {
players: {
name: string;
uuid: string;
connected_at: number;
}[];
max_players: number;
}
interface DaemonBackupResponseRaw {
backup_id: string;
size_bytes: number;
checksum: string;
success: boolean;
}
export interface DaemonConsoleOutput {
uuid: string;
line: string;
timestamp: number;
}
export interface DaemonConsoleStreamHandle {
stream: grpc.ClientReadableStream<DaemonConsoleOutput>;
close: () => void;
}
export interface DaemonFileEntry {
name: string;
path: string;
isDirectory: boolean;
size: number;
modifiedAt: number;
mimeType: string;
}
export interface DaemonPlayersResponse {
players: Array<{
name: string;
id: string;
connectedAt: number;
}>;
maxPlayers: number;
}
export interface DaemonBackupResponse {
backupId: string;
sizeBytes: number;
checksum: string;
success: boolean;
}
export interface DaemonManagedDatabaseCredentials {
databaseName: string;
username: string;
password: string;
host: string;
port: number;
phpMyAdminUrl: string | null;
}
export interface DaemonNodeStatus {
version: string;
isHealthy: boolean;
uptimeSeconds: number;
activeServers: number;
}
export interface DaemonNodeStats {
cpuPercent: number;
memoryUsed: number;
memoryTotal: number;
diskUsed: number;
diskTotal: number;
}
type UnaryCallback<TResponse> = (error: grpc.ServiceError | null, response: TResponse) => void;
interface DaemonServiceClient extends grpc.Client {
getNodeStatus(
request: EmptyResponse,
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonNodeStatusRaw>,
): void;
streamNodeStats(
request: EmptyResponse,
metadata: grpc.Metadata,
): grpc.ClientReadableStream<DaemonNodeStatsRaw>;
createServer(
request: DaemonCreateServerRequest,
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonServerResponse>,
): void;
updateServer(
request: DaemonUpdateServerRequest,
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonServerResponse>,
): void;
deleteServer(
request: { uuid: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
createDatabase(
request: { server_uuid: string; name: string; password?: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonManagedDatabaseCredentialsRaw>,
): void;
importDatabaseSql(
request: { database_name: string; sql: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
updateDatabasePassword(
request: { username: string; password: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
deleteDatabase(
request: { database_name: string; username: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
setPowerState(
request: {
uuid: string;
action: number;
stop_command: string;
stop_timeout_seconds: number;
},
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
getServerStatus(
request: { uuid: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonStatusResponse>,
): void;
streamConsole(
request: { uuid: string },
metadata: grpc.Metadata,
): grpc.ClientReadableStream<DaemonConsoleOutput>;
sendCommand(
request: { uuid: string; command: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
listFiles(
request: { uuid: string; path: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonFileListResponseRaw>,
): void;
readFile(
request: { uuid: string; path: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonFileContentRaw>,
): void;
writeFile(
request: { uuid: string; path: string; data: Uint8Array | Buffer },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
deleteFiles(
request: { uuid: string; paths: string[] },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
createBackup(
request: { server_uuid: string; backup_id: string; cdn_upload_url?: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonBackupResponseRaw>,
): void;
restoreBackup(
request: { server_uuid: string; backup_id: string; cdn_download_url?: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
deleteBackup(
request: { server_uuid: string; backup_id: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
getActivePlayers(
request: { uuid: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonPlayerListRaw>,
): void;
}
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: Number,
enums: Number,
defaults: true,
oneofs: true,
});
const loaded = grpc.loadPackageDefinition(packageDefinition) as {
gamepanel?: {
daemon?: {
DaemonService?: grpc.ServiceClientConstructor;
};
};
};
const DaemonServiceCtor = loaded.gamepanel?.daemon?.DaemonService;
if (!DaemonServiceCtor) {
throw new Error('Failed to load DaemonService gRPC definition');
}
const DaemonService = DaemonServiceCtor;
const POWER_ACTIONS: Record<PowerAction, number> = {
start: 0,
stop: 1,
restart: 2,
kill: 3,
};
const MAX_GRPC_MESSAGE_BYTES = 32 * 1024 * 1024;
function buildGrpcTarget(fqdn: string, grpcPort: number): string {
const trimmed = fqdn.trim();
if (!trimmed) throw new Error('Node FQDN is empty');
let host = trimmed;
if (trimmed.includes('://')) {
try {
const parsed = new URL(trimmed);
host = parsed.hostname || parsed.host;
if (!host) throw new Error('Node FQDN has no hostname');
} catch {
// Fall through to raw handling below.
}
}
const withoutPath = host.replace(/\/.*$/, '');
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
const innerHost = withoutPath.replace(/^\[/, '').replace(/\](?::\d+)?$/, '');
return `[${innerHost}]:${grpcPort}`;
}
if (/^[^:]+:\d+$/.test(withoutPath)) {
const hostOnly = withoutPath.replace(/:\d+$/, '');
return `${hostOnly}:${grpcPort}`;
}
if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`;
return `${withoutPath}:${grpcPort}`;
}
function getMetadata(daemonToken: string): grpc.Metadata {
const metadata = new grpc.Metadata();
metadata.set('authorization', `Bearer ${daemonToken}`);
return metadata;
}
function createClient(node: DaemonNodeConnection): DaemonServiceClient {
const target = buildGrpcTarget(node.fqdn, node.grpcPort);
return new DaemonService(target, grpc.credentials.createInsecure(), {
'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES,
'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES,
}) as unknown as DaemonServiceClient;
}
function waitForReady(client: grpc.Client, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
client.waitForReady(Date.now() + timeoutMs, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
function callUnary<TResponse>(
invoke: (callback: UnaryCallback<TResponse>) => void,
timeoutMs: number,
): Promise<TResponse> {
return new Promise((resolve, reject) => {
let completed = false;
const timeout = setTimeout(() => {
if (completed) return;
completed = true;
reject(new Error(`gRPC request timed out after ${timeoutMs}ms`));
}, timeoutMs);
invoke((error, response) => {
if (completed) return;
completed = true;
clearTimeout(timeout);
if (error) {
reject(error);
return;
}
resolve(response);
});
});
}
function readFirstStreamMessage<TMessage>(
stream: grpc.ClientReadableStream<TMessage>,
timeoutMs: number,
): Promise<TMessage> {
return new Promise((resolve, reject) => {
let completed = false;
const timeout = setTimeout(() => {
if (completed) return;
completed = true;
reject(new Error(`gRPC stream timed out after ${timeoutMs}ms`));
}, timeoutMs);
const onData = (message: TMessage) => {
if (completed) return;
completed = true;
clearTimeout(timeout);
resolve(message);
};
const onError = (error: Error) => {
if (completed) return;
completed = true;
clearTimeout(timeout);
reject(error);
};
const onEnd = () => {
if (completed) return;
completed = true;
clearTimeout(timeout);
reject(new Error('gRPC stream ended before first message'));
};
stream.on('data', onData);
stream.on('error', onError);
stream.on('end', onEnd);
});
}
function toBuffer(data: Uint8Array | Buffer): Buffer {
if (Buffer.isBuffer(data)) return data;
return Buffer.from(data);
}
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
const DEFAULT_RPC_TIMEOUT_MS = 20_000;
const POWER_RPC_TIMEOUT_MS = 45_000;
const MAX_POWER_RPC_TIMEOUT_MS = 360_000;
interface DaemonRequestTimeoutOptions {
connectTimeoutMs?: number;
rpcTimeoutMs?: number;
}
export async function daemonGetNodeStatus(node: DaemonNodeConnection): Promise<DaemonNodeStatus> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonNodeStatusRaw>(
(callback) => client.getNodeStatus({}, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
version: response.version,
isHealthy: response.is_healthy,
uptimeSeconds: Number(response.uptime_seconds),
activeServers: Number(response.active_servers),
};
} finally {
client.close();
}
}
export async function daemonGetNodeStats(node: DaemonNodeConnection): Promise<DaemonNodeStats> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const stream = client.streamNodeStats({}, getMetadata(node.daemonToken));
const response = await readFirstStreamMessage(stream, DEFAULT_RPC_TIMEOUT_MS);
return {
cpuPercent: Number(response.cpu_percent),
memoryUsed: Number(response.memory_used),
memoryTotal: Number(response.memory_total),
diskUsed: Number(response.disk_used),
diskTotal: Number(response.disk_total),
};
} finally {
client.close();
}
}
export async function daemonCreateServer(
node: DaemonNodeConnection,
request: DaemonCreateServerRequest,
): Promise<DaemonServerResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonServerResponse>(
(callback) => client.createServer(request, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteServer(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonUpdateServer(
node: DaemonNodeConnection,
request: DaemonUpdateServerRequest,
): Promise<DaemonServerResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonServerResponse>(
(callback) => client.updateServer(request, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonCreateDatabase(
node: DaemonNodeConnection,
request: { serverUuid: string; name: string; password?: string },
): Promise<DaemonManagedDatabaseCredentials> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonManagedDatabaseCredentialsRaw>(
(callback) =>
client.createDatabase(
{
server_uuid: request.serverUuid,
name: request.name,
password: request.password ?? '',
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
databaseName: response.database_name,
username: response.username,
password: response.password,
host: response.host,
port: Number(response.port),
phpMyAdminUrl: response.phpmyadmin_url.trim() ? response.phpmyadmin_url : null,
};
} finally {
client.close();
}
}
export async function daemonUpdateDatabasePassword(
node: DaemonNodeConnection,
request: { username: string; password: string },
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.updateDatabasePassword(
{
username: request.username,
password: request.password,
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonImportDatabaseSql(
node: DaemonNodeConnection,
request: { databaseName: string; sql: string },
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.importDatabaseSql(
{
database_name: request.databaseName,
sql: request.sql,
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteDatabase(
node: DaemonNodeConnection,
request: { databaseName: string; username: string },
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.deleteDatabase(
{
database_name: request.databaseName,
username: request.username,
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonSetPowerState(
node: DaemonNodeConnection,
serverUuid: string,
action: PowerAction,
options: DaemonPowerOptions = {},
): Promise<void> {
const stopTimeoutSeconds =
Number(options.stopTimeoutSeconds) > 0 ? Math.floor(Number(options.stopTimeoutSeconds)) : 0;
// The daemon waits out the shutdown before replying, so the RPC deadline has
// to outlive the game's own budget (ARK saves its world for minutes).
const rpcTimeoutMs =
action === 'stop' || action === 'restart'
? Math.min(
Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS),
MAX_POWER_RPC_TIMEOUT_MS,
)
: POWER_RPC_TIMEOUT_MS;
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.setPowerState(
{
uuid: serverUuid,
action: POWER_ACTIONS[action],
stop_command: options.stopCommand?.trim() ?? '',
stop_timeout_seconds: stopTimeoutSeconds,
},
getMetadata(node.daemonToken),
callback,
),
rpcTimeoutMs,
);
} finally {
client.close();
}
}
export async function daemonGetServerStatus(
node: DaemonNodeConnection,
serverUuid: string,
timeouts: DaemonRequestTimeoutOptions = {},
): Promise<DaemonStatusResponse> {
const client = createClient(node);
try {
await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonStatusResponse>(
(callback) =>
client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonOpenConsoleStream(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<DaemonConsoleStreamHandle> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const stream = client.streamConsole({ uuid: serverUuid }, getMetadata(node.daemonToken));
const close = () => {
try {
stream.cancel();
} catch {
// no-op
}
client.close();
};
stream.on('end', () => client.close());
stream.on('error', () => client.close());
return { stream, close };
} catch (error) {
client.close();
throw error;
}
}
export async function daemonSendCommand(
node: DaemonNodeConnection,
serverUuid: string,
command: string,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonListFiles(
node: DaemonNodeConnection,
serverUuid: string,
path: string,
): Promise<DaemonFileEntry[]> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileListResponseRaw>(
(callback) =>
client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return response.files.map((file) => ({
name: file.name,
path: file.path,
isDirectory: file.is_directory,
size: Number(file.size),
modifiedAt: Number(file.modified_at),
mimeType: file.mime_type,
}));
} finally {
client.close();
}
}
export async function daemonReadFile(
node: DaemonNodeConnection,
serverUuid: string,
path: string,
): Promise<{ data: Buffer; mimeType: string }> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileContentRaw>(
(callback) =>
client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
data: toBuffer(response.data),
mimeType: response.mime_type,
};
} finally {
client.close();
}
}
export async function daemonWriteFile(
node: DaemonNodeConnection,
serverUuid: string,
path: string,
data: string | Buffer,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.writeFile(
{
uuid: serverUuid,
path,
data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data,
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteFiles(
node: DaemonNodeConnection,
serverUuid: string,
paths: string[],
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonCreateBackup(
node: DaemonNodeConnection,
serverUuid: string,
backupId: string,
): Promise<DaemonBackupResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonBackupResponseRaw>(
(callback) =>
client.createBackup(
{ server_uuid: serverUuid, backup_id: backupId },
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
backupId: response.backup_id,
sizeBytes: Number(response.size_bytes),
checksum: response.checksum,
success: response.success,
};
} finally {
client.close();
}
}
export async function daemonRestoreBackup(
node: DaemonNodeConnection,
serverUuid: string,
backupId: string,
cdnPath?: string | null,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.restoreBackup(
{
server_uuid: serverUuid,
backup_id: backupId,
cdn_download_url: cdnPath ?? '',
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteBackup(
node: DaemonNodeConnection,
serverUuid: string,
backupId: string,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.deleteBackup(
{ server_uuid: serverUuid, backup_id: backupId },
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonGetActivePlayers(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<DaemonPlayersResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonPlayerListRaw>(
(callback) =>
client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
players: response.players.map((player) => ({
name: player.name,
id: player.uuid,
connectedAt: Number(player.connected_at),
})),
maxPlayers: Number(response.max_players),
};
} finally {
client.close();
}
}
+30
View File
@@ -0,0 +1,30 @@
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string,
) {
super(message);
this.name = 'AppError';
}
static badRequest(message: string, code?: string) {
return new AppError(400, message, code);
}
static unauthorized(message = 'Unauthorized', code?: string) {
return new AppError(401, message, code);
}
static forbidden(message = 'Forbidden', code?: string) {
return new AppError(403, message, code);
}
static notFound(message = 'Not found', code?: string) {
return new AppError(404, message, code);
}
static conflict(message: string, code?: string) {
return new AppError(409, message, code);
}
}
+740
View File
@@ -0,0 +1,740 @@
import { randomBytes } from 'node:crypto';
import { gunzipSync } from 'node:zlib';
import type { FastifyInstance } from 'fastify';
import { and, asc, eq } from 'drizzle-orm';
import * as tar from 'tar-stream';
import type { Headers } from 'tar-stream';
import * as unzipper from 'unzipper';
import { serverDatabases, servers } from '@source/database';
import {
daemonCreateDatabase,
daemonDeleteDatabase,
daemonDeleteFiles,
daemonImportDatabaseSql,
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
const GITHUB_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
const URL_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
const QBCORE_DATABASE_NAME = 'qbcore';
const FIVE_M_QBCORE_MARKER_PATH = '/.gamepanel/fivem-qbcore.json';
const FIVE_M_INTERNAL_PORT = 30120;
const QBCORE_SQL_URL =
'https://raw.githubusercontent.com/qbcore-framework/txAdminRecipe/main/qbcore.sql';
const OXMYSQL_ZIP_URL =
'https://github.com/overextended/oxmysql/releases/download/v2.12.0/oxmysql.zip';
const MENUV_ZIP_URL = 'https://github.com/ThymonA/menuv/releases/download/v1.4.1/menuv_v1.4.1.zip';
interface ExtractedFile {
path: string;
data: Buffer;
}
interface ManagedServerDatabaseRecord {
id: string;
name: string;
databaseName: string;
username: string;
password: string;
host: string;
port: number;
phpMyAdminUrl: string | null;
}
interface FivemProvisionContext {
node: DaemonNodeConnection;
serverDescription?: string | null;
serverId: string;
serverName: string;
serverUuid: string;
}
interface GitHubArchiveResource {
destination: string;
owner: string;
ref: string;
repo: string;
subpath?: string;
}
interface RemoteArchiveResource {
collapseTopLevelDirectory?: boolean;
destination: string;
url: string;
}
const FIVEM_GITHUB_RESOURCES: GitHubArchiveResource[] = [
{
owner: 'citizenfx',
repo: 'cfx-server-data',
ref: 'master',
destination: '/resources/[cfx-default]',
subpath: 'resources',
},
{
owner: 'qbcore-framework',
repo: 'bob74_ipl',
ref: 'master',
destination: '/resources/[standalone]/bob74_ipl',
},
{
owner: 'qbcore-framework',
repo: 'safecracker',
ref: 'main',
destination: '/resources/[standalone]/safecracker',
},
{
owner: 'citizenfx',
repo: 'screenshot-basic',
ref: 'master',
destination: '/resources/[standalone]/screenshot-basic',
},
{
owner: 'qbcore-framework',
repo: 'progressbar',
ref: 'main',
destination: '/resources/[standalone]/progressbar',
},
{
owner: 'qbcore-framework',
repo: 'interact-sound',
ref: 'master',
destination: '/resources/[standalone]/interact-sound',
},
{
owner: 'qbcore-framework',
repo: 'connectqueue',
ref: 'master',
destination: '/resources/[standalone]/connectqueue',
},
{
owner: 'qbcore-framework',
repo: 'PolyZone',
ref: 'master',
destination: '/resources/[standalone]/PolyZone',
},
{
owner: 'AvarianKnight',
repo: 'pma-voice',
ref: 'main',
destination: '/resources/[voice]/pma-voice',
},
{
owner: 'qbcore-framework',
repo: 'qb-radio',
ref: 'main',
destination: '/resources/[voice]/qb-radio',
},
{
owner: 'qbcore-framework',
repo: 'hospital_map',
ref: 'main',
destination: '/resources/[defaultmaps]/hospital_map',
},
{
owner: 'qbcore-framework',
repo: 'dealer_map',
ref: 'main',
destination: '/resources/[defaultmaps]/dealer_map',
},
{
owner: 'qbcore-framework',
repo: 'prison_map',
ref: 'main',
destination: '/resources/[defaultmaps]/prison_map',
},
...[
'qb-core',
'qb-scoreboard',
'qb-adminmenu',
'qb-multicharacter',
'qb-target',
'qb-vehiclesales',
'qb-vehicleshop',
'qb-houserobbery',
'qb-prison',
'qb-hud',
'qb-management',
'qb-weed',
'qb-lapraces',
'qb-inventory',
'qb-houses',
'qb-garages',
'qb-ambulancejob',
'qb-radialmenu',
'qb-crypto',
'qb-weathersync',
'qb-policejob',
'qb-apartments',
'qb-vehiclekeys',
'qb-mechanicjob',
'qb-phone',
'qb-vineyard',
'qb-weapons',
'qb-scrapyard',
'qb-towjob',
'qb-streetraces',
'qb-storerobbery',
'qb-spawn',
'qb-smallresources',
'qb-recyclejob',
'qb-crafting',
'qb-diving',
'qb-cityhall',
'qb-truckrobbery',
'qb-pawnshop',
'qb-minigames',
'qb-taxijob',
'qb-busjob',
'qb-newsjob',
'qb-fuel',
'qb-jewelery',
'qb-bankrobbery',
'qb-banking',
'qb-clothing',
'qb-hotdogjob',
'qb-doorlock',
'qb-garbagejob',
'qb-drugs',
'qb-shops',
'qb-interior',
'qb-menu',
'qb-input',
'qb-loading',
].map((repo) => ({
owner: 'qbcore-framework',
repo,
ref: 'main',
destination: `/resources/[qb]/${repo}`,
})),
];
const FIVEM_REMOTE_ARCHIVES: RemoteArchiveResource[] = [
{
url: OXMYSQL_ZIP_URL,
destination: '/resources/[standalone]/oxmysql',
collapseTopLevelDirectory: true,
},
{
url: MENUV_ZIP_URL,
destination: '/resources/[standalone]/menuv',
collapseTopLevelDirectory: true,
},
];
function normalizePathSegments(path: string): string[] {
return path
.replace(/\\/g, '/')
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
function normalizeArchivePath(path: string): string | null {
const segments = normalizePathSegments(path);
if (segments.length === 0) return null;
return segments.join('/');
}
function joinServerPath(base: string, relative: string): string {
const baseSegments = normalizePathSegments(base);
const relativeSegments = normalizePathSegments(relative);
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
}
function stripSharedTopLevelDirectory(files: ExtractedFile[]): ExtractedFile[] {
if (files.length === 0) return files;
const firstSegments = new Set<string>();
for (const file of files) {
const [first] = normalizePathSegments(file.path);
if (!first) return files;
firstSegments.add(first);
if (firstSegments.size > 1) {
return files;
}
}
return files
.map((file) => {
const segments = normalizePathSegments(file.path).slice(1);
if (segments.length === 0) return null;
return {
path: segments.join('/'),
data: file.data,
};
})
.filter((file): file is ExtractedFile => file !== null);
}
function filterFilesBySubpath(files: ExtractedFile[], subpath: string): ExtractedFile[] {
const prefix = normalizePathSegments(subpath).join('/');
if (!prefix) return files;
const normalizedPrefix = `${prefix}/`;
return files
.map((file) => {
if (file.path === prefix) return null;
if (!file.path.startsWith(normalizedPrefix)) return null;
return {
path: file.path.slice(normalizedPrefix.length),
data: file.data,
};
})
.filter((file): file is ExtractedFile => file !== null && file.path.length > 0);
}
async function downloadBinary(
url: string,
maxBytes: number,
headers: Record<string, string> = {},
): Promise<Buffer> {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
...headers,
},
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Download failed (${response.status}): ${url}`);
}
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (contentLength > maxBytes) {
throw new Error(`Download exceeds size limit (${contentLength} > ${maxBytes})`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error(`Downloaded archive is empty: ${url}`);
}
if (buffer.length > maxBytes) {
throw new Error(`Download exceeds size limit (${buffer.length} > ${maxBytes})`);
}
return buffer;
}
async function downloadText(url: string, headers: Record<string, string> = {}): Promise<string> {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
...headers,
},
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Text download failed (${response.status}): ${url}`);
}
const text = await response.text();
if (!text.trim()) {
throw new Error(`Downloaded text is empty: ${url}`);
}
return text;
}
async function extractZipFiles(buffer: Buffer): Promise<ExtractedFile[]> {
const archive = await unzipper.Open.buffer(buffer);
const files: ExtractedFile[] = [];
for (const entry of archive.files) {
if (entry.type !== 'File') continue;
const normalized = normalizeArchivePath(entry.path);
if (!normalized) continue;
files.push({
path: normalized,
data: await entry.buffer(),
});
}
return files;
}
function extractTarFiles(buffer: Buffer): Promise<ExtractedFile[]> {
return new Promise((resolve, reject) => {
const extract = tar.extract();
const files: ExtractedFile[] = [];
extract.on('entry', (header: Headers, stream, next) => {
const type = header.type ?? 'file';
const normalized = normalizeArchivePath(header.name);
const isFileType = type === 'file' || type === 'contiguous-file';
if (!isFileType || !normalized) {
stream.resume();
stream.on('end', next);
stream.on('error', reject);
return;
}
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('end', () => {
files.push({ path: normalized, data: Buffer.concat(chunks) });
next();
});
stream.on('error', reject);
});
extract.on('finish', () => resolve(files));
extract.on('error', reject);
extract.end(buffer);
});
}
async function extractArchive(buffer: Buffer, url: string): Promise<ExtractedFile[]> {
const normalizedUrl = url.toLowerCase();
if (normalizedUrl.endsWith('.zip')) {
return extractZipFiles(buffer);
}
if (normalizedUrl.endsWith('.tar.gz') || normalizedUrl.endsWith('.tgz')) {
return extractTarFiles(gunzipSync(buffer));
}
if (normalizedUrl.endsWith('.tar')) {
return extractTarFiles(buffer);
}
throw new Error(`Unsupported archive type: ${url}`);
}
async function writeFilesToServer(
node: DaemonNodeConnection,
serverUuid: string,
destination: string,
files: ExtractedFile[],
): Promise<void> {
for (const file of files) {
await daemonWriteFile(node, serverUuid, joinServerPath(destination, file.path), file.data);
}
}
function escapeCfgValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function buildMysqlConnectionString(database: ManagedServerDatabaseRecord): string {
return `mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}?charset=utf8mb4`;
}
function renderFivemServerConfig(
serverName: string,
description: string | null | undefined,
database: ManagedServerDatabaseRecord,
): string {
const safeServerName = escapeCfgValue(serverName.trim() || 'QBCore Server');
const safeProjectDescription = escapeCfgValue(
description?.trim() || 'QBCore server provisioned by Source GamePanel.',
);
const rconPassword = randomBytes(16).toString('hex');
const mysqlConnectionString = escapeCfgValue(buildMysqlConnectionString(database));
return `# Generated by Source GamePanel
# QBCore resources and base dependencies are installed automatically.
endpoint_add_tcp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
endpoint_add_udp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
sv_maxclients "32"
sv_hostname "${safeServerName}"
sets sv_projectName "[QBCore] ${safeServerName}"
sets sv_projectDesc "${safeProjectDescription}"
sets locale "en-US"
sets tags "qbcore, qb-core, roleplay, source-gamepanel"
set steam_webApiKey "none"
set resources_useSystemChat "true"
set mysql_connection_string "${mysqlConnectionString}"
setr qb_locale "en"
setr UseTarget "false"
setr voice_useNativeAudio "true"
setr voice_useSendingRangeOnly "true"
setr voice_defaultCycle "GRAVE"
setr voice_defaultVolume "0.3"
setr voice_enableRadioAnim "1"
setr voice_syncData "1"
sv_scriptHookAllowed "0"
sv_endpointprivacy "true"
rcon_password "${rconPassword}"
ensure mapmanager
ensure chat
ensure spawnmanager
ensure sessionmanager
ensure basic-gamemode
ensure hardcap
ensure baseevents
ensure qb-core
ensure [qb]
ensure [standalone]
ensure [voice]
ensure [defaultmaps]
add_ace group.admin command allow
add_ace group.admin command.quit deny
add_ace resource.qb-core command allow
add_ace qbcore.god command allow
add_principal qbcore.god group.admin
add_principal qbcore.god qbcore.admin
add_principal qbcore.admin qbcore.mod
`;
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
export function isFivemQbCoreGame(gameSlug: string): boolean {
return gameSlug.trim().toLowerCase() === 'fivem';
}
export async function ensureFivemQbCoreDatabase(
app: FastifyInstance,
context: Pick<FivemProvisionContext, 'node' | 'serverId' | 'serverUuid'>,
): Promise<ManagedServerDatabaseRecord> {
const existing = await app.db
.select({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
})
.from(serverDatabases)
.where(eq(serverDatabases.serverId, context.serverId))
.orderBy(asc(serverDatabases.createdAt));
const preferred =
existing.find((database) => database.name.trim().toLowerCase() === QBCORE_DATABASE_NAME) ??
existing[0];
if (preferred) {
return preferred;
}
const managedDatabase = await daemonCreateDatabase(context.node, {
serverUuid: context.serverUuid,
name: QBCORE_DATABASE_NAME,
});
try {
const [created] = await app.db
.insert(serverDatabases)
.values({
serverId: context.serverId,
name: QBCORE_DATABASE_NAME,
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
password: managedDatabase.password,
host: managedDatabase.host,
port: managedDatabase.port,
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
})
.returning({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
});
if (!created) {
throw new Error('Failed to persist managed database metadata');
}
return created;
} catch (error) {
try {
await daemonDeleteDatabase(context.node, {
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
});
} catch (cleanupError) {
app.log.error(
{
cleanupError,
databaseName: managedDatabase.databaseName,
serverId: context.serverId,
serverUuid: context.serverUuid,
},
'Failed to roll back managed MySQL database after metadata save failure',
);
}
throw error;
}
}
export async function deleteFivemQbCoreDatabase(
app: FastifyInstance,
context: Pick<FivemProvisionContext, 'node' | 'serverId'>,
): Promise<void> {
const [database] = await app.db
.select({
id: serverDatabases.id,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
})
.from(serverDatabases)
.where(
and(
eq(serverDatabases.serverId, context.serverId),
eq(serverDatabases.name, QBCORE_DATABASE_NAME),
),
);
if (!database) return;
await daemonDeleteDatabase(context.node, {
databaseName: database.databaseName,
username: database.username,
});
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, database.id));
}
async function installGitHubResource(
app: FastifyInstance,
context: FivemProvisionContext,
resource: GitHubArchiveResource,
): Promise<void> {
const archiveUrl = `https://codeload.github.com/${resource.owner}/${resource.repo}/tar.gz/refs/heads/${encodeURIComponent(resource.ref)}`;
const archive = await downloadBinary(archiveUrl, GITHUB_ARCHIVE_MAX_BYTES);
let files = await extractTarFiles(gunzipSync(archive));
files = stripSharedTopLevelDirectory(files);
if (resource.subpath) {
files = filterFilesBySubpath(files, resource.subpath);
}
if (files.length === 0) {
throw new Error(
`GitHub archive had no files: ${resource.owner}/${resource.repo}@${resource.ref}`,
);
}
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
app.log.info(
{
destination: resource.destination,
filesWritten: files.length,
repo: `${resource.owner}/${resource.repo}`,
serverId: context.serverId,
serverUuid: context.serverUuid,
},
'Installed FiveM GitHub resource',
);
}
async function installRemoteArchive(
app: FastifyInstance,
context: FivemProvisionContext,
resource: RemoteArchiveResource,
): Promise<void> {
const archive = await downloadBinary(resource.url, URL_ARCHIVE_MAX_BYTES);
let files = await extractArchive(archive, resource.url);
if (resource.collapseTopLevelDirectory) {
files = stripSharedTopLevelDirectory(files);
}
if (files.length === 0) {
throw new Error(`Remote archive had no files: ${resource.url}`);
}
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
app.log.info(
{
destination: resource.destination,
filesWritten: files.length,
serverId: context.serverId,
serverUuid: context.serverUuid,
url: resource.url,
},
'Installed FiveM remote archive',
);
}
export async function provisionFivemQbCoreServer(
app: FastifyInstance,
context: FivemProvisionContext,
): Promise<void> {
try {
await daemonReadFile(context.node, context.serverUuid, FIVE_M_QBCORE_MARKER_PATH);
return;
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}
const database = await ensureFivemQbCoreDatabase(app, context);
const qbCoreSql = await downloadText(QBCORE_SQL_URL);
await daemonImportDatabaseSql(context.node, {
databaseName: database.databaseName,
sql: qbCoreSql,
});
for (const resource of FIVEM_GITHUB_RESOURCES) {
await installGitHubResource(app, context, resource);
}
for (const resource of FIVEM_REMOTE_ARCHIVES) {
await installRemoteArchive(app, context, resource);
}
try {
await daemonDeleteFiles(context.node, context.serverUuid, [
'/resources/[cfx-default]/[gameplay]/chat',
]);
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}
await daemonWriteFile(
context.node,
context.serverUuid,
'/server.cfg',
renderFivemServerConfig(context.serverName, context.serverDescription, database),
);
await daemonWriteFile(
context.node,
context.serverUuid,
FIVE_M_QBCORE_MARKER_PATH,
JSON.stringify(
{
installedAt: new Date().toISOString(),
manifestVersion: 1,
resourceCount: FIVEM_GITHUB_RESOURCES.length + FIVEM_REMOTE_ARCHIVES.length,
},
null,
2,
),
);
await app.db
.update(servers)
.set({ updatedAt: new Date() })
.where(eq(servers.id, context.serverId));
}
+66
View File
@@ -0,0 +1,66 @@
import type { FastifyInstance } from 'fastify';
export interface AccessTokenPayload {
sub: string; // user id
email: string;
isSuperAdmin: boolean;
}
export interface RefreshTokenPayload {
sub: string; // user id
type: 'refresh';
}
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
type JwtSign = (payload: object, options?: { expiresIn?: string }) => string;
type JwtVerify = (token: string) => unknown;
/**
* The parts of the JWT decoration we actually call.
*
* @fastify/jwt decorates the instance at runtime and the refresh namespace is
* registered by our own auth plugin, so neither appears in FastifyInstance's
* type. Describing the shape here keeps the call sites type-checked instead of
* casting the instance to `any`, which switches checking off entirely.
*/
interface JwtDecoratedInstance {
jwt?: {
sign?: JwtSign;
verify?: JwtVerify;
refresh?: { sign?: JwtSign; verify?: JwtVerify };
jwtRefresh?: { sign?: JwtSign; verify?: JwtVerify };
};
}
/** The decorated JWT namespace, or undefined when the plugin is not loaded. */
export function getJwt(app: FastifyInstance): JwtDecoratedInstance['jwt'] {
return (app as unknown as JwtDecoratedInstance).jwt;
}
export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string {
const signer = getJwt(app)?.sign;
if (typeof signer !== 'function') {
throw new Error('JWT signer is not configured');
}
return signer(payload, { expiresIn: ACCESS_TOKEN_EXPIRY });
}
export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string {
const jwt = getJwt(app);
const signer = jwt?.refresh?.sign ?? jwt?.jwtRefresh?.sign;
if (typeof signer !== 'function') {
throw new Error('Refresh JWT signer is not configured');
}
return signer(payload, { expiresIn: REFRESH_TOKEN_EXPIRY });
}
export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload {
const jwt = getJwt(app);
const verifier = jwt?.refresh?.verify ?? jwt?.jwtRefresh?.verify;
if (typeof verifier !== 'function') {
throw new Error('Refresh JWT verifier is not configured');
}
return verifier(token) as RefreshTokenPayload;
}
+373
View File
@@ -0,0 +1,373 @@
import type { FastifyInstance } from 'fastify';
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from './daemon.js';
/**
* Some game images run a SteamCMD `app_update ... validate` on every container
* start, which rewrites config files that ship with the game back to their
* stock contents. The panel therefore keeps its own copy of every managed
* config file in a hidden sidecar next to the real one, and restores the real
* file whenever the game resets it.
*/
export interface ManagedConfigFile {
/** Path of the real file, relative to the server data directory. */
path: string;
/** Sidecar holding the panel's copy of record. */
shadowPath: string;
/** Base name of the sidecar, so the file browser can hide it. */
shadowFileName: string;
/** Written when neither the real file nor the sidecar exists yet. */
defaultContent: string;
/**
* Stock contents shipped by the image. When the sidecar is adopted from an
* existing install, contents matching one of these are replaced by
* `defaultContent` instead of being preserved.
*/
imageDefaults: string[];
}
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
hostname "GamePanel CS2 Server" // Set server hostname
sv_cheats 0 // Enable or disable cheats
sv_hibernate_when_empty 0 // Disable server hibernation
// Passwords
rcon_password "" // Set rcon password
sv_password "" // Set server password
// CSTV
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
tv_chatgroupsize 0 // Set the default chat group size
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
tv_debug 0 // CSTV debug info.
tv_delay 0 // CSTV broadcast delay in seconds
tv_delaymapchange 1 // Delays map change until broadcast is complete
tv_deltacache 2 // Enable delta entity bit stream cache
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
tv_maxclients 10 // Maximum client number on CSTV server.
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
tv_overridemaster 0 // Overrides the CSTV master root address.
tv_port 27020 // Host SourceTV port
tv_password "changeme" // CSTV password for clients
tv_relaypassword "changeme" // CSTV password for relay proxies
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
tv_timeout 60 // CSTV connection timeout in seconds.
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
tv_transmitall 1 // Transmit all entities (not only director view)
// Logs
log on // Turns logging 'on' or 'off', defaults to 'on'
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
`;
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
// CS2 Server Config
// ============================================
// ---- Sunucu Bilgileri ----
hostname "SourceGamePanel CS2 Server"
sv_password ""
rcon_password "changeme"
sv_cheats 0
// ---- Topluluk Sunucu Gorunurlugu ----
sv_region 3
sv_tags "competitive,community"
sv_lan 0
sv_steamgroup ""
sv_steamgroup_exclusive 0
// ---- Performans ----
sv_maxrate 0
sv_minrate 64000
sv_max_queries_sec 5
sv_max_queries_window 30
sv_parallel_sendsnapshot 1
net_maxroutable 1200
// ---- Baglanti ----
sv_maxclients 16
sv_timeout 60
// ---- GOTV (Tamamen Kapali) ----
tv_enable 0
tv_autorecord 0
tv_delay 0
tv_maxclients 0
tv_port 0
// ---- Loglama ----
log on
mp_logmoney 0
mp_logdetail 0
mp_logdetail_items 0
sv_logfile 1
// ---- Genel Oyun Ayarlari ----
mp_autokick 0
sv_allow_votes 0
sv_alltalk 0
sv_deadtalk 1
sv_voiceenable 1
`;
const MANAGED_CONFIG_FILES: Record<string, ManagedConfigFile[]> = {
cs2: [
{
path: CS2_SERVER_CFG_PATH,
shadowPath: CS2_PERSISTED_SERVER_CFG_PATH,
shadowFileName: CS2_PERSISTED_SERVER_CFG_FILE,
defaultContent: DEFAULT_CS2_SERVER_CFG,
imageDefaults: [LEGACY_IMAGE_CS2_SERVER_CFG],
},
],
};
function normalizePath(path: string): string {
return path
.trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/\/{2,}/g, '/');
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizeComparableContent(content: string): string {
return content.replace(/\r\n/g, '\n').trim();
}
export function managedConfigFilesForGame(gameSlug: string): ManagedConfigFile[] {
return MANAGED_CONFIG_FILES[gameSlug.trim().toLowerCase()] ?? [];
}
/** The managed file a request path refers to, or `null` if it is not managed. */
export function managedConfigFileFor(gameSlug: string, path: string): ManagedConfigFile | null {
const normalized = normalizePath(path);
return managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null;
}
export function isManagedConfigShadowFile(gameSlug: string, fileName: string): boolean {
const normalized = fileName.trim();
return managedConfigFilesForGame(gameSlug).some((file) => file.shadowFileName === normalized);
}
/**
* Read the panel's copy of a managed config file, adopting whatever is on disk
* the first time around.
*/
export async function readManagedConfig(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
): Promise<string> {
try {
const persisted = await daemonReadFile(node, serverUuid, file.shadowPath);
return persisted.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
try {
const current = await daemonReadFile(node, serverUuid, file.path);
const content = current.data.toString('utf8');
const isStockContent = file.imageDefaults.some(
(stock) => normalizeComparableContent(stock) === normalizeComparableContent(content),
);
const nextContent = isStockContent ? file.defaultContent : content;
await daemonWriteFile(node, serverUuid, file.shadowPath, nextContent);
return nextContent;
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
await daemonWriteFile(node, serverUuid, file.shadowPath, file.defaultContent);
return file.defaultContent;
}
/** Write a managed config file, keeping the panel's copy in sync. */
export async function writeManagedConfig(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
content: string | Buffer,
): Promise<void> {
await daemonWriteFile(node, serverUuid, file.shadowPath, content);
await daemonWriteFile(node, serverUuid, file.path, content);
}
// === Drift watcher ===
/**
* How long to keep watching after a start. This has to outlast the image's own
* update/validate step — for CS2 that is a multi-gigabyte SteamCMD run that can
* easily take 10+ minutes on a cold cache, and it rewrites `server.cfg` when it
* finishes. Watching for only a minute is why edited configs kept coming back.
*/
const SUSTAIN_WINDOW_MS = Number(process.env.MANAGED_CONFIG_SUSTAIN_MS) || 30 * 60_000;
const FAST_INTERVAL_MS = 5_000;
const SLOW_INTERVAL_MS = 20_000;
const FAST_PHASE_MS = 2 * 60_000;
/** Consecutive drift-free polls needed before the watcher stops early. */
const REQUIRED_STABLE_ROUNDS = 6;
/** Never stop early before this much of the window has elapsed. */
const MIN_WATCH_MS = 3 * 60_000;
/** One watcher per server; a newer start supersedes the one already running. */
const activeWatchers = new Map<string, symbol>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function restoreDriftedFile(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
): Promise<boolean> {
const expected = await readManagedConfig(node, serverUuid, file);
let live: string | null = null;
try {
const current = await daemonReadFile(node, serverUuid, file.path);
live = current.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
if (live !== null && normalizeComparableContent(live) === normalizeComparableContent(expected)) {
return false;
}
await daemonWriteFile(node, serverUuid, file.path, expected);
return true;
}
/** Restore every managed config file for a game to the panel's copy. */
export async function reapplyManagedConfigs(
node: DaemonNodeConnection,
serverUuid: string,
gameSlug: string,
): Promise<void> {
for (const file of managedConfigFilesForGame(gameSlug)) {
await restoreDriftedFile(node, serverUuid, file);
}
}
/**
* Watch a server's managed config files after a start and put the panel's
* version back whenever the game overwrites it.
*
* `isServerActive` lets the caller abort once the server leaves the running
* state, so a stopped server never gets its files rewritten behind its back.
*/
export function sustainManagedConfigsAfterStart(
app: FastifyInstance,
options: {
node: DaemonNodeConnection;
serverId: string;
serverUuid: string;
gameSlug: string;
isServerActive: () => Promise<boolean>;
},
): void {
const files = managedConfigFilesForGame(options.gameSlug);
if (files.length === 0) return;
const token = Symbol(options.serverId);
activeWatchers.set(options.serverId, token);
void (async () => {
const startedAt = Date.now();
const deadline = startedAt + SUSTAIN_WINDOW_MS;
let stableRounds = 0;
try {
while (Date.now() < deadline) {
const elapsed = Date.now() - startedAt;
await sleep(elapsed < FAST_PHASE_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
if (activeWatchers.get(options.serverId) !== token) return;
let active: boolean;
try {
active = await options.isServerActive();
} catch (error) {
app.log.warn(
{ error, serverId: options.serverId },
'Managed config watcher could not read server status',
);
continue;
}
if (!active) {
app.log.debug(
{ serverId: options.serverId },
'Managed config watcher stopping: server is no longer running',
);
return;
}
let drifted = false;
for (const file of files) {
try {
if (await restoreDriftedFile(options.node, options.serverUuid, file)) {
drifted = true;
app.log.info(
{
serverId: options.serverId,
serverUuid: options.serverUuid,
gameSlug: options.gameSlug,
path: file.path,
},
'Restored managed config file after the game reset it',
);
}
} catch (error) {
app.log.warn(
{
error,
serverId: options.serverId,
serverUuid: options.serverUuid,
path: file.path,
},
'Failed to restore managed config file',
);
}
}
stableRounds = drifted ? 0 : stableRounds + 1;
if (stableRounds >= REQUIRED_STABLE_ROUNDS && Date.now() - startedAt >= MIN_WATCH_MS) {
return;
}
}
} finally {
if (activeWatchers.get(options.serverId) === token) {
activeWatchers.delete(options.serverId);
}
}
})();
}
+34
View File
@@ -0,0 +1,34 @@
import { Type } from '@sinclair/typebox';
export const PaginationQuerySchema = Type.Object({
page: Type.Optional(Type.Number({ minimum: 1, default: 1 })),
perPage: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })),
});
/**
* The querystring shape PaginationQuerySchema validates.
*
* Route handlers receive `request.query` as `unknown`; the schema has already
* checked the values by then, so the cast at the call site is what tells
* TypeScript what Fastify handed over.
*/
export type PaginationQuery = { page?: number; perPage?: number };
export function paginate(query: PaginationQuery) {
const page = query.page ?? 1;
const perPage = query.perPage ?? 20;
const offset = (page - 1) * perPage;
return { page, perPage, offset, limit: perPage };
}
export function paginatedResponse<T>(data: T[], total: number, page: number, perPage: number) {
return {
data,
meta: {
page,
perPage,
total,
totalPages: Math.ceil(total / perPage),
},
};
}
+14
View File
@@ -0,0 +1,14 @@
import argon2 from 'argon2';
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password);
}
+85
View File
@@ -0,0 +1,85 @@
import type { FastifyRequest } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { organizationMembers } from '@source/database';
import { ROLES } from '@source/shared';
import type { Permission, Role } from '@source/shared';
import { AppError } from './errors.js';
interface OrgMember {
role: Role;
customPermissions: Record<string, boolean>;
}
/**
* Get the requesting user's membership in an organization.
* Super admins bypass membership checks.
*/
export async function getOrgMembership(
request: FastifyRequest,
orgId: string,
): Promise<OrgMember | 'super_admin'> {
const user = request.user;
if (user.isSuperAdmin) {
return 'super_admin';
}
const member = await request.server.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.organizationId, orgId),
eq(organizationMembers.userId, user.sub),
),
});
if (!member) {
throw AppError.forbidden('You are not a member of this organization');
}
return {
role: member.role as Role,
customPermissions: (member.customPermissions ?? {}) as Record<string, boolean>,
};
}
/**
* Check if the user has a specific permission in the organization.
* Super admins always have all permissions.
*/
export function hasPermission(
membership: OrgMember | 'super_admin',
permission: Permission,
): boolean {
if (membership === 'super_admin') return true;
// Check custom permission overrides first
if (permission in membership.customPermissions) {
return membership.customPermissions[permission]!;
}
// Fall back to role defaults
const rolePerms = ROLES[membership.role]?.permissions ?? [];
return (rolePerms as readonly string[]).includes(permission);
}
/**
* Require a specific permission, throw 403 if not allowed.
*/
export async function requirePermission(
request: FastifyRequest,
orgId: string,
permission: Permission,
): Promise<void> {
const membership = await getOrgMembership(request, orgId);
if (!hasPermission(membership, permission)) {
throw AppError.forbidden(`Missing permission: ${permission}`);
}
}
/**
* Require super admin role.
*/
export function requireSuperAdmin(request: FastifyRequest): void {
if (!request.user.isSuperAdmin) {
throw AppError.forbidden('Super admin access required');
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Compute the next run time for a scheduled task.
*/
export function computeNextRun(scheduleType: string, scheduleData: Record<string, unknown>): Date {
const now = new Date();
switch (scheduleType) {
case 'interval': {
const minutes = Number(scheduleData.minutes) || 60;
return new Date(now.getTime() + minutes * 60_000);
}
case 'daily': {
const hour = Number(scheduleData.hour ?? 0);
const minute = Number(scheduleData.minute ?? 0);
const next = new Date(now);
next.setHours(hour, minute, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next;
}
case 'weekly': {
const dayOfWeek = Number(scheduleData.dayOfWeek ?? 0); // 0=Sunday
const hour = Number(scheduleData.hour ?? 0);
const minute = Number(scheduleData.minute ?? 0);
const next = new Date(now);
next.setHours(hour, minute, 0, 0);
const currentDay = next.getDay();
let daysAhead = dayOfWeek - currentDay;
if (daysAhead < 0 || (daysAhead === 0 && next <= now)) {
daysAhead += 7;
}
next.setDate(next.getDate() + daysAhead);
return next;
}
case 'cron': {
// Simple cron parser for: minute hour dayOfMonth month dayOfWeek
const expression = String(scheduleData.expression || '0 * * * *');
return parseCronNextRun(expression, now);
}
default:
return new Date(now.getTime() + 3600_000); // fallback: 1 hour
}
}
function parseCronNextRun(expression: string, from: Date): Date {
const parts = expression.trim().split(/\s+/);
const cronMinute = parts[0] ?? '*';
const cronHour = parts[1] ?? '*';
const cronDom = parts[2] ?? '*';
const cronMonth = parts[3] ?? '*';
const cronDow = parts[4] ?? '*';
// Brute force: check next 1440 minutes (24 hours)
const candidate = new Date(from);
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
for (let i = 0; i < 1440 * 31; i++) {
if (
matchesCronField(cronMinute, candidate.getMinutes()) &&
matchesCronField(cronHour, candidate.getHours()) &&
matchesCronField(cronDom, candidate.getDate()) &&
matchesCronField(cronMonth, candidate.getMonth() + 1) &&
matchesCronField(cronDow, candidate.getDay())
) {
return candidate;
}
candidate.setMinutes(candidate.getMinutes() + 1);
}
// Fallback if no match found
return new Date(from.getTime() + 3600_000);
}
function matchesCronField(field: string, value: number): boolean {
if (field === '*') return true;
// Handle step values: */5
if (field.startsWith('*/')) {
const step = parseInt(field.slice(2), 10);
return step > 0 && value % step === 0;
}
// Handle ranges: 1-5
if (field.includes('-')) {
const [min, max] = field.split('-').map(Number);
return min !== undefined && max !== undefined && value >= min && value <= max;
}
// Handle lists: 1,3,5
if (field.includes(',')) {
return field.split(',').map(Number).includes(value);
}
// Exact match
return parseInt(field, 10) === value;
}
+938
View File
@@ -0,0 +1,938 @@
import { gunzipSync } from 'node:zlib';
import type { FastifyInstance } from 'fastify';
import * as tar from 'tar-stream';
import type { Headers } from 'tar-stream';
import * as unzipper from 'unzipper';
import type {
GameAutomationRule,
ServerAutomationEvent,
ServerAutomationAction,
ServerAutomationGitHubReleaseExtractAction,
ServerAutomationHttpDirectoryExtractAction,
ServerAutomationInsertBeforeLineAction,
ServerAutomationWriteFileAction,
} from '@source/shared';
import {
daemonReadFile,
daemonSendCommand,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
import {
CS2_PERSISTED_SERVER_CFG_PATH,
CS2_SERVER_CFG_PATH,
DEFAULT_CS2_SERVER_CFG,
} from './managed-config.js';
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
const AUTOMATION_MARKER_ROOT = '/.gamepanel/automation';
const CS2_GAMEINFO_PATH = '/game/csgo/gameinfo.gi';
const CS2_GAMEINFO_METAMOD_LINE = '\t\t\tGame csgo/addons/metamod';
const CS2_GAMEINFO_INSERT_BEFORE_PATTERN = '^\\s*Game\\s+csgo\\s*$';
const CS2_GAMEINFO_EXISTS_PATTERN = '^\\s*Game\\s+csgo/addons/metamod\\s*$';
const CS2_GAMEINFO_INSERT_ACTION_ID = 'ensure-cs2-metamod-gameinfo-entry';
const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction = {
id: CS2_GAMEINFO_INSERT_ACTION_ID,
type: 'insert_before_line',
path: CS2_GAMEINFO_PATH,
line: CS2_GAMEINFO_METAMOD_LINE,
beforePattern: CS2_GAMEINFO_INSERT_BEFORE_PATTERN,
existsPattern: CS2_GAMEINFO_EXISTS_PATTERN,
skipIfExists: true,
};
const DEFAULT_CS2_SERVER_CONFIG_ACTION: ServerAutomationWriteFileAction = {
id: 'write-cs2-default-server-config',
type: 'write_file',
path: `/${CS2_SERVER_CFG_PATH}`,
data: DEFAULT_CS2_SERVER_CFG,
};
const DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION: ServerAutomationWriteFileAction = {
id: 'write-cs2-persisted-server-config',
type: 'write_file',
path: `/${CS2_PERSISTED_SERVER_CFG_PATH}`,
data: DEFAULT_CS2_SERVER_CFG,
};
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
cs2: [
{
id: 'cs2-write-default-server-config',
event: 'server.install.completed',
enabled: true,
runOncePerServer: true,
continueOnError: false,
actions: [
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
],
},
{
id: 'cs2-install-latest-metamod',
event: 'server.install.completed',
enabled: true,
runOncePerServer: true,
continueOnError: false,
actions: [
{
id: 'install-cs2-metamod',
type: 'http_directory_extract',
indexUrl: 'https://mms.alliedmods.net/mmsdrop/2.0/',
assetNamePattern: '^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$',
destination: '/game/csgo',
stripComponents: 0,
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
},
{ ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION },
],
},
{
id: 'cs2-install-latest-counterstrikesharp-runtime',
event: 'server.install.completed',
enabled: true,
runOncePerServer: true,
continueOnError: false,
actions: [
{
id: 'install-cs2-runtime',
type: 'github_release_extract',
owner: 'roflmuffin',
repo: 'CounterStrikeSharp',
assetNamePatterns: [
'^counterstrikesharp-with-runtime-.*linux.*\\.zip$',
'^counterstrikesharp-with-runtime.*\\.zip$',
],
destination: '/game/csgo',
stripComponents: 0,
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
},
],
},
],
};
interface ServerAutomationContext {
serverId: string;
serverUuid: string;
gameSlug: string;
event: ServerAutomationEvent;
node: DaemonNodeConnection;
automationRulesRaw: unknown;
force?: boolean;
}
export interface ServerAutomationRunResult {
workflowsMatched: number;
workflowsExecuted: number;
workflowsSkipped: number;
workflowsFailed: number;
actionFailures: number;
failures: ServerAutomationFailure[];
}
interface ExtractedFile {
path: string;
data: Buffer;
}
export interface ServerAutomationFailure {
level: 'action' | 'workflow';
workflowId: string;
actionId?: string;
message: string;
}
interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
size: number;
}
interface GitHubReleaseResponse {
tag_name: string;
assets: GitHubReleaseAsset[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function readWorkflowId(value: unknown): string | null {
if (!isObject(value)) return null;
const id = value.id;
if (typeof id !== 'string' || id.trim() === '') return null;
return id;
}
function normalizeWorkflow(gameSlug: string, workflow: GameAutomationRule): GameAutomationRule {
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
if (workflow.id === 'cs2-write-default-server-config') {
return {
...workflow,
actions: [
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
],
};
}
if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') {
const normalizedActions = workflow.actions.map((action) => {
if (action.type !== 'github_release_extract') return action;
if (action.id !== 'install-cs2-runtime') return action;
const destination = (action.destination ?? '').trim();
if (destination !== '' && destination !== '/') return action;
return {
...action,
destination: '/game/csgo',
};
});
return {
...workflow,
actions: normalizedActions,
};
}
if (workflow.id === 'cs2-install-latest-metamod') {
const hasGameInfoAction = workflow.actions.some(
(action) =>
action.type === 'insert_before_line' &&
(action.id === CS2_GAMEINFO_INSERT_ACTION_ID || action.path === CS2_GAMEINFO_PATH),
);
if (hasGameInfoAction) return workflow;
return {
...workflow,
actions: [...workflow.actions, { ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION }],
};
}
return workflow;
}
function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] {
const defaults = DEFAULT_GAME_AUTOMATION_RULES[gameSlug.toLowerCase()] ?? [];
if (!Array.isArray(raw)) {
return defaults.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
const configured = raw as GameAutomationRule[];
if (defaults.length === 0) {
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
const existingIds = new Set(
raw.map(readWorkflowId).filter((workflowId): workflowId is string => workflowId !== null),
);
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
if (missingDefaults.length === 0) {
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
return [...configured, ...missingDefaults].map((workflow) =>
normalizeWorkflow(gameSlug, workflow),
);
}
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
const cleanId = workflowId.trim().replace(/[^a-zA-Z0-9._-]+/g, '-');
return `${AUTOMATION_MARKER_ROOT}/${event}/${cleanId}.json`;
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizePathSegments(path: string): string[] {
return path
.replace(/\\/g, '/')
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
function joinServerPath(base: string, relative: string): string {
const baseSegments = normalizePathSegments(base);
const relativeSegments = normalizePathSegments(relative);
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
}
function normalizeArchivePath(path: string, stripComponents = 0): string | null {
const segments = normalizePathSegments(path);
const stripped = segments.slice(Math.max(0, stripComponents));
if (stripped.length === 0) return null;
return stripped.join('/');
}
async function hasMarker(
node: DaemonNodeConnection,
serverUuid: string,
event: ServerAutomationEvent,
workflowId: string,
): Promise<boolean> {
try {
await daemonReadFile(node, serverUuid, markerPath(event, workflowId));
return true;
} catch (error) {
if (isMissingFileError(error)) return false;
throw error;
}
}
async function writeMarker(
node: DaemonNodeConnection,
serverUuid: string,
event: ServerAutomationEvent,
workflowId: string,
payload: Record<string, unknown>,
): Promise<void> {
await daemonWriteFile(
node,
serverUuid,
markerPath(event, workflowId),
JSON.stringify(payload, null, 2),
);
}
function githubHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'User-Agent': 'SourceGamePanel/1.0',
};
const token = process.env.GITHUB_TOKEN?.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function compileAssetPatterns(patterns: string[]): RegExp[] {
const compiled: RegExp[] = [];
const seen = new Set<string>();
const tryCompile = (pattern: string) => {
const key = pattern.trim();
if (!key || seen.has(key)) return;
try {
compiled.push(new RegExp(key, 'i'));
seen.add(key);
} catch {
// Ignore invalid regex patterns in configuration.
}
};
for (const pattern of patterns) {
tryCompile(pattern);
// Some JSON-stored patterns may be over-escaped (e.g. "\\\\." instead of "\\.").
// Collapse double backslashes once and compile a fallback variant.
if (pattern.includes('\\\\')) {
tryCompile(pattern.replace(/\\\\/g, '\\'));
}
}
return compiled;
}
async function fetchLatestRelease(
action: ServerAutomationGitHubReleaseExtractAction,
): Promise<GitHubReleaseResponse> {
const releaseUrl = `https://api.github.com/repos/${action.owner}/${action.repo}/releases/latest`;
const response = await fetch(releaseUrl, {
headers: githubHeaders(),
});
if (!response.ok) {
throw new Error(
`GitHub latest release request failed (${action.owner}/${action.repo}): HTTP ${response.status}`,
);
}
const release = (await response.json()) as GitHubReleaseResponse;
if (!Array.isArray(release.assets)) {
throw new Error(`GitHub release payload has no assets (${action.owner}/${action.repo})`);
}
return release;
}
interface DirectoryAssetCandidate {
name: string;
downloadUrl: string;
}
function extractNumberParts(value: string): number[] {
const matches = value.match(/\d+/g);
if (!matches) return [];
return matches.map((part) => Number.parseInt(part, 10)).filter((num) => Number.isFinite(num));
}
function compareNumberPartsDesc(a: number[], b: number[]): number {
const maxLength = Math.max(a.length, b.length);
for (let i = 0; i < maxLength; i += 1) {
const left = a[i] ?? -1;
const right = b[i] ?? -1;
if (left !== right) {
return right - left;
}
}
return 0;
}
function pickLatestDirectoryAsset(candidates: DirectoryAssetCandidate[]): DirectoryAssetCandidate {
const sorted = [...candidates].sort((left, right) => {
const numberDiff = compareNumberPartsDesc(
extractNumberParts(left.name),
extractNumberParts(right.name),
);
if (numberDiff !== 0) return numberDiff;
return right.name.localeCompare(left.name);
});
return sorted[0] ?? candidates[0]!;
}
function extractDirectoryCandidates(
html: string,
indexUrl: string,
assetPattern: RegExp,
): DirectoryAssetCandidate[] {
const hrefRegex = /href\s*=\s*(['"])(.*?)\1/gi;
const candidates: DirectoryAssetCandidate[] = [];
let match: RegExpExecArray | null = null;
while ((match = hrefRegex.exec(html)) !== null) {
const href = (match[2] ?? '').trim();
if (!href || href.endsWith('/')) continue;
try {
const resolvedUrl = new URL(href, indexUrl);
const filename = decodeURIComponent(
resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '',
);
if (!filename || !assetPattern.test(filename)) continue;
candidates.push({
name: filename,
downloadUrl: resolvedUrl.toString(),
});
} catch {
// Ignore malformed links.
}
}
return candidates;
}
async function resolveLatestDirectoryAsset(
action: ServerAutomationHttpDirectoryExtractAction,
): Promise<DirectoryAssetCandidate> {
let assetPattern: RegExp;
try {
assetPattern = new RegExp(action.assetNamePattern, 'i');
} catch {
throw new Error(`Invalid assetNamePattern regex for action ${action.id}`);
}
const response = await fetch(action.indexUrl, {
headers: { 'User-Agent': 'SourceGamePanel/1.0' },
});
if (!response.ok) {
throw new Error(
`Directory listing request failed (${action.indexUrl}): HTTP ${response.status}`,
);
}
const html = await response.text();
const candidates = extractDirectoryCandidates(html, action.indexUrl, assetPattern);
if (candidates.length === 0) {
throw new Error(
`No matching directory asset for ${action.indexUrl} with pattern: ${action.assetNamePattern}`,
);
}
return pickLatestDirectoryAsset(candidates);
}
async function downloadBinary(url: string, maxBytes: number): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DEFAULT_DOWNLOAD_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
},
redirect: 'follow',
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Download failed with HTTP ${response.status}: ${url}`);
}
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (contentLength > maxBytes) {
throw new Error(`Artifact exceeds max size (${contentLength} > ${maxBytes} bytes)`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error('Downloaded artifact is empty');
}
if (buffer.length > maxBytes) {
throw new Error(`Artifact exceeds max size (${buffer.length} > ${maxBytes} bytes)`);
}
return buffer;
} finally {
clearTimeout(timeout);
}
}
async function extractZipFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
const archive = await unzipper.Open.buffer(buffer);
const files: ExtractedFile[] = [];
for (const entry of archive.files) {
if (entry.type !== 'File') continue;
const normalized = normalizeArchivePath(entry.path, stripComponents);
if (!normalized) continue;
files.push({
path: normalized,
data: await entry.buffer(),
});
}
return files;
}
function extractTarFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
return new Promise((resolve, reject) => {
const extract = tar.extract();
const files: ExtractedFile[] = [];
extract.on('entry', (header: Headers, stream, next) => {
const type = header.type ?? 'file';
const normalized = normalizeArchivePath(header.name, stripComponents);
const isFileType = type === 'file' || type === 'contiguous-file';
if (!isFileType || !normalized) {
stream.resume();
stream.on('end', next);
stream.on('error', reject);
return;
}
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('end', () => {
files.push({ path: normalized, data: Buffer.concat(chunks) });
next();
});
stream.on('error', reject);
});
extract.on('finish', () => resolve(files));
extract.on('error', reject);
extract.end(buffer);
});
}
async function extractArtifactFiles(
artifact: Buffer,
assetName: string,
stripComponents = 0,
): Promise<ExtractedFile[]> {
const name = assetName.toLowerCase();
if (name.endsWith('.zip')) {
return extractZipFiles(artifact, stripComponents);
}
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
return extractTarFiles(gunzipSync(artifact), stripComponents);
}
if (name.endsWith('.tar')) {
return extractTarFiles(artifact, stripComponents);
}
const normalized = normalizeArchivePath(assetName, stripComponents) ?? assetName;
return [{ path: normalized, data: artifact }];
}
async function executeGitHubReleaseExtract(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationGitHubReleaseExtractAction,
): Promise<void> {
const release = await fetchLatestRelease(action);
const patterns = compileAssetPatterns(action.assetNamePatterns);
if (patterns.length === 0) {
throw new Error(`No valid asset regex pattern for action ${action.id}`);
}
const asset = release.assets.find((candidate) =>
patterns.some((pattern) => pattern.test(candidate.name)),
);
if (!asset) {
throw new Error(
`No matching release asset for ${action.owner}/${action.repo} with patterns: ${action.assetNamePatterns.join(', ')}`,
);
}
const maxBytes =
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
const files = await extractArtifactFiles(
artifact,
asset.name,
Number(action.stripComponents) || 0,
);
if (files.length === 0) {
throw new Error(`Extracted artifact has no files: ${asset.name}`);
}
const destination = action.destination ?? '/';
for (const file of files) {
const targetPath = joinServerPath(destination, file.path);
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
}
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
actionId: action.id,
release: release.tag_name,
asset: asset.name,
filesWritten: files.length,
},
'Automation action completed: github_release_extract',
);
}
async function executeHttpDirectoryExtract(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationHttpDirectoryExtractAction,
): Promise<void> {
const selectedAsset = await resolveLatestDirectoryAsset(action);
const maxBytes =
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
const files = await extractArtifactFiles(
artifact,
selectedAsset.name,
Number(action.stripComponents) || 0,
);
if (files.length === 0) {
throw new Error(`Extracted artifact has no files: ${selectedAsset.name}`);
}
const destination = action.destination ?? '/';
for (const file of files) {
const targetPath = joinServerPath(destination, file.path);
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
}
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
actionId: action.id,
source: action.indexUrl,
asset: selectedAsset.name,
filesWritten: files.length,
},
'Automation action completed: http_directory_extract',
);
}
async function executeInsertBeforeLine(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationInsertBeforeLineAction,
): Promise<void> {
const file = await daemonReadFile(context.node, context.serverUuid, action.path);
const content = file.data.toString('utf8');
const eol = content.includes('\r\n') ? '\r\n' : '\n';
const hasTrailingEol = content.endsWith('\n');
const lines = content.split(/\r?\n/);
if (hasTrailingEol && lines[lines.length - 1] === '') {
lines.pop();
}
const skipIfExists = action.skipIfExists !== false;
if (skipIfExists) {
const existsRegex = action.existsPattern ? new RegExp(action.existsPattern, 'i') : null;
const alreadyExists = lines.some((line) =>
existsRegex ? existsRegex.test(line) : line === action.line,
);
if (alreadyExists) {
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
path: action.path,
},
'Automation action skipped: line already present',
);
return;
}
}
let beforeRegex: RegExp;
try {
beforeRegex = new RegExp(action.beforePattern);
} catch {
throw new Error(`Invalid beforePattern regex for action ${action.id}`);
}
const insertIndex = lines.findIndex((line) => beforeRegex.test(line));
if (insertIndex < 0) {
throw new Error(
`Could not find insertion point in ${action.path} with pattern: ${action.beforePattern}`,
);
}
const updated = [...lines.slice(0, insertIndex), action.line, ...lines.slice(insertIndex)];
const output = `${updated.join(eol)}${hasTrailingEol ? eol : ''}`;
await daemonWriteFile(context.node, context.serverUuid, action.path, output);
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
path: action.path,
},
'Automation action completed: insert_before_line',
);
}
async function executeAction(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationAction,
): Promise<void> {
switch (action.type) {
case 'github_release_extract': {
await executeGitHubReleaseExtract(app, context, action);
return;
}
case 'http_directory_extract': {
await executeHttpDirectoryExtract(app, context, action);
return;
}
case 'insert_before_line': {
await executeInsertBeforeLine(app, context, action);
return;
}
case 'write_file': {
const payload =
action.encoding === 'base64' ? Buffer.from(action.data, 'base64') : action.data;
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
path: action.path,
},
'Automation action completed: write_file',
);
return;
}
case 'send_command': {
await daemonSendCommand(context.node, context.serverUuid, action.command);
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
command: action.command,
},
'Automation action completed: send_command',
);
return;
}
default: {
const unknownAction = action as { type?: unknown };
throw new Error(`Unsupported automation action type: ${String(unknownAction.type)}`);
}
}
}
export async function runServerAutomationEvent(
app: FastifyInstance,
context: ServerAutomationContext,
): Promise<ServerAutomationRunResult> {
const workflows = asAutomationRules(context.automationRulesRaw, context.gameSlug)
.filter((rule) => isObject(rule))
.filter((rule) => rule.event === context.event)
.filter((rule) => rule.enabled !== false)
.filter((rule) => Array.isArray(rule.actions) && rule.actions.length > 0);
const result: ServerAutomationRunResult = {
workflowsMatched: workflows.length,
workflowsExecuted: 0,
workflowsSkipped: 0,
workflowsFailed: 0,
actionFailures: 0,
failures: [],
};
if (workflows.length === 0) {
return result;
}
for (const workflow of workflows) {
const runOnce = workflow.runOncePerServer !== false;
try {
if (
runOnce &&
!context.force &&
(await hasMarker(context.node, context.serverUuid, context.event, workflow.id))
) {
result.workflowsSkipped += 1;
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
},
'Skipping automation workflow (already completed)',
);
continue;
}
for (const action of workflow.actions) {
try {
await executeAction(app, context, action);
} catch (error) {
const message = errorMessage(error);
result.actionFailures += 1;
result.failures.push({
level: 'action',
workflowId: workflow.id,
actionId: action.id,
message,
});
app.log.error(
{
err: error,
errorMessage: message,
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
actionId: action.id,
},
'Automation action failed',
);
if (workflow.continueOnError) {
continue;
}
throw error;
}
}
if (runOnce) {
await writeMarker(context.node, context.serverUuid, context.event, workflow.id, {
workflowId: workflow.id,
event: context.event,
completedAt: new Date().toISOString(),
});
}
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
},
'Automation workflow completed',
);
result.workflowsExecuted += 1;
} catch (error) {
const message = errorMessage(error);
result.workflowsFailed += 1;
result.failures.push({
level: 'workflow',
workflowId: workflow.id,
message,
});
app.log.error(
{
err: error,
errorMessage: message,
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
},
'Automation workflow failed',
);
}
}
return result;
}
+56
View File
@@ -0,0 +1,56 @@
const SPIGET_BASE = 'https://api.spiget.org/v2';
export interface SpigetResource {
id: number;
name: string;
tag: string;
icon: { url: string; data: string };
releaseDate: number;
updateDate: number;
downloads: number;
rating: { average: number; count: number };
file: { type: string; size: number; url: string };
version: { id: number };
external: boolean;
}
export interface SpigetVersion {
id: number;
name: string;
releaseDate: number;
downloads: number;
url: string;
}
export async function searchSpigetPlugins(
query: string,
page = 1,
size = 20,
): Promise<SpigetResource[]> {
const res = await fetch(
`${SPIGET_BASE}/search/resources/${encodeURIComponent(query)}?size=${size}&page=${page}&sort=-downloads`,
{ headers: { 'User-Agent': 'GamePanel/1.0' } },
);
if (!res.ok) return [];
return res.json() as Promise<SpigetResource[]>;
}
export async function getSpigetResource(id: number): Promise<SpigetResource | null> {
const res = await fetch(`${SPIGET_BASE}/resources/${id}`, {
headers: { 'User-Agent': 'GamePanel/1.0' },
});
if (!res.ok) return null;
return res.json() as Promise<SpigetResource>;
}
export async function getSpigetVersions(resourceId: number): Promise<SpigetVersion[]> {
const res = await fetch(`${SPIGET_BASE}/resources/${resourceId}/versions?sort=-releaseDate`, {
headers: { 'User-Agent': 'GamePanel/1.0' },
});
if (!res.ok) return [];
return res.json() as Promise<SpigetVersion[]>;
}
export function getSpigetDownloadUrl(resourceId: number): string {
return `${SPIGET_BASE}/resources/${resourceId}/download`;
}
+47
View File
@@ -0,0 +1,47 @@
import fp from 'fastify-plugin';
import jwt from '@fastify/jwt';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { AccessTokenPayload } from '../lib/jwt.js';
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: AccessTokenPayload;
user: AccessTokenPayload;
}
}
export default fp(async (app: FastifyInstance) => {
const jwtSecret = process.env.JWT_SECRET;
const jwtRefreshSecret = process.env.JWT_REFRESH_SECRET;
if (!jwtSecret || !jwtRefreshSecret) {
throw new Error('JWT_SECRET and JWT_REFRESH_SECRET environment variables are required');
}
// Access token JWT
await app.register(jwt, {
secret: jwtSecret,
});
// Refresh token JWT (separate namespace)
await app.register(jwt, {
secret: jwtRefreshSecret,
namespace: 'refresh',
decoratorName: 'jwtRefresh',
});
// Auth decorator
app.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch {
reply.code(401).send({ error: 'Unauthorized', message: 'Invalid or expired token' });
}
});
});
+45
View File
@@ -0,0 +1,45 @@
import fp from 'fastify-plugin';
import type { FastifyInstance } from 'fastify';
import { createDb, type Database } from '@source/database';
import { sql } from 'drizzle-orm';
declare module 'fastify' {
interface FastifyInstance {
db: Database;
}
}
export default fp(async (app: FastifyInstance) => {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is required');
}
const db = createDb(databaseUrl);
app.decorate('db', db);
await db.execute(
sql.raw(`
CREATE TABLE IF NOT EXISTS server_databases (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name varchar(255) NOT NULL,
database_name varchar(255) NOT NULL UNIQUE,
username varchar(64) NOT NULL UNIQUE,
password text NOT NULL,
host varchar(255) NOT NULL,
port integer NOT NULL,
phpmyadmin_url text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`),
);
await db.execute(
sql.raw(
'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)',
),
);
app.log.info('Database connected');
});
+358
View File
@@ -0,0 +1,358 @@
import fp from 'fastify-plugin';
import type { FastifyInstance } from 'fastify';
import { and, eq } from 'drizzle-orm';
import { Server as SocketIOServer } from 'socket.io';
import { nodes, organizationMembers, servers } from '@source/database';
import { ROLES } from '@source/shared';
import type { Role } from '@source/shared';
import { getJwt } from '../lib/jwt.js';
import type { AccessTokenPayload } from '../lib/jwt.js';
import {
daemonOpenConsoleStream,
daemonSendCommand,
type DaemonConsoleStreamHandle,
type DaemonNodeConnection,
} from '../lib/daemon.js';
declare module 'fastify' {
interface FastifyInstance {
io: SocketIOServer;
}
}
type ConsolePermission = 'console.read' | 'console.write';
type ConsoleCommandAck = {
requestId: string | null;
ok: boolean;
error?: string;
};
interface SharedConsoleStream {
handle: DaemonConsoleStreamHandle;
subscribers: number;
}
function roomForServer(serverId: string): string {
return `server:console:${serverId}`;
}
export default fp(async (app: FastifyInstance) => {
const io = new SocketIOServer(app.server, {
path: '/socket.io',
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
},
});
app.decorate('io', io);
const serverStreams = new Map<string, SharedConsoleStream>();
const socketSubscriptions = new Map<string, string>();
const clearServerSubscriptions = (serverId: string) => {
for (const [socketId, subscribedServerId] of socketSubscriptions.entries()) {
if (subscribedServerId === serverId) {
socketSubscriptions.delete(socketId);
}
}
};
io.use((socket, next) => {
const token =
typeof socket.handshake.auth?.token === 'string' ? socket.handshake.auth.token : null;
if (!token) {
next(new Error('Unauthorized'));
return;
}
const verifier = getJwt(app)?.verify;
if (typeof verifier !== 'function') {
next(new Error('Authentication is not configured'));
return;
}
try {
const payload = verifier(token) as AccessTokenPayload;
(socket.data as { user?: AccessTokenPayload }).user = payload;
next();
} catch {
next(new Error('Unauthorized'));
}
});
io.on('connection', (socket) => {
const cleanupSocketStream = () => {
const subscribedServerId = socketSubscriptions.get(socket.id);
if (!subscribedServerId) return;
socketSubscriptions.delete(socket.id);
socket.leave(roomForServer(subscribedServerId));
const shared = serverStreams.get(subscribedServerId);
if (!shared) return;
shared.subscribers = Math.max(0, shared.subscribers - 1);
if (shared.subscribers === 0) {
shared.handle.close();
serverStreams.delete(subscribedServerId);
}
};
socket.on('server:console:join', async (payload: unknown) => {
const serverId =
typeof (payload as { serverId?: unknown })?.serverId === 'string'
? (payload as { serverId: string }).serverId
: '';
if (!serverId) {
socket.emit('server:console:output', { line: '[error] Invalid server id' });
return;
}
const user = (socket.data as { user?: AccessTokenPayload }).user;
if (!user) {
socket.emit('server:console:output', { line: '[error] Unauthorized' });
return;
}
const server = await getServerContext(app, serverId);
if (!server) {
socket.emit('server:console:output', { line: '[error] Server not found' });
return;
}
const allowed = await hasConsolePermission(app, user, server.organizationId, 'console.read');
if (!allowed) {
socket.emit('server:console:output', { line: '[error] Missing permission: console.read' });
return;
}
const previousSubscription = socketSubscriptions.get(socket.id);
if (previousSubscription === serverId) {
return;
}
cleanupSocketStream();
socket.join(roomForServer(serverId));
let shared = serverStreams.get(serverId);
if (!shared) {
try {
const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid);
const room = roomForServer(serverId);
streamHandle.stream.on('data', (output) => {
io.to(room).emit('server:console:output', { line: output.line });
});
streamHandle.stream.on('end', () => {
const current = serverStreams.get(serverId);
if (current?.handle !== streamHandle) return;
serverStreams.delete(serverId);
clearServerSubscriptions(serverId);
io.to(room).emit('server:console:output', { line: '[console] Stream ended' });
io.in(room).socketsLeave(room);
});
streamHandle.stream.on('error', (error) => {
const current = serverStreams.get(serverId);
if (current?.handle !== streamHandle) return;
serverStreams.delete(serverId);
clearServerSubscriptions(serverId);
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid },
'Console stream failed',
);
io.to(room).emit('server:console:output', { line: '[error] Console stream failed' });
io.in(room).socketsLeave(room);
});
shared = {
handle: streamHandle,
subscribers: 0,
};
serverStreams.set(serverId, shared);
} catch (error) {
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
'Failed to open console stream',
);
socket.leave(roomForServer(serverId));
socket.emit('server:console:output', { line: '[error] Failed to open console stream' });
return;
}
}
shared.subscribers += 1;
socketSubscriptions.set(socket.id, serverId);
});
socket.on('server:console:leave', () => {
cleanupSocketStream();
});
socket.on('server:console:command', async (payload: unknown) => {
const body = payload as {
serverId?: unknown;
orgId?: unknown;
command?: unknown;
requestId?: unknown;
};
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
const command = typeof body.command === 'string' ? body.command.trim() : '';
const requestId =
typeof body.requestId === 'string' && body.requestId.trim() ? body.requestId.trim() : null;
if (!serverId || !orgId || !command) {
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
const ack: ConsoleCommandAck = {
requestId,
ok: false,
error: 'Invalid command payload',
};
socket.emit('server:console:command:ack', ack);
return;
}
const user = (socket.data as { user?: AccessTokenPayload }).user;
if (!user) {
socket.emit('server:console:output', { line: '[error] Unauthorized' });
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Unauthorized' };
socket.emit('server:console:command:ack', ack);
return;
}
const server = await getServerContext(app, serverId, orgId);
if (!server) {
socket.emit('server:console:output', { line: '[error] Server not found' });
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Server not found' };
socket.emit('server:console:command:ack', ack);
return;
}
const allowed = await hasConsolePermission(app, user, orgId, 'console.write');
if (!allowed) {
socket.emit('server:console:output', { line: '[error] Missing permission: console.write' });
const ack: ConsoleCommandAck = {
requestId,
ok: false,
error: 'Missing permission: console.write',
};
socket.emit('server:console:command:ack', ack);
return;
}
try {
await daemonSendCommand(server.node, server.serverUuid, command);
const ack: ConsoleCommandAck = { requestId, ok: true };
socket.emit('server:console:command:ack', ack);
} catch (error) {
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
'Failed to send console command',
);
// The daemon explains *why* (server not running, no RCON password, …) —
// showing that beats a generic failure the user cannot act on.
const reason = daemonErrorReason(error);
socket.emit('server:console:output', { line: `[error] ${reason}` });
const ack: ConsoleCommandAck = { requestId, ok: false, error: reason };
socket.emit('server:console:command:ack', ack);
}
});
socket.on('disconnect', () => {
cleanupSocketStream();
});
});
app.addHook('onClose', async () => {
for (const stream of serverStreams.values()) {
stream.handle.close();
}
serverStreams.clear();
socketSubscriptions.clear();
await new Promise<void>((resolve) => {
io.close(() => resolve());
});
});
});
/** Strip the gRPC status prefix so the console shows the daemon's own wording. */
function daemonErrorReason(error: unknown): string {
const raw = error instanceof Error ? error.message.trim() : '';
if (!raw) return 'Failed to send command';
const withoutStatus = raw.replace(/^\d+\s+[A-Z_]+:\s*/, '').trim();
return withoutStatus || 'Failed to send command';
}
async function hasConsolePermission(
app: FastifyInstance,
user: AccessTokenPayload,
orgId: string,
permission: ConsolePermission,
): Promise<boolean> {
if (user.isSuperAdmin) return true;
const member = await app.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.organizationId, orgId),
eq(organizationMembers.userId, user.sub),
),
columns: {
role: true,
customPermissions: true,
},
});
if (!member) return false;
const custom = (member.customPermissions ?? {}) as Record<string, boolean>;
if (permission in custom) {
return Boolean(custom[permission]);
}
const rolePerms = ROLES[member.role as Role]?.permissions ?? [];
return (rolePerms as readonly string[]).includes(permission);
}
async function getServerContext(
app: FastifyInstance,
serverId: string,
orgId?: string,
): Promise<{
organizationId: string;
serverUuid: string;
node: DaemonNodeConnection;
} | null> {
const whereClause = orgId
? and(eq(servers.id, serverId), eq(servers.organizationId, orgId))
: eq(servers.id, serverId);
const [row] = await app.db
.select({
organizationId: servers.organizationId,
serverUuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(whereClause);
if (!row) return null;
return {
organizationId: row.organizationId,
serverUuid: row.serverUuid,
node: {
fqdn: row.nodeFqdn,
grpcPort: row.nodeGrpcPort,
daemonToken: row.nodeDaemonToken,
},
};
}
+961
View File
@@ -0,0 +1,961 @@
import type { FastifyInstance } from 'fastify';
import multipart from '@fastify/multipart';
import { eq, desc, count, and } from 'drizzle-orm';
import { Type } from '@sinclair/typebox';
import { users, games, nodes, auditLogs, plugins, pluginReleases } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requireSuperAdmin } from '../../lib/permissions.js';
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
import type { PaginationQuery } from '../../lib/pagination.js';
import { uploadPluginArtifact } from '../../lib/cdn.js';
import * as yazl from 'yazl';
import {
CreateGameSchema,
UpdateGameSchema,
GameIdParamSchema,
PluginIdParamSchema,
PluginReleaseIdParamSchema,
CreateGlobalPluginSchema,
UpdateGlobalPluginSchema,
ImportPluginsSchema,
CreatePluginReleaseSchema,
UpdatePluginReleaseSchema,
} from './schemas.js';
type ReleaseChannel = 'stable' | 'beta' | 'alpha';
interface UploadArtifactFile {
relativePath: string;
data: Buffer;
}
interface UploadJsonFile {
filename: string;
data: Buffer;
}
function toSlug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 200);
}
function sanitizeRelativeSegments(path: string): string[] {
const segments = path.replace(/\\/g, '/').split('/').filter(Boolean);
const normalized: string[] = [];
for (const segment of segments) {
if (segment === '.' || segment === '') continue;
if (segment === '..') {
throw AppError.badRequest('Invalid artifact path segment');
}
normalized.push(segment);
}
return normalized;
}
function normalizeRelativePath(path: string, fallbackName: string): string {
const segments = sanitizeRelativeSegments(path);
if (segments.length === 0) {
return sanitizeRelativeSegments(fallbackName).join('/');
}
return segments.join('/');
}
function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] {
if (rawValue === undefined || rawValue === null || rawValue === '') return [];
if (typeof rawValue !== 'string') {
throw AppError.badRequest(`${fieldName} must be a JSON string`);
}
let parsed: unknown;
try {
parsed = JSON.parse(rawValue);
} catch {
throw AppError.badRequest(`${fieldName} is not valid JSON`);
}
if (!Array.isArray(parsed)) {
throw AppError.badRequest(`${fieldName} must be a JSON array`);
}
return parsed;
}
function parseJsonArrayUploadFile(file: UploadJsonFile | null, fieldName: string): unknown[] {
if (!file) return [];
let rawValue = file.data.toString('utf8');
if (rawValue.charCodeAt(0) === 0xfeff) {
rawValue = rawValue.slice(1);
}
return parseJsonArrayField(rawValue, fieldName);
}
function parseJsonArrayInput(
rawValue: unknown,
file: UploadJsonFile | null,
fieldName: string,
): unknown[] {
if (file) return parseJsonArrayUploadFile(file, fieldName);
return parseJsonArrayField(rawValue, fieldName);
}
function parseOptionalBoolean(rawValue: unknown): boolean | undefined {
if (rawValue === undefined || rawValue === null || rawValue === '') return undefined;
if (typeof rawValue === 'boolean') return rawValue;
if (typeof rawValue !== 'string') return undefined;
const normalized = rawValue.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on')
return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off')
return false;
return undefined;
}
function parseReleaseChannel(rawValue: unknown): ReleaseChannel {
if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue;
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase();
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable')
return normalized;
}
return 'stable';
}
async function zipArtifacts(files: UploadArtifactFile[]): Promise<Buffer> {
return await new Promise<Buffer>((resolve, reject) => {
const archive = new yazl.ZipFile();
const chunks: Buffer[] = [];
archive.outputStream.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
archive.outputStream.on('error', reject);
archive.outputStream.on('end', () => {
resolve(Buffer.concat(chunks));
});
for (const file of files) {
archive.addBuffer(file.data, file.relativePath.replace(/^\/+/g, ''));
}
archive.end();
});
}
async function resolveImportGame(
app: FastifyInstance,
{
gameId,
gameSlug,
}: {
gameId?: string;
gameSlug?: string;
},
) {
if (gameId) {
const game = await app.db.query.games.findFirst({
where: eq(games.id, gameId),
});
if (!game) {
throw AppError.notFound(`Game not found: ${gameId}`);
}
return game;
}
const normalizedSlug = gameSlug?.trim().toLowerCase();
if (normalizedSlug) {
const game = await app.db.query.games.findFirst({
where: eq(games.slug, normalizedSlug),
});
if (!game) {
throw AppError.notFound(`Game not found: ${normalizedSlug}`);
}
return game;
}
throw AppError.badRequest('gameId or gameSlug is required for each import item');
}
export default async function adminRoutes(app: FastifyInstance) {
await app.register(multipart, {
limits: {
files: 200,
parts: 600,
fileSize: 512 * 1024 * 1024,
},
});
// All admin routes require auth + super admin
app.addHook('onRequest', app.authenticate);
app.addHook('onRequest', async (request) => {
requireSuperAdmin(request);
});
// === Users ===
// GET /api/admin/users
app.get('/users', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery);
const [totalResult] = await app.db.select({ count: count() }).from(users);
const userList = await app.db
.select({
id: users.id,
email: users.email,
username: users.username,
isSuperAdmin: users.isSuperAdmin,
avatarUrl: users.avatarUrl,
createdAt: users.createdAt,
})
.from(users)
.limit(limit)
.offset(offset)
.orderBy(users.createdAt);
return paginatedResponse(userList, totalResult!.count, page, perPage);
});
// === Games ===
// GET /api/admin/games
app.get('/games', async () => {
const gameList = await app.db.select().from(games).orderBy(games.name);
return { data: gameList };
});
// POST /api/admin/games
app.post('/games', { schema: CreateGameSchema }, async (request, reply) => {
const body = request.body as {
slug: string;
name: string;
dockerImage: string;
defaultPort: number;
startupCommand: string;
stopCommand?: string;
stopTimeoutSeconds?: number;
containerDataPath?: string;
configFiles?: unknown[];
environmentVars?: unknown[];
automationRules?: unknown[];
};
const existing = await app.db.query.games.findFirst({
where: eq(games.slug, body.slug),
});
if (existing) throw AppError.conflict('Game slug already exists');
const [game] = await app.db
.insert(games)
.values({
...body,
configFiles: body.configFiles ?? [],
environmentVars: body.environmentVars ?? [],
automationRules: body.automationRules ?? [],
})
.returning();
return reply.code(201).send(game);
});
// PATCH /api/admin/games/:gameId
app.patch(
'/games/:gameId',
{ schema: { ...GameIdParamSchema, ...UpdateGameSchema } },
async (request) => {
const { gameId } = request.params as { gameId: string };
const body = request.body as Record<string, unknown>;
const [updated] = await app.db
.update(games)
.set({ ...body, updatedAt: new Date() })
.where(eq(games.id, gameId))
.returning();
if (!updated) throw AppError.notFound('Game not found');
return updated;
},
);
// === Nodes (global view) ===
// === Global Plugins ===
app.get(
'/plugins',
{
schema: {
querystring: Type.Object({
gameId: Type.Optional(Type.String({ format: 'uuid' })),
}),
},
},
async (request) => {
const { gameId } = request.query as { gameId?: string };
const rows = await app.db
.select({
id: plugins.id,
gameId: plugins.gameId,
name: plugins.name,
slug: plugins.slug,
description: plugins.description,
source: plugins.source,
isGlobal: plugins.isGlobal,
updatedAt: plugins.updatedAt,
gameName: games.name,
gameSlug: games.slug,
})
.from(plugins)
.innerJoin(games, eq(plugins.gameId, games.id))
.where(gameId ? eq(plugins.gameId, gameId) : undefined)
.orderBy(plugins.name);
return { data: rows };
},
);
app.post('/plugins', { schema: CreateGlobalPluginSchema }, async (request, reply) => {
const body = request.body as {
gameId: string;
name: string;
slug?: string;
description?: string;
source?: 'manual' | 'spiget';
};
const game = await app.db.query.games.findFirst({
where: eq(games.id, body.gameId),
});
if (!game) throw AppError.notFound('Game not found');
const slug = toSlug(body.slug ?? body.name);
if (!slug) throw AppError.badRequest('Plugin slug is invalid');
const existing = await app.db.query.plugins.findFirst({
where: and(eq(plugins.gameId, body.gameId), eq(plugins.slug, slug)),
});
if (existing) throw AppError.conflict('Plugin slug already exists for this game');
const [created] = await app.db
.insert(plugins)
.values({
gameId: body.gameId,
name: body.name,
slug,
description: body.description ?? null,
source: body.source ?? 'manual',
isGlobal: true,
})
.returning();
return reply.code(201).send(created);
});
app.post('/plugins/import', { schema: ImportPluginsSchema }, async (request) => {
const body = request.body as {
defaultGameId?: string;
defaultGameSlug?: string;
stopOnError?: boolean;
items: Array<{
gameId?: string;
gameSlug?: string;
plugin: {
name: string;
slug?: string;
description?: string;
source?: 'manual' | 'spiget';
isGlobal?: boolean;
};
release?: {
version: string;
channel?: 'stable' | 'beta' | 'alpha';
artifactType?: 'file' | 'zip';
artifactUrl: string;
destination?: string;
fileName?: string;
changelog?: string;
installSchema?: unknown[];
configTemplates?: unknown[];
isPublished?: boolean;
};
}>;
};
const results: Array<{
index: number;
success: boolean;
gameId?: string;
gameSlug?: string;
pluginId?: string;
pluginSlug?: string;
pluginAction?: 'created' | 'updated';
releaseId?: string;
releaseVersion?: string;
releaseAction?: 'created' | 'updated' | 'skipped';
error?: string;
}> = [];
for (const [index, item] of body.items.entries()) {
try {
const game = await resolveImportGame(app, {
gameId: item.gameId ?? body.defaultGameId,
gameSlug: item.gameSlug ?? body.defaultGameSlug,
});
const pluginPayload = item.plugin;
const pluginSlug = toSlug(pluginPayload.slug ?? pluginPayload.name);
if (!pluginSlug) {
throw AppError.badRequest('Plugin slug is invalid');
}
const existingPlugin = await app.db.query.plugins.findFirst({
where: and(eq(plugins.gameId, game.id), eq(plugins.slug, pluginSlug)),
});
let pluginRecord: typeof plugins.$inferSelect;
let pluginAction: 'created' | 'updated';
if (existingPlugin) {
const [updatedPlugin] = await app.db
.update(plugins)
.set({
name: pluginPayload.name,
slug: pluginSlug,
description:
pluginPayload.description !== undefined
? pluginPayload.description
: existingPlugin.description,
source: pluginPayload.source ?? existingPlugin.source,
isGlobal: pluginPayload.isGlobal ?? existingPlugin.isGlobal,
updatedAt: new Date(),
})
.where(eq(plugins.id, existingPlugin.id))
.returning();
if (!updatedPlugin) {
throw AppError.notFound('Plugin not found');
}
pluginRecord = updatedPlugin;
pluginAction = 'updated';
} else {
const [createdPlugin] = await app.db
.insert(plugins)
.values({
gameId: game.id,
name: pluginPayload.name,
slug: pluginSlug,
description: pluginPayload.description ?? null,
source: pluginPayload.source ?? 'manual',
isGlobal: pluginPayload.isGlobal ?? true,
})
.returning();
if (!createdPlugin) {
throw new AppError(500, 'Failed to create plugin');
}
pluginRecord = createdPlugin;
pluginAction = 'created';
}
let releaseAction: 'created' | 'updated' | 'skipped' = 'skipped';
let releaseRecord: typeof pluginReleases.$inferSelect | null = null;
if (item.release) {
const releasePayload = item.release;
const existingRelease = await app.db.query.pluginReleases.findFirst({
where: and(
eq(pluginReleases.pluginId, pluginRecord.id),
eq(pluginReleases.version, releasePayload.version),
),
});
if (existingRelease) {
const [updatedRelease] = await app.db
.update(pluginReleases)
.set({
channel: releasePayload.channel ?? existingRelease.channel,
artifactType: releasePayload.artifactType ?? existingRelease.artifactType,
artifactUrl: releasePayload.artifactUrl,
destination:
releasePayload.destination !== undefined
? releasePayload.destination
: existingRelease.destination,
fileName:
releasePayload.fileName !== undefined
? releasePayload.fileName
: existingRelease.fileName,
changelog:
releasePayload.changelog !== undefined
? releasePayload.changelog
: existingRelease.changelog,
installSchema: releasePayload.installSchema ?? existingRelease.installSchema,
configTemplates: releasePayload.configTemplates ?? existingRelease.configTemplates,
isPublished: releasePayload.isPublished ?? existingRelease.isPublished,
updatedAt: new Date(),
})
.where(eq(pluginReleases.id, existingRelease.id))
.returning();
if (!updatedRelease) {
throw AppError.notFound('Plugin release not found');
}
releaseRecord = updatedRelease;
releaseAction = 'updated';
} else {
const [createdRelease] = await app.db
.insert(pluginReleases)
.values({
pluginId: pluginRecord.id,
version: releasePayload.version,
channel: releasePayload.channel ?? 'stable',
artifactType: releasePayload.artifactType ?? 'file',
artifactUrl: releasePayload.artifactUrl,
destination: releasePayload.destination ?? null,
fileName: releasePayload.fileName ?? null,
changelog: releasePayload.changelog ?? null,
installSchema: releasePayload.installSchema ?? [],
configTemplates: releasePayload.configTemplates ?? [],
isPublished: releasePayload.isPublished ?? true,
createdByUserId: request.user.sub,
})
.returning();
if (!createdRelease) {
throw new AppError(500, 'Failed to create plugin release');
}
releaseRecord = createdRelease;
releaseAction = 'created';
}
}
results.push({
index,
success: true,
gameId: game.id,
gameSlug: game.slug,
pluginId: pluginRecord.id,
pluginSlug: pluginRecord.slug,
pluginAction,
releaseId: releaseRecord?.id,
releaseVersion: releaseRecord?.version,
releaseAction,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (body.stopOnError) {
throw AppError.badRequest(`Import failed at item ${index}: ${message}`);
}
results.push({
index,
success: false,
error: message,
});
}
}
const succeeded = results.filter((result) => result.success).length;
const failed = results.length - succeeded;
return {
results,
summary: {
total: results.length,
succeeded,
failed,
},
};
});
app.patch(
'/plugins/:pluginId',
{ schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } },
async (request) => {
const { pluginId } = request.params as { pluginId: string };
const body = request.body as {
name?: string;
slug?: string;
description?: string;
source?: 'manual' | 'spiget';
isGlobal?: boolean;
};
const existing = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!existing) throw AppError.notFound('Plugin not found');
const nextSlug =
body.slug !== undefined
? toSlug(body.slug)
: body.name !== undefined
? toSlug(body.name)
: existing.slug;
if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid');
const duplicate = await app.db.query.plugins.findFirst({
where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)),
});
if (duplicate && duplicate.id !== existing.id) {
throw AppError.conflict('Plugin slug already exists for this game');
}
const [updated] = await app.db
.update(plugins)
.set({
name: body.name ?? existing.name,
slug: nextSlug,
description: body.description ?? existing.description,
source: body.source ?? existing.source,
isGlobal: body.isGlobal ?? existing.isGlobal,
updatedAt: new Date(),
})
.where(eq(plugins.id, existing.id))
.returning();
if (!updated) throw AppError.notFound('Plugin not found');
return updated;
},
);
app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => {
const { pluginId } = request.params as { pluginId: string };
const plugin = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!plugin) throw AppError.notFound('Plugin not found');
const releases = await app.db
.select()
.from(pluginReleases)
.where(eq(pluginReleases.pluginId, pluginId))
.orderBy(desc(pluginReleases.createdAt));
return { plugin, releases };
});
app.post(
'/plugins/:pluginId/releases/upload',
{ schema: PluginIdParamSchema },
async (request, reply) => {
const { pluginId } = request.params as { pluginId: string };
const plugin = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!plugin) throw AppError.notFound('Plugin not found');
if (!request.isMultipart()) {
throw AppError.badRequest('Content-Type must be multipart/form-data');
}
const fields: Record<string, unknown> = {};
const files: UploadArtifactFile[] = [];
let installSchemaFile: UploadJsonFile | null = null;
let configTemplatesFile: UploadJsonFile | null = null;
const relativePathQueue: string[] = [];
for await (const part of request.parts()) {
if (part.type === 'file') {
if (part.fieldname === 'installSchemaFile') {
const data = await part.toBuffer();
if (data.length > 0) {
installSchemaFile = {
filename: part.filename || 'install-schema.json',
data,
};
}
continue;
}
if (part.fieldname === 'configTemplatesFile') {
const data = await part.toBuffer();
if (data.length > 0) {
configTemplatesFile = {
filename: part.filename || 'config-templates.json',
data,
};
}
continue;
}
const fallbackName = `artifact-${files.length + 1}.bin`;
const queuedPath = relativePathQueue.shift();
const relativePath = normalizeRelativePath(
queuedPath ?? part.filename ?? '',
fallbackName,
);
const data = await part.toBuffer();
if (data.length === 0) continue;
files.push({ relativePath, data });
} else {
if (part.fieldname === 'relativePath') {
const raw = typeof part.value === 'string' ? part.value : '';
relativePathQueue.push(raw);
continue;
}
fields[part.fieldname] = part.value;
}
}
if (files.length === 0) {
throw AppError.badRequest('At least one file is required');
}
const version = typeof fields.version === 'string' ? fields.version.trim() : '';
if (!version) {
throw AppError.badRequest('version is required');
}
const channel = parseReleaseChannel(fields.channel);
const destination =
typeof fields.destination === 'string' && fields.destination.trim().length > 0
? fields.destination.trim()
: null;
const changelog =
typeof fields.changelog === 'string' && fields.changelog.trim().length > 0
? fields.changelog
: null;
const isPublished = parseOptionalBoolean(fields.isPublished) ?? true;
const installSchema = parseJsonArrayInput(
fields.installSchema,
installSchemaFile,
'installSchema',
);
const configTemplates = parseJsonArrayInput(
fields.configTemplates,
configTemplatesFile,
'configTemplates',
);
const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : '';
const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/'));
const shouldZip = files.length > 1 || hasNestedPaths;
let artifactType: 'file' | 'zip';
let artifactContent: Buffer;
let uploadFileName: string;
let releaseFileName: string | null;
if (shouldZip) {
artifactType = 'zip';
artifactContent = await zipArtifacts(files);
const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`;
uploadFileName = suggestedName.toLowerCase().endsWith('.zip')
? suggestedName
: `${suggestedName}.zip`;
releaseFileName = null;
} else {
artifactType = 'file';
const [singleFile] = files;
if (!singleFile) {
throw AppError.badRequest('No artifact file received');
}
artifactContent = singleFile.data;
const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin';
uploadFileName = rawFileName || originalName;
releaseFileName = uploadFileName;
}
const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, {
pluginId: plugin.id,
pluginSlug: plugin.slug,
releaseVersion: version,
uploadedBy: request.user.sub,
uploadMode: shouldZip ? 'archive' : 'single',
sourceFileCount: files.length,
});
const [created] = await app.db
.insert(pluginReleases)
.values({
pluginId: plugin.id,
version,
channel,
artifactType,
artifactUrl: uploaded.artifactPointer,
destination,
fileName: releaseFileName,
changelog,
installSchema,
configTemplates,
isPublished,
createdByUserId: request.user.sub,
})
.returning();
return reply.code(201).send({
release: created,
artifact: {
bucket: uploaded.bucket,
fileId: uploaded.file.id,
storedName: uploaded.file.storedName,
originalName: uploaded.file.originalName,
pointer: uploaded.artifactPointer,
},
});
},
);
app.post(
'/plugins/:pluginId/releases',
{ schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } },
async (request, reply) => {
const { pluginId } = request.params as { pluginId: string };
const body = request.body as {
version: string;
channel?: 'stable' | 'beta' | 'alpha';
artifactType?: 'file' | 'zip';
artifactUrl: string;
destination?: string;
fileName?: string;
changelog?: string;
installSchema?: unknown[];
configTemplates?: unknown[];
isPublished?: boolean;
cloneFromReleaseId?: string;
};
const plugin = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!plugin) throw AppError.notFound('Plugin not found');
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
if (body.cloneFromReleaseId) {
baseRelease =
(await app.db.query.pluginReleases.findFirst({
where: and(
eq(pluginReleases.id, body.cloneFromReleaseId),
eq(pluginReleases.pluginId, pluginId),
),
})) ?? null;
if (!baseRelease) {
throw AppError.notFound('Clone source release not found');
}
}
const [created] = await app.db
.insert(pluginReleases)
.values({
pluginId,
version: body.version,
channel: body.channel ?? baseRelease?.channel ?? 'stable',
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
artifactUrl: body.artifactUrl,
destination: body.destination ?? baseRelease?.destination ?? null,
fileName: body.fileName ?? baseRelease?.fileName ?? null,
changelog: body.changelog ?? baseRelease?.changelog ?? null,
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
createdByUserId: request.user.sub,
})
.returning();
return reply.code(201).send(created);
},
);
app.patch(
'/plugins/:pluginId/releases/:releaseId',
{ schema: { ...PluginReleaseIdParamSchema, ...UpdatePluginReleaseSchema } },
async (request) => {
const { pluginId, releaseId } = request.params as { pluginId: string; releaseId: string };
const body = request.body as {
version?: string;
channel?: 'stable' | 'beta' | 'alpha';
artifactType?: 'file' | 'zip';
artifactUrl?: string;
destination?: string;
fileName?: string;
changelog?: string;
installSchema?: unknown[];
configTemplates?: unknown[];
isPublished?: boolean;
};
const release = await app.db.query.pluginReleases.findFirst({
where: and(eq(pluginReleases.id, releaseId), eq(pluginReleases.pluginId, pluginId)),
});
if (!release) throw AppError.notFound('Plugin release not found');
const [updated] = await app.db
.update(pluginReleases)
.set({
version: body.version ?? release.version,
channel: body.channel ?? release.channel,
artifactType: body.artifactType ?? release.artifactType,
artifactUrl: body.artifactUrl ?? release.artifactUrl,
destination: body.destination ?? release.destination,
fileName: body.fileName ?? release.fileName,
changelog: body.changelog ?? release.changelog,
installSchema: body.installSchema ?? release.installSchema,
configTemplates: body.configTemplates ?? release.configTemplates,
isPublished: body.isPublished ?? release.isPublished,
updatedAt: new Date(),
})
.where(eq(pluginReleases.id, release.id))
.returning();
if (!updated) throw AppError.notFound('Plugin release not found');
return updated;
},
);
// GET /api/admin/nodes
app.get('/nodes', async () => {
const nodeList = await app.db.select().from(nodes).orderBy(nodes.createdAt);
return { data: nodeList };
});
// === Audit Logs ===
// GET /api/admin/audit-logs
app.get('/audit-logs', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery);
const [totalResult] = await app.db.select({ count: count() }).from(auditLogs);
const logs = await app.db
.select({
id: auditLogs.id,
organizationId: auditLogs.organizationId,
userId: auditLogs.userId,
serverId: auditLogs.serverId,
action: auditLogs.action,
metadata: auditLogs.metadata,
ipAddress: auditLogs.ipAddress,
createdAt: auditLogs.createdAt,
userEmail: users.email,
userName: users.username,
})
.from(auditLogs)
.innerJoin(users, eq(auditLogs.userId, users.id))
.orderBy(desc(auditLogs.createdAt))
.limit(limit)
.offset(offset);
return paginatedResponse(logs, totalResult!.count, page, perPage);
});
}
+177
View File
@@ -0,0 +1,177 @@
import { Type } from '@sinclair/typebox';
export const CreateGameSchema = {
body: Type.Object({
slug: Type.String({ minLength: 1, maxLength: 100, pattern: '^[a-z0-9-]+$' }),
name: Type.String({ minLength: 1, maxLength: 255 }),
dockerImage: Type.String({ minLength: 1 }),
defaultPort: Type.Number({ minimum: 1, maximum: 65535 }),
startupCommand: Type.String({ minLength: 1 }),
stopCommand: Type.Optional(Type.String()),
stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })),
containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })),
configFiles: Type.Optional(Type.Array(Type.Any())),
environmentVars: Type.Optional(Type.Array(Type.Any())),
automationRules: Type.Optional(Type.Array(Type.Any())),
}),
};
export const UpdateGameSchema = {
body: Type.Object({
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
dockerImage: Type.Optional(Type.String({ minLength: 1 })),
defaultPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
startupCommand: Type.Optional(Type.String({ minLength: 1 })),
stopCommand: Type.Optional(Type.String()),
stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })),
containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })),
configFiles: Type.Optional(Type.Array(Type.Any())),
environmentVars: Type.Optional(Type.Array(Type.Any())),
automationRules: Type.Optional(Type.Array(Type.Any())),
}),
};
export const GameIdParamSchema = {
params: Type.Object({
gameId: Type.String({ format: 'uuid' }),
}),
};
export const PluginIdParamSchema = {
params: Type.Object({
pluginId: Type.String({ format: 'uuid' }),
}),
};
export const PluginReleaseIdParamSchema = {
params: Type.Object({
pluginId: Type.String({ format: 'uuid' }),
releaseId: Type.String({ format: 'uuid' }),
}),
};
export const CreateGlobalPluginSchema = {
body: Type.Object({
gameId: Type.String({ format: 'uuid' }),
name: Type.String({ minLength: 1, maxLength: 255 }),
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
description: Type.Optional(Type.String()),
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
}),
};
export const UpdateGlobalPluginSchema = {
body: Type.Object({
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
description: Type.Optional(Type.String()),
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
isGlobal: Type.Optional(Type.Boolean()),
}),
};
const ImportPluginPayloadSchema = Type.Object({
name: Type.String({ minLength: 1, maxLength: 255 }),
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
description: Type.Optional(Type.String()),
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
isGlobal: Type.Optional(Type.Boolean()),
});
export const ReleaseInstallFieldSchema = Type.Object({
key: Type.String({ minLength: 1, maxLength: 120 }),
label: Type.String({ minLength: 1, maxLength: 255 }),
type: Type.Union([
Type.Literal('text'),
Type.Literal('number'),
Type.Literal('boolean'),
Type.Literal('select'),
]),
description: Type.Optional(Type.String({ maxLength: 1000 })),
required: Type.Optional(Type.Boolean()),
defaultValue: Type.Optional(Type.Any()),
options: Type.Optional(
Type.Array(
Type.Object({
label: Type.String({ minLength: 1, maxLength: 255 }),
value: Type.String({ minLength: 1, maxLength: 255 }),
}),
),
),
min: Type.Optional(Type.Number()),
max: Type.Optional(Type.Number()),
pattern: Type.Optional(Type.String({ maxLength: 500 })),
secret: Type.Optional(Type.Boolean()),
});
export const ReleaseTemplateSchema = Type.Object({
path: Type.String({ minLength: 1 }),
content: Type.String(),
});
const ImportPluginReleasePayloadSchema = Type.Object({
version: Type.String({ minLength: 1, maxLength: 100 }),
channel: Type.Optional(
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
),
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
artifactUrl: Type.String({ format: 'uri' }),
destination: Type.Optional(Type.String({ minLength: 1 })),
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
changelog: Type.Optional(Type.String()),
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
isPublished: Type.Optional(Type.Boolean()),
});
export const ImportPluginsSchema = {
body: Type.Object({
defaultGameId: Type.Optional(Type.String({ format: 'uuid' })),
defaultGameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
stopOnError: Type.Optional(Type.Boolean()),
items: Type.Array(
Type.Object({
gameId: Type.Optional(Type.String({ format: 'uuid' })),
gameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
plugin: ImportPluginPayloadSchema,
release: Type.Optional(ImportPluginReleasePayloadSchema),
}),
{ minItems: 1, maxItems: 500 },
),
}),
};
export const CreatePluginReleaseSchema = {
body: Type.Object({
version: Type.String({ minLength: 1, maxLength: 100 }),
channel: Type.Optional(
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
),
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
artifactUrl: Type.String({ format: 'uri' }),
destination: Type.Optional(Type.String({ minLength: 1 })),
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
changelog: Type.Optional(Type.String()),
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
isPublished: Type.Optional(Type.Boolean()),
cloneFromReleaseId: Type.Optional(Type.String({ format: 'uuid' })),
}),
};
export const UpdatePluginReleaseSchema = {
body: Type.Object({
version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
channel: Type.Optional(
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
),
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
artifactUrl: Type.Optional(Type.String({ format: 'uri' })),
destination: Type.Optional(Type.String({ minLength: 1 })),
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
changelog: Type.Optional(Type.String()),
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
isPublished: Type.Optional(Type.Boolean()),
}),
};
+229
View File
@@ -0,0 +1,229 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { users } from '@source/database';
import { hashPassword, verifyPassword } from '../../lib/password.js';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.js';
import type { RefreshTokenPayload } from '../../lib/jwt.js';
import { AppError } from '../../lib/errors.js';
import { RegisterSchema, LoginSchema } from './schemas.js';
const REFRESH_COOKIE_NAME = 'refresh_token';
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/api/auth',
maxAge: 7 * 24 * 60 * 60, // 7 days in seconds
};
export default async function authRoutes(app: FastifyInstance) {
// POST /api/auth/register
app.post('/register', { schema: RegisterSchema }, async (request, reply) => {
const { email, username, password } = request.body as {
email: string;
username: string;
password: string;
};
// Check if email already exists
const existingEmail = await app.db.query.users.findFirst({
where: eq(users.email, email),
});
if (existingEmail) {
throw AppError.conflict('Email already in use', 'EMAIL_TAKEN');
}
// Check if username already exists
const existingUsername = await app.db.query.users.findFirst({
where: eq(users.username, username),
});
if (existingUsername) {
throw AppError.conflict('Username already in use', 'USERNAME_TAKEN');
}
const passwordHash = await hashPassword(password);
const [user] = await app.db
.insert(users)
.values({
email,
username,
passwordHash,
})
.returning({
id: users.id,
email: users.email,
username: users.username,
isSuperAdmin: users.isSuperAdmin,
});
// Generate tokens
const accessToken = signAccessToken(app, {
sub: user!.id,
email: user!.email,
isSuperAdmin: user!.isSuperAdmin,
});
const refreshToken = signRefreshToken(app, {
sub: user!.id,
type: 'refresh',
});
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
return reply.code(201).send({
user: {
id: user!.id,
email: user!.email,
username: user!.username,
isSuperAdmin: user!.isSuperAdmin,
},
accessToken,
});
});
// POST /api/auth/login
app.post('/login', { schema: LoginSchema }, async (request, reply) => {
const { email, password } = request.body as { email: string; password: string };
const user = await app.db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user) {
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
}
const isValid = await verifyPassword(user.passwordHash, password);
if (!isValid) {
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
}
const accessToken = signAccessToken(app, {
sub: user.id,
email: user.email,
isSuperAdmin: user.isSuperAdmin,
});
const refreshToken = signRefreshToken(app, {
sub: user.id,
type: 'refresh',
});
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
return {
user: {
id: user.id,
email: user.email,
username: user.username,
isSuperAdmin: user.isSuperAdmin,
avatarUrl: user.avatarUrl,
},
accessToken,
};
});
// POST /api/auth/refresh
app.post('/refresh', async (request, reply) => {
const token = request.cookies[REFRESH_COOKIE_NAME];
if (!token) {
throw AppError.unauthorized('No refresh token', 'NO_REFRESH_TOKEN');
}
let payload: RefreshTokenPayload;
try {
payload = verifyRefreshToken(app, token);
} catch {
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
throw AppError.unauthorized('Invalid refresh token', 'INVALID_REFRESH_TOKEN');
}
const user = await app.db.query.users.findFirst({
where: eq(users.id, payload.sub),
});
if (!user) {
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
throw AppError.unauthorized('User not found', 'USER_NOT_FOUND');
}
// Token rotation: issue new tokens
const accessToken = signAccessToken(app, {
sub: user.id,
email: user.email,
isSuperAdmin: user.isSuperAdmin,
});
const newRefreshToken = signRefreshToken(app, {
sub: user.id,
type: 'refresh',
});
reply.setCookie(REFRESH_COOKIE_NAME, newRefreshToken, REFRESH_COOKIE_OPTIONS);
return { accessToken };
});
// POST /api/auth/logout
app.post('/logout', async (_request, reply) => {
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
return { success: true };
});
// POST /api/auth/change-password
app.post('/change-password', { onRequest: [app.authenticate] }, async (request) => {
const { currentPassword, newPassword } = request.body as {
currentPassword: string;
newPassword: string;
};
if (!currentPassword || !newPassword || newPassword.length < 8) {
throw AppError.badRequest('New password must be at least 8 characters');
}
const user = await app.db.query.users.findFirst({
where: eq(users.id, request.user.sub),
});
if (!user) {
throw AppError.notFound('User not found');
}
const isValid = await verifyPassword(user.passwordHash, currentPassword);
if (!isValid) {
throw AppError.unauthorized('Current password is incorrect', 'INVALID_PASSWORD');
}
const newHash = await hashPassword(newPassword);
await app.db
.update(users)
.set({ passwordHash: newHash, updatedAt: new Date() })
.where(eq(users.id, user.id));
return { success: true };
});
// GET /api/auth/me
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
const payload = request.user;
const user = await app.db.query.users.findFirst({
where: eq(users.id, payload.sub),
columns: {
id: true,
email: true,
username: true,
isSuperAdmin: true,
avatarUrl: true,
createdAt: true,
},
});
if (!user) {
throw AppError.notFound('User not found');
}
return { user };
});
}
+16
View File
@@ -0,0 +1,16 @@
import { Type } from '@sinclair/typebox';
export const RegisterSchema = {
body: Type.Object({
email: Type.String({ format: 'email' }),
username: Type.String({ minLength: 3, maxLength: 100 }),
password: Type.String({ minLength: 8, maxLength: 128 }),
}),
};
export const LoginSchema = {
body: Type.Object({
email: Type.String({ format: 'email' }),
password: Type.String(),
}),
};
+13
View File
@@ -0,0 +1,13 @@
import type { FastifyInstance } from 'fastify';
import { games } from '@source/database';
export default async function gameRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// GET /api/games
app.get('/', async () => {
const gameList = await app.db.select().from(games).orderBy(games.name);
return { data: gameList };
});
}
+180
View File
@@ -0,0 +1,180 @@
import { Type } from '@sinclair/typebox';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { and, eq, lte } from 'drizzle-orm';
import { nodes, scheduledTasks, servers } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { computeNextRun } from '../../lib/schedule-utils.js';
function extractBearerToken(authHeader?: string): string | null {
if (!authHeader) return null;
const [scheme, token] = authHeader.split(' ');
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') return null;
return token;
}
function extractCdnWebhookSecret(request: FastifyRequest): string | null {
const byHeader = request.headers['x-cdn-webhook-secret'] ?? request.headers['x-webhook-secret'];
if (typeof byHeader === 'string' && byHeader.trim().length > 0) {
return byHeader.trim();
}
const authHeader =
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined;
return extractBearerToken(authHeader);
}
async function requireDaemonToken(
app: FastifyInstance,
request: FastifyRequest,
): Promise<{ id: string }> {
const token = extractBearerToken(
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined,
);
if (!token) {
throw AppError.unauthorized('Missing daemon bearer token', 'DAEMON_AUTH_MISSING');
}
const node = await app.db.query.nodes.findFirst({
where: eq(nodes.daemonToken, token),
columns: { id: true },
});
if (!node) {
throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID');
}
return node;
}
export default async function internalRoutes(app: FastifyInstance) {
app.post(
'/cdn/webhook/plugins',
{
schema: {
body: Type.Optional(Type.Unknown()),
},
},
async (request, reply) => {
const configuredSecret = process.env.CDN_WEBHOOK_SECRET?.trim();
if (configuredSecret) {
const providedSecret = extractCdnWebhookSecret(request);
if (!providedSecret || providedSecret !== configuredSecret) {
throw AppError.unauthorized('Invalid CDN webhook secret', 'CDN_WEBHOOK_AUTH_INVALID');
}
}
const body = request.body as Record<string, unknown> | undefined;
const eventType =
typeof body?.eventType === 'string'
? body.eventType
: typeof body?.type === 'string'
? body.type
: 'unknown';
request.log.info({ eventType, payload: body }, 'Received CDN plugin webhook event');
return reply.code(202).send({ accepted: true });
},
);
app.get('/schedules/due', async (request) => {
const node = await requireDaemonToken(app, request);
const now = new Date();
const dueTasks = await app.db
.select({
id: scheduledTasks.id,
serverUuid: servers.uuid,
action: scheduledTasks.action,
payload: scheduledTasks.payload,
scheduleType: scheduledTasks.scheduleType,
isActive: scheduledTasks.isActive,
nextRunAt: scheduledTasks.nextRunAt,
})
.from(scheduledTasks)
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
.where(
and(
eq(servers.nodeId, node.id),
eq(scheduledTasks.isActive, true),
lte(scheduledTasks.nextRunAt, now),
),
);
return {
tasks: dueTasks.map((task) => ({
id: task.id,
server_uuid: task.serverUuid,
action: task.action,
payload: task.payload,
schedule_type: task.scheduleType,
is_active: task.isActive,
next_run_at: task.nextRunAt?.toISOString() ?? null,
})),
};
});
app.post(
'/schedules/:taskId/ack',
{
schema: {
params: Type.Object({
taskId: Type.String(),
}),
},
},
async (request) => {
const node = await requireDaemonToken(app, request);
const { taskId } = request.params as { taskId: string };
const [task] = await app.db
.select({
id: scheduledTasks.id,
isActive: scheduledTasks.isActive,
scheduleType: scheduledTasks.scheduleType,
scheduleData: scheduledTasks.scheduleData,
})
.from(scheduledTasks)
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
.where(and(eq(scheduledTasks.id, taskId), eq(servers.nodeId, node.id)));
if (!task) {
throw AppError.notFound('Scheduled task not found');
}
const now = new Date();
const nextRunAt = task.isActive
? computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>)
: null;
await app.db
.update(scheduledTasks)
.set({
lastRunAt: now,
nextRunAt,
updatedAt: now,
})
.where(eq(scheduledTasks.id, taskId));
return { success: true, taskId };
},
);
app.post(
'/servers/:serverUuid/backup',
{
schema: {
params: Type.Object({
serverUuid: Type.String(),
}),
},
},
async (request) => {
await requireDaemonToken(app, request);
const { serverUuid } = request.params as { serverUuid: string };
return { success: true, serverUuid };
},
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Type } from '@sinclair/typebox';
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { nodes } from '@source/database';
import { AppError } from '../../lib/errors.js';
const HeartbeatSchema = {
body: Type.Object({
active_servers: Type.Number({ minimum: 0 }),
total_servers: Type.Number({ minimum: 0 }),
version: Type.String(),
}),
};
function extractBearerToken(authHeader?: string): string | null {
if (!authHeader) return null;
const [scheme, token] = authHeader.split(' ');
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') return null;
return token;
}
export default async function daemonNodeRoutes(app: FastifyInstance) {
// POST /api/nodes/heartbeat
app.post('/heartbeat', { schema: HeartbeatSchema }, async (request) => {
const token = extractBearerToken(
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined,
);
if (!token) {
throw AppError.unauthorized('Missing daemon bearer token', 'DAEMON_AUTH_MISSING');
}
const node = await app.db.query.nodes.findFirst({
where: eq(nodes.daemonToken, token),
columns: { id: true },
});
if (!node) {
throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID');
}
const now = new Date();
await app.db
.update(nodes)
.set({
isOnline: true,
lastHeartbeat: now,
updatedAt: now,
})
.where(eq(nodes.id, node.id));
const body = request.body as {
active_servers: number;
total_servers: number;
version: string;
};
return {
success: true,
nodeId: node.id,
activeServers: body.active_servers,
totalServers: body.total_servers,
version: body.version,
};
});
}
+281
View File
@@ -0,0 +1,281 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { randomBytes } from 'crypto';
import { nodes, allocations, servers, games } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { createAuditLog } from '../../lib/audit.js';
import {
daemonGetNodeStats,
daemonGetNodeStatus,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
import {
NodeParamSchema,
CreateNodeSchema,
UpdateNodeSchema,
CreateAllocationSchema,
} from './schemas.js';
export default async function nodeRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// GET /api/organizations/:orgId/nodes
app.get('/', async (request) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'node.read');
const nodeList = await app.db
.select()
.from(nodes)
.where(eq(nodes.organizationId, orgId))
.orderBy(nodes.createdAt);
const total = nodeList.length;
return {
data: nodeList,
meta: {
total,
page: 1,
perPage: total,
totalPages: total === 0 ? 0 : 1,
},
};
});
// POST /api/organizations/:orgId/nodes
app.post('/', { schema: CreateNodeSchema }, async (request, reply) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'node.manage');
const body = request.body as {
name: string;
fqdn: string;
daemonPort?: number;
grpcPort?: number;
location?: string;
memoryTotal: number;
diskTotal: number;
memoryOveralloc?: number;
diskOveralloc?: number;
};
const daemonToken = randomBytes(32).toString('hex');
const [node] = await app.db
.insert(nodes)
.values({
organizationId: orgId,
...body,
daemonToken,
})
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'node.create',
metadata: { nodeId: node!.id, name: body.name },
});
return reply.code(201).send(node);
});
// GET /api/organizations/:orgId/nodes/:nodeId
app.get('/:nodeId', { schema: NodeParamSchema }, async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.read');
const node = await app.db.query.nodes.findFirst({
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
});
if (!node) throw AppError.notFound('Node not found');
return node;
});
// PATCH /api/organizations/:orgId/nodes/:nodeId
app.patch(
'/:nodeId',
{ schema: { ...NodeParamSchema, ...UpdateNodeSchema } },
async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
const body = request.body as Record<string, unknown>;
const [updated] = await app.db
.update(nodes)
.set({ ...body, updatedAt: new Date() })
.where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)))
.returning();
if (!updated) throw AppError.notFound('Node not found');
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'node.update',
metadata: { nodeId, ...body },
});
return updated;
},
);
// DELETE /api/organizations/:orgId/nodes/:nodeId
app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
const node = await app.db.query.nodes.findFirst({
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
});
if (!node) throw AppError.notFound('Node not found');
await app.db.delete(nodes).where(eq(nodes.id, nodeId));
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'node.delete',
metadata: { nodeId, name: node.name },
});
return reply.code(204).send();
});
// GET /api/organizations/:orgId/nodes/:nodeId/servers
app.get('/:nodeId/servers', { schema: NodeParamSchema }, async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.read');
const serverList = await app.db
.select({
id: servers.id,
name: servers.name,
status: servers.status,
memoryLimit: servers.memoryLimit,
cpuLimit: servers.cpuLimit,
gameName: games.name,
})
.from(servers)
.leftJoin(games, eq(servers.gameId, games.id))
.where(and(eq(servers.nodeId, nodeId), eq(servers.organizationId, orgId)));
return { data: serverList };
});
// GET /api/organizations/:orgId/nodes/:nodeId/stats
// Returns real-time stats from daemon when available, with DB fallback.
app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.read');
const node = await app.db.query.nodes.findFirst({
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
});
if (!node) throw AppError.notFound('Node not found');
const serverList = await app.db
.select({ id: servers.id, status: servers.status })
.from(servers)
.where(eq(servers.nodeId, nodeId));
const totalServers = serverList.length;
let activeServers = serverList.filter((s) => s.status === 'running').length;
let cpuPercent = 0;
let memoryUsed = 0;
let memoryTotal = node.memoryTotal;
let diskUsed = 0;
let diskTotal = node.diskTotal;
let uptime = 0;
const daemonNode: DaemonNodeConnection = {
fqdn: node.fqdn,
grpcPort: node.grpcPort,
daemonToken: node.daemonToken,
};
try {
const [liveStats, liveStatus] = await Promise.all([
daemonGetNodeStats(daemonNode),
daemonGetNodeStatus(daemonNode),
]);
cpuPercent = Number.isFinite(liveStats.cpuPercent)
? Math.max(0, Math.min(100, liveStats.cpuPercent))
: 0;
memoryUsed = Math.max(0, liveStats.memoryUsed);
memoryTotal = liveStats.memoryTotal > 0 ? liveStats.memoryTotal : node.memoryTotal;
diskUsed = Math.max(0, liveStats.diskUsed);
diskTotal = liveStats.diskTotal > 0 ? liveStats.diskTotal : node.diskTotal;
uptime = Math.max(0, liveStatus.uptimeSeconds);
if (Number.isFinite(liveStatus.activeServers)) {
activeServers = Math.max(0, Math.min(totalServers, liveStatus.activeServers));
}
} catch (error) {
request.log.warn(
{ error, nodeId, orgId },
'Failed to fetch live node stats from daemon, returning fallback values',
);
}
return {
cpuPercent,
memoryUsed,
memoryTotal,
diskUsed,
diskTotal,
activeServers,
totalServers,
uptime,
};
});
// === Allocations ===
// GET /api/organizations/:orgId/nodes/:nodeId/allocations
app.get('/:nodeId/allocations', { schema: NodeParamSchema }, async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.read');
const allocs = await app.db
.select()
.from(allocations)
.where(eq(allocations.nodeId, nodeId))
.orderBy(allocations.port);
return { data: allocs };
});
// POST /api/organizations/:orgId/nodes/:nodeId/allocations
app.post(
'/:nodeId/allocations',
{ schema: { ...NodeParamSchema, ...CreateAllocationSchema } },
async (request, reply) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
const { ip, ports } = request.body as { ip: string; ports: number[] };
const values = ports.map((port) => ({
nodeId,
ip,
port,
}));
const created = await app.db
.insert(allocations)
.values(values)
.onConflictDoNothing()
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'allocation.create',
metadata: { nodeId, ip, ports },
});
return reply.code(201).send({ data: created });
},
);
}
+43
View File
@@ -0,0 +1,43 @@
import { Type } from '@sinclair/typebox';
export const NodeParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
nodeId: Type.String({ format: 'uuid' }),
}),
};
export const CreateNodeSchema = {
body: Type.Object({
name: Type.String({ minLength: 1, maxLength: 255 }),
fqdn: Type.String({ minLength: 1, maxLength: 255 }),
daemonPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535, default: 8443 })),
grpcPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535, default: 50051 })),
location: Type.Optional(Type.String({ maxLength: 255 })),
memoryTotal: Type.Number({ minimum: 0 }),
diskTotal: Type.Number({ minimum: 0 }),
memoryOveralloc: Type.Optional(Type.Number({ minimum: 0, default: 0 })),
diskOveralloc: Type.Optional(Type.Number({ minimum: 0, default: 0 })),
}),
};
export const UpdateNodeSchema = {
body: Type.Object({
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
fqdn: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
daemonPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
grpcPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
location: Type.Optional(Type.String({ maxLength: 255 })),
memoryTotal: Type.Optional(Type.Number({ minimum: 0 })),
diskTotal: Type.Optional(Type.Number({ minimum: 0 })),
memoryOveralloc: Type.Optional(Type.Number({ minimum: 0 })),
diskOveralloc: Type.Optional(Type.Number({ minimum: 0 })),
}),
};
export const CreateAllocationSchema = {
body: Type.Object({
ip: Type.String({ minLength: 1, maxLength: 45 }),
ports: Type.Array(Type.Number({ minimum: 1, maximum: 65535 }), { minItems: 1 }),
}),
};
+290
View File
@@ -0,0 +1,290 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, count } from 'drizzle-orm';
import { organizations, organizationMembers, users } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission, getOrgMembership } from '../../lib/permissions.js';
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
import type { PaginationQuery } from '../../lib/pagination.js';
import { createAuditLog } from '../../lib/audit.js';
import {
CreateOrgSchema,
UpdateOrgSchema,
OrgIdParamSchema,
AddMemberSchema,
UpdateMemberSchema,
MemberIdParamSchema,
} from './schemas.js';
export default async function organizationRoutes(app: FastifyInstance) {
// All org routes require authentication
app.addHook('onRequest', app.authenticate);
// GET /api/organizations — list user's organizations
app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery);
const userId = request.user.sub;
if (request.user.isSuperAdmin) {
const [totalResult] = await app.db.select({ count: count() }).from(organizations);
const orgs = await app.db
.select()
.from(organizations)
.limit(limit)
.offset(offset)
.orderBy(organizations.createdAt);
return paginatedResponse(orgs, totalResult!.count, page, perPage);
}
const memberOrgs = await app.db
.select({
id: organizations.id,
name: organizations.name,
slug: organizations.slug,
ownerId: organizations.ownerId,
maxServers: organizations.maxServers,
maxNodes: organizations.maxNodes,
createdAt: organizations.createdAt,
updatedAt: organizations.updatedAt,
role: organizationMembers.role,
})
.from(organizationMembers)
.innerJoin(organizations, eq(organizationMembers.organizationId, organizations.id))
.where(eq(organizationMembers.userId, userId))
.limit(limit)
.offset(offset);
const [totalResult] = await app.db
.select({ count: count() })
.from(organizationMembers)
.where(eq(organizationMembers.userId, userId));
return paginatedResponse(memberOrgs, totalResult!.count, page, perPage);
});
// POST /api/organizations — create organization
app.post('/', { schema: CreateOrgSchema }, async (request, reply) => {
const { name, slug } = request.body as { name: string; slug: string };
const existing = await app.db.query.organizations.findFirst({
where: eq(organizations.slug, slug),
});
if (existing) {
throw AppError.conflict('Organization slug already in use', 'SLUG_TAKEN');
}
const [org] = await app.db
.insert(organizations)
.values({
name,
slug,
ownerId: request.user.sub,
})
.returning();
// Add creator as admin member
await app.db.insert(organizationMembers).values({
organizationId: org!.id,
userId: request.user.sub,
role: 'admin',
});
return reply.code(201).send(org);
});
// GET /api/organizations/:orgId
app.get('/:orgId', { schema: OrgIdParamSchema }, async (request) => {
const { orgId } = request.params as { orgId: string };
await getOrgMembership(request, orgId);
const org = await app.db.query.organizations.findFirst({
where: eq(organizations.id, orgId),
});
if (!org) throw AppError.notFound('Organization not found');
return org;
});
// PATCH /api/organizations/:orgId
app.patch('/:orgId', { schema: { ...OrgIdParamSchema, ...UpdateOrgSchema } }, async (request) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'org.settings');
const body = request.body as { name?: string; maxServers?: number; maxNodes?: number };
const [updated] = await app.db
.update(organizations)
.set({ ...body, updatedAt: new Date() })
.where(eq(organizations.id, orgId))
.returning();
if (!updated) throw AppError.notFound('Organization not found');
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'organization.update',
metadata: body,
});
return updated;
});
// DELETE /api/organizations/:orgId
app.delete('/:orgId', { schema: OrgIdParamSchema }, async (request, reply) => {
const { orgId } = request.params as { orgId: string };
const membership = await getOrgMembership(request, orgId);
// Only owner or super admin can delete
const org = await app.db.query.organizations.findFirst({
where: eq(organizations.id, orgId),
});
if (!org) throw AppError.notFound('Organization not found');
if (membership !== 'super_admin' && org.ownerId !== request.user.sub) {
throw AppError.forbidden('Only the organization owner can delete this organization');
}
await app.db.delete(organizations).where(eq(organizations.id, orgId));
return reply.code(204).send();
});
// === Members ===
// GET /api/organizations/:orgId/members
app.get('/:orgId/members', { schema: OrgIdParamSchema }, async (request) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'org.members');
const members = await app.db
.select({
id: organizationMembers.id,
userId: organizationMembers.userId,
role: organizationMembers.role,
customPermissions: organizationMembers.customPermissions,
joinedAt: organizationMembers.joinedAt,
email: users.email,
username: users.username,
avatarUrl: users.avatarUrl,
})
.from(organizationMembers)
.innerJoin(users, eq(organizationMembers.userId, users.id))
.where(eq(organizationMembers.organizationId, orgId));
return { data: members };
});
// POST /api/organizations/:orgId/members — invite by email
app.post(
'/:orgId/members',
{ schema: { ...OrgIdParamSchema, ...AddMemberSchema } },
async (request, reply) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'org.members');
const { email, role } = request.body as { email: string; role: 'admin' | 'user' };
const user = await app.db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user) throw AppError.notFound('User with this email not found');
const existing = await app.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.organizationId, orgId),
eq(organizationMembers.userId, user.id),
),
});
if (existing) throw AppError.conflict('User is already a member');
const [member] = await app.db
.insert(organizationMembers)
.values({
organizationId: orgId,
userId: user.id,
role,
})
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'member.add',
metadata: { userId: user.id, email, role },
});
return reply.code(201).send(member);
},
);
// PATCH /api/organizations/:orgId/members/:memberId
app.patch(
'/:orgId/members/:memberId',
{ schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } },
async (request) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members');
const body = request.body as {
role?: 'admin' | 'user';
customPermissions?: Record<string, boolean>;
};
const [updated] = await app.db
.update(organizationMembers)
.set(body)
.where(
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
)
.returning();
if (!updated) throw AppError.notFound('Member not found');
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'member.update',
metadata: { memberId, ...body },
});
return updated;
},
);
// DELETE /api/organizations/:orgId/members/:memberId
app.delete(
'/:orgId/members/:memberId',
{ schema: MemberIdParamSchema },
async (request, reply) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members');
const member = await app.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.id, memberId),
eq(organizationMembers.organizationId, orgId),
),
});
if (!member) throw AppError.notFound('Member not found');
// Cannot remove org owner
const org = await app.db.query.organizations.findFirst({
where: eq(organizations.id, orgId),
});
if (org && member.userId === org.ownerId) {
throw AppError.badRequest('Cannot remove the organization owner');
}
await app.db
.delete(organizationMembers)
.where(
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
);
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'member.remove',
metadata: { memberId, userId: member.userId },
});
return reply.code(204).send();
},
);
}
@@ -0,0 +1,43 @@
import { Type } from '@sinclair/typebox';
export const CreateOrgSchema = {
body: Type.Object({
name: Type.String({ minLength: 2, maxLength: 255 }),
slug: Type.String({ minLength: 2, maxLength: 255, pattern: '^[a-z0-9-]+$' }),
}),
};
export const UpdateOrgSchema = {
body: Type.Object({
name: Type.Optional(Type.String({ minLength: 2, maxLength: 255 })),
maxServers: Type.Optional(Type.Number({ minimum: 0 })),
maxNodes: Type.Optional(Type.Number({ minimum: 0 })),
}),
};
export const OrgIdParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
}),
};
export const AddMemberSchema = {
body: Type.Object({
email: Type.String({ format: 'email' }),
role: Type.Union([Type.Literal('admin'), Type.Literal('user')]),
}),
};
export const UpdateMemberSchema = {
body: Type.Object({
role: Type.Optional(Type.Union([Type.Literal('admin'), Type.Literal('user')])),
customPermissions: Type.Optional(Type.Record(Type.String(), Type.Boolean())),
}),
};
export const MemberIdParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
memberId: Type.String({ format: 'uuid' }),
}),
};
+248
View File
@@ -0,0 +1,248 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { Type } from '@sinclair/typebox';
import { servers, backups, nodes } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { createAuditLog } from '../../lib/audit.js';
import {
daemonCreateBackup,
daemonDeleteBackup,
daemonRestoreBackup,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
const ParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
const BackupParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
backupId: Type.String({ format: 'uuid' }),
}),
};
const CreateBackupBody = Type.Object({
name: Type.String({ minLength: 1, maxLength: 255 }),
isLocked: Type.Optional(Type.Boolean({ default: false })),
});
export default async function backupRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// GET /backups — list all backups for a server
app.get('/', { schema: ParamSchema }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'backup.read');
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
const backupList = await app.db.query.backups.findMany({
where: eq(backups.serverId, serverId),
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
return { backups: backupList };
});
// POST /backups — create a backup
app.post('/', { schema: { ...ParamSchema, body: CreateBackupBody } }, async (request, reply) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'backup.create');
const body = request.body as { name: string; isLocked?: boolean };
const serverContext = await getServerBackupContext(app, orgId, serverId);
// Create backup record (pending — daemon will update when complete)
const [backup] = await app.db
.insert(backups)
.values({
serverId,
name: body.name,
isLocked: body.isLocked ?? false,
})
.returning();
if (!backup) {
throw new AppError(500, 'Failed to create backup record', 'BACKUP_CREATE_FAILED');
}
let completedBackup = backup;
try {
const daemonResult = await daemonCreateBackup(
serverContext.node,
serverContext.serverUuid,
backup.id,
);
if (!daemonResult.success) {
throw new Error('Daemon returned unsuccessful backup response');
}
const [updated] = await app.db
.update(backups)
.set({
sizeBytes: daemonResult.sizeBytes,
checksum: daemonResult.checksum || null,
completedAt: new Date(),
})
.where(eq(backups.id, backup.id))
.returning();
completedBackup = updated ?? completedBackup;
} catch (error) {
request.log.error(
{ error, serverId, backupId: backup.id },
'Failed to create backup on daemon',
);
await app.db.delete(backups).where(eq(backups.id, backup.id));
throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED');
}
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'backup.create',
metadata: { name: body.name },
});
return reply.code(201).send(completedBackup);
});
// POST /backups/:backupId/restore — restore a backup
app.post('/:backupId/restore', { schema: BackupParamSchema }, async (request) => {
const { orgId, serverId, backupId } = request.params as {
orgId: string;
serverId: string;
backupId: string;
};
await requirePermission(request, orgId, 'backup.restore');
const serverContext = await getServerBackupContext(app, orgId, serverId);
const backup = await app.db.query.backups.findFirst({
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
});
if (!backup) throw AppError.notFound('Backup not found');
if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed');
try {
await daemonRestoreBackup(
serverContext.node,
serverContext.serverUuid,
backup.id,
backup.cdnPath,
);
} catch (error) {
request.log.error({ error, serverId, backupId }, 'Failed to restore backup on daemon');
throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED');
}
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'backup.restore',
metadata: { backupName: backup.name, backupId },
});
return { success: true, message: 'Restore initiated' };
});
// PATCH /backups/:backupId/lock — toggle backup lock
app.patch('/:backupId/lock', { schema: BackupParamSchema }, async (request) => {
const { orgId, serverId, backupId } = request.params as {
orgId: string;
serverId: string;
backupId: string;
};
await requirePermission(request, orgId, 'backup.manage');
const backup = await app.db.query.backups.findFirst({
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
});
if (!backup) throw AppError.notFound('Backup not found');
const [updated] = await app.db
.update(backups)
.set({ isLocked: !backup.isLocked })
.where(eq(backups.id, backupId))
.returning();
return updated;
});
// DELETE /backups/:backupId — delete a backup
app.delete('/:backupId', { schema: BackupParamSchema }, async (request, reply) => {
const { orgId, serverId, backupId } = request.params as {
orgId: string;
serverId: string;
backupId: string;
};
await requirePermission(request, orgId, 'backup.delete');
const backup = await app.db.query.backups.findFirst({
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
});
if (!backup) throw AppError.notFound('Backup not found');
if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup');
const serverContext = await getServerBackupContext(app, orgId, serverId);
try {
await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id);
} catch (error) {
request.log.error({ error, serverId, backupId }, 'Failed to delete backup on daemon');
throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED');
}
await app.db.delete(backups).where(eq(backups.id, backupId));
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'backup.delete',
metadata: { name: backup.name },
});
return reply.code(204).send();
});
}
async function getServerBackupContext(
app: FastifyInstance,
orgId: string,
serverId: string,
): Promise<{ serverUuid: string; node: DaemonNodeConnection }> {
const [server] = await app.db
.select({
serverUuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
throw AppError.notFound('Server not found');
}
return {
serverUuid: server.serverUuid,
node: {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
};
}
+240
View File
@@ -0,0 +1,240 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { Type } from '@sinclair/typebox';
import { servers, games, nodes } from '@source/database';
import type { GameConfigFile, ConfigParser } from '@source/shared';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
import {
managedConfigFileFor,
readManagedConfig,
writeManagedConfig,
} from '../../lib/managed-config.js';
const ParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
const ConfigFileParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
configIndex: Type.Number({ minimum: 0 }),
}),
};
export default async function configRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// GET /config — list available config files for this server's game
app.get('/', { schema: ParamSchema }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'config.read');
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
const game = await app.db.query.games.findFirst({
where: eq(games.id, server.gameId),
});
if (!game) throw AppError.notFound('Game not found');
const configFiles = (game.configFiles as GameConfigFile[]) || [];
return {
configs: configFiles.map((cf, index) => ({
index,
path: cf.path,
parser: cf.parser,
editableKeys: cf.editableKeys ?? null,
})),
};
});
// GET /config/:configIndex — read & parse a specific config file
app.get('/:configIndex', { schema: ConfigFileParamSchema }, async (request) => {
const { orgId, serverId, configIndex } = request.params as {
orgId: string;
serverId: string;
configIndex: number;
};
await requirePermission(request, orgId, 'config.read');
const { game, server, node, configFile } = await getServerConfig(
app,
orgId,
serverId,
configIndex,
);
let raw = '';
try {
const managedFile = managedConfigFileFor(game.slug, configFile.path);
if (managedFile) {
raw = await readManagedConfig(node, server.uuid, managedFile);
} else {
const file = await daemonReadFile(node, server.uuid, configFile.path);
raw = file.data.toString('utf8');
}
} catch (error) {
if (!isMissingConfigFileError(error)) {
app.log.error(
{ error, serverId, path: configFile.path },
'Failed to read config file from daemon',
);
throw new AppError(
502,
'Failed to read config file from daemon',
'DAEMON_CONFIG_READ_FAILED',
);
}
}
const entries = raw ? parseConfig(raw, configFile.parser as ConfigParser) : [];
return {
path: configFile.path,
parser: configFile.parser,
editableKeys: configFile.editableKeys ?? null,
entries,
raw,
};
});
// PUT /config/:configIndex — update a config file
app.put(
'/:configIndex',
{
schema: {
...ConfigFileParamSchema,
body: Type.Object({
entries: Type.Array(
Type.Object({
key: Type.String(),
value: Type.String(),
}),
),
}),
},
},
async (request) => {
const { orgId, serverId, configIndex } = request.params as {
orgId: string;
serverId: string;
configIndex: number;
};
const { entries } = request.body as { entries: { key: string; value: string }[] };
await requirePermission(request, orgId, 'config.write');
const { game, server, node, configFile } = await getServerConfig(
app,
orgId,
serverId,
configIndex,
);
const managedFile = managedConfigFileFor(game.slug, configFile.path);
let originalContent: string | undefined;
let originalEntries: { key: string; value: string }[] = [];
try {
if (managedFile) {
originalContent = await readManagedConfig(node, server.uuid, managedFile);
} else {
const current = await daemonReadFile(node, server.uuid, configFile.path);
originalContent = current.data.toString('utf8');
}
originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser);
} catch (error) {
if (!isMissingConfigFileError(error)) {
app.log.error(
{ error, serverId, path: configFile.path },
'Failed to read existing config before write',
);
throw new AppError(
502,
'Failed to read existing config file',
'DAEMON_CONFIG_READ_FAILED',
);
}
}
// If editableKeys is set, allow:
// 1) explicitly editable keys
// 2) keys that already exist in the current file
if (configFile.editableKeys && configFile.editableKeys.length > 0) {
const allowedKeys = new Set(configFile.editableKeys);
const existingKeys = new Set(originalEntries.map((entry) => entry.key));
const invalidKeys = entries.filter(
(entry) => !allowedKeys.has(entry.key) && !existingKeys.has(entry.key),
);
if (invalidKeys.length > 0) {
throw AppError.badRequest(
`Keys not allowed: ${invalidKeys.map((k) => k.key).join(', ')}`,
);
}
}
const content = serializeConfig(entries, configFile.parser as ConfigParser, originalContent);
if (managedFile) {
await writeManagedConfig(node, server.uuid, managedFile, content);
} else {
await daemonWriteFile(node, server.uuid, configFile.path, content);
}
return { success: true, path: configFile.path, content };
},
);
}
async function getServerConfig(
app: FastifyInstance,
orgId: string,
serverId: string,
configIndex: number,
) {
const [server] = await app.db
.select({
id: servers.id,
uuid: servers.uuid,
gameId: servers.gameId,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) throw AppError.notFound('Server not found');
const game = await app.db.query.games.findFirst({
where: eq(games.id, server.gameId as string),
});
if (!game) throw AppError.notFound('Game not found');
const configFiles = (game.configFiles as GameConfigFile[]) || [];
const configFile = configFiles[configIndex];
if (!configFile) throw AppError.notFound('Config file not found');
const node: DaemonNodeConnection = {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
};
return { game, server, node, configFile };
}
function isMissingConfigFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
+351
View File
@@ -0,0 +1,351 @@
import type { FastifyInstance } from 'fastify';
import { Type } from '@sinclair/typebox';
import { and, eq } from 'drizzle-orm';
import { nodes, serverDatabases, servers } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { createAuditLog } from '../../lib/audit.js';
import {
daemonCreateDatabase,
daemonDeleteDatabase,
daemonUpdateDatabasePassword,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
const ServerDatabaseParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
databaseId: Type.String({ format: 'uuid' }),
}),
};
const ServerScopeSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
const CreateServerDatabaseSchema = {
body: Type.Object({
name: Type.String({ minLength: 1, maxLength: 255 }),
password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })),
}),
};
const UpdateServerDatabaseSchema = {
body: Type.Object({
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })),
}),
};
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string) {
const [server] = await app.db
.select({
id: servers.id,
name: servers.name,
uuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
throw AppError.notFound('Server not found');
}
return server;
}
function buildNodeConnection(server: {
nodeDaemonToken: string;
nodeFqdn: string;
nodeGrpcPort: number;
}): DaemonNodeConnection {
return {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
};
}
function daemonErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return fallback;
}
export default async function databaseRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.get('/', { schema: ServerScopeSchema }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.read');
await getServerContext(app, orgId, serverId);
const databases = await app.db
.select()
.from(serverDatabases)
.where(eq(serverDatabases.serverId, serverId))
.orderBy(serverDatabases.createdAt);
return { data: databases };
});
app.post(
'/',
{ schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } },
async (request, reply) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.update');
const body = request.body as { name: string; password?: string };
const name = body.name.trim();
if (!name) {
throw AppError.badRequest('Database name is required');
}
const server = await getServerContext(app, orgId, serverId);
let managedDatabase;
try {
managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), {
name,
password: body.password,
serverUuid: server.uuid,
});
} catch (error) {
request.log.error(
{ error, orgId, serverId, serverUuid: server.uuid },
'Failed to provision node-local MySQL database',
);
throw new AppError(
502,
daemonErrorMessage(error, 'Failed to provision node-local MySQL database'),
'MANAGED_MYSQL_CREATE_FAILED',
);
}
try {
const [created] = await app.db
.insert(serverDatabases)
.values({
serverId,
name,
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
password: managedDatabase.password,
host: managedDatabase.host,
port: managedDatabase.port,
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
})
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'server.database.create',
metadata: {
name: created!.name,
databaseName: created!.databaseName,
username: created!.username,
},
});
return reply.code(201).send(created);
} catch (error) {
try {
await daemonDeleteDatabase(buildNodeConnection(server), {
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
});
} catch (cleanupError) {
request.log.error(
{ cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName },
'Failed to roll back node-local MySQL database after panel insert failure',
);
}
request.log.error(
{ error, orgId, serverId, databaseName: managedDatabase.databaseName },
'Failed to persist managed MySQL database metadata',
);
throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED');
}
},
);
app.patch(
'/:databaseId',
{ schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } },
async (request) => {
const { orgId, serverId, databaseId } = request.params as {
databaseId: string;
orgId: string;
serverId: string;
};
await requirePermission(request, orgId, 'server.update');
const body = request.body as { name?: string; password?: string };
const [current] = await app.db
.select({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
createdAt: serverDatabases.createdAt,
updatedAt: serverDatabases.updatedAt,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(serverDatabases)
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(
and(
eq(serverDatabases.id, databaseId),
eq(serverDatabases.serverId, serverId),
eq(servers.organizationId, orgId),
),
);
if (!current) {
throw AppError.notFound('Database not found');
}
const nextName = body.name === undefined ? undefined : body.name.trim();
if (body.name !== undefined && !nextName) {
throw AppError.badRequest('Database name is required');
}
const nextPassword = body.password?.trim();
if (!nextName && !nextPassword) {
return current;
}
if (nextPassword) {
try {
await daemonUpdateDatabasePassword(buildNodeConnection(current), {
password: nextPassword,
username: current.username,
});
} catch (error) {
request.log.error(
{ error, orgId, serverId, databaseId, username: current.username },
'Failed to rotate node-local MySQL password',
);
throw new AppError(
502,
daemonErrorMessage(error, 'Failed to rotate database password'),
'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED',
);
}
}
const patch: Record<string, unknown> = {
updatedAt: new Date(),
};
if (nextName) patch.name = nextName;
if (nextPassword) patch.password = nextPassword;
const [updated] = await app.db
.update(serverDatabases)
.set(patch)
.where(eq(serverDatabases.id, databaseId))
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'server.database.update',
metadata: {
databaseId,
updatedName: nextName ?? undefined,
passwordRotated: Boolean(nextPassword),
},
});
return updated;
},
);
app.delete('/:databaseId', { schema: ServerDatabaseParamSchema }, async (request, reply) => {
const { orgId, serverId, databaseId } = request.params as {
databaseId: string;
orgId: string;
serverId: string;
};
await requirePermission(request, orgId, 'server.update');
const [current] = await app.db
.select({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(serverDatabases)
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(
and(
eq(serverDatabases.id, databaseId),
eq(serverDatabases.serverId, serverId),
eq(servers.organizationId, orgId),
),
);
if (!current) {
throw AppError.notFound('Database not found');
}
try {
await daemonDeleteDatabase(buildNodeConnection(current), {
databaseName: current.databaseName,
username: current.username,
});
} catch (error) {
request.log.error(
{ error, orgId, serverId, databaseId, databaseName: current.databaseName },
'Failed to delete node-local MySQL database',
);
throw new AppError(
502,
daemonErrorMessage(error, 'Failed to delete node-local MySQL database'),
'MANAGED_MYSQL_DELETE_FAILED',
);
}
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, databaseId));
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'server.database.delete',
metadata: {
databaseId,
name: current.name,
databaseName: current.databaseName,
username: current.username,
},
});
return reply.code(204).send();
});
}
+242
View File
@@ -0,0 +1,242 @@
import type { FastifyInstance } from 'fastify';
import { Type } from '@sinclair/typebox';
import { and, eq } from 'drizzle-orm';
import { games, nodes, servers } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import {
daemonDeleteFiles,
daemonListFiles,
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
import {
isManagedConfigShadowFile,
managedConfigFileFor,
readManagedConfig,
writeManagedConfig,
} from '../../lib/managed-config.js';
const FileParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean {
if (isManagedConfigShadowFile(gameSlug, fileName)) return true;
if (gameSlug !== 'cs2') return false;
if (isDirectory) return false;
const normalizedName = fileName.trim().toLowerCase();
if (normalizedName.endsWith('.vpk')) return true;
if (/^backup_round.*\.txt$/.test(normalizedName)) return true;
return false;
}
function decodeBase64Payload(data: string): Buffer {
const normalized = data.trim();
if (!normalized) return Buffer.alloc(0);
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) {
throw AppError.badRequest('Invalid base64 payload');
}
return Buffer.from(normalized, 'base64');
}
export default async function fileRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.get(
'/',
{
schema: {
...FileParamSchema,
querystring: Type.Object({
path: Type.Optional(Type.String()),
}),
},
},
async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { path } = request.query as { path?: string };
await requirePermission(request, orgId, 'files.read');
const serverContext = await getServerContext(app, orgId, serverId);
const files = await daemonListFiles(
serverContext.node,
serverContext.serverUuid,
path?.trim() || '/',
);
const filteredFiles = files.filter(
(file) => !shouldHideFileForGame(serverContext.gameSlug, file.name, file.isDirectory),
);
return { files: filteredFiles };
},
);
app.get(
'/read',
{
schema: {
...FileParamSchema,
querystring: Type.Object({
path: Type.String({ minLength: 1 }),
encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])),
}),
},
},
async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { path, encoding } = request.query as {
path: string;
encoding?: 'utf8' | 'base64';
};
await requirePermission(request, orgId, 'files.read');
const serverContext = await getServerContext(app, orgId, serverId);
const requestedEncoding = encoding === 'base64' ? 'base64' : 'utf8';
let payload: Buffer;
let mimeType = 'text/plain';
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
if (managedFile) {
payload = Buffer.from(
await readManagedConfig(serverContext.node, serverContext.serverUuid, managedFile),
'utf8',
);
} else {
const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path);
payload = content.data;
mimeType = content.mimeType;
}
return {
data:
requestedEncoding === 'base64' ? payload.toString('base64') : payload.toString('utf8'),
encoding: requestedEncoding,
mimeType,
};
},
);
app.post(
'/write',
{
bodyLimit: 128 * 1024 * 1024,
schema: {
...FileParamSchema,
body: Type.Object({
path: Type.String({ minLength: 1 }),
data: Type.String(),
encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])),
}),
},
},
async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { path, data, encoding } = request.body as {
path: string;
data: string;
encoding?: 'utf8' | 'base64';
};
await requirePermission(request, orgId, 'files.write');
const serverContext = await getServerContext(app, orgId, serverId);
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
if (managedFile) {
await writeManagedConfig(
serverContext.node,
serverContext.serverUuid,
managedFile,
payload,
);
} else {
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
}
return { success: true, path };
},
);
app.post(
'/delete',
{
schema: {
...FileParamSchema,
body: Type.Object({
paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
}),
},
},
async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { paths } = request.body as { paths: string[] };
await requirePermission(request, orgId, 'files.delete');
const serverContext = await getServerContext(app, orgId, serverId);
// Deleting a managed config also drops the panel's sidecar copy,
// otherwise the next start would resurrect the file.
const resolvedPaths = paths.flatMap((path) => {
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
if (!managedFile) return [path];
return [
path,
path.trim().startsWith('/') ? `/${managedFile.shadowPath}` : managedFile.shadowPath,
];
});
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths);
return { success: true, paths };
},
);
}
async function getServerContext(
app: FastifyInstance,
orgId: string,
serverId: string,
): Promise<{
serverUuid: string;
gameSlug: string;
node: DaemonNodeConnection;
}> {
const [server] = await app.db
.select({
uuid: servers.uuid,
gameSlug: games.slug,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.innerJoin(games, eq(servers.gameId, games.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
throw AppError.notFound('Server not found');
}
return {
serverUuid: server.uuid,
gameSlug: server.gameSlug,
node: {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
};
}
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from 'fastify';
import { Type } from '@sinclair/typebox';
import { and, eq } from 'drizzle-orm';
import { nodes, servers } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { daemonGetActivePlayers, type DaemonNodeConnection } from '../../lib/daemon.js';
const ParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
export default async function playerRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.get('/', { schema: ParamSchema }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.read');
const serverContext = await getServerContext(app, orgId, serverId);
const players = await daemonGetActivePlayers(serverContext.node, serverContext.serverUuid);
return {
players: players.players.map((player) => ({
name: player.name,
steamid: player.id || undefined,
})),
maxPlayers: players.maxPlayers,
};
});
}
async function getServerContext(
app: FastifyInstance,
orgId: string,
serverId: string,
): Promise<{
serverUuid: string;
node: DaemonNodeConnection;
}> {
const [server] = await app.db
.select({
uuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
throw AppError.notFound('Server not found');
}
return {
serverUuid: server.uuid,
node: {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
};
}
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { Type } from '@sinclair/typebox';
import { nodes, servers, scheduledTasks } from '@source/database';
import type { PowerAction } from '@source/shared';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { createAuditLog } from '../../lib/audit.js';
import { computeNextRun } from '../../lib/schedule-utils.js';
import {
daemonSendCommand,
daemonSetPowerState,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
const ParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
const TaskParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
taskId: Type.String({ format: 'uuid' }),
}),
};
const CreateScheduleBody = Type.Object({
name: Type.String({ minLength: 1, maxLength: 255 }),
action: Type.Union([Type.Literal('command'), Type.Literal('power'), Type.Literal('backup')]),
payload: Type.String({ minLength: 1 }),
scheduleType: Type.Union([
Type.Literal('interval'),
Type.Literal('daily'),
Type.Literal('weekly'),
Type.Literal('cron'),
]),
scheduleData: Type.Object({}, { additionalProperties: true }),
isActive: Type.Optional(Type.Boolean({ default: true })),
});
const UpdateScheduleBody = Type.Object({
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
action: Type.Optional(
Type.Union([Type.Literal('command'), Type.Literal('power'), Type.Literal('backup')]),
),
payload: Type.Optional(Type.String({ minLength: 1 })),
scheduleType: Type.Optional(
Type.Union([
Type.Literal('interval'),
Type.Literal('daily'),
Type.Literal('weekly'),
Type.Literal('cron'),
]),
),
scheduleData: Type.Optional(Type.Object({}, { additionalProperties: true })),
isActive: Type.Optional(Type.Boolean()),
});
export default async function scheduleRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// GET /schedules — list all scheduled tasks for a server
app.get('/', { schema: ParamSchema }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'schedule.read');
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
const tasks = await app.db.query.scheduledTasks.findMany({
where: eq(scheduledTasks.serverId, serverId),
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
return { tasks };
});
// POST /schedules — create a scheduled task
app.post('/', { schema: { ...ParamSchema, body: CreateScheduleBody } }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'schedule.manage');
const body = request.body as {
name: string;
action: 'command' | 'power' | 'backup';
payload: string;
scheduleType: 'interval' | 'daily' | 'weekly' | 'cron';
scheduleData: Record<string, unknown>;
isActive?: boolean;
};
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
const nextRun = computeNextRun(body.scheduleType, body.scheduleData);
const [task] = await app.db
.insert(scheduledTasks)
.values({
serverId,
name: body.name,
action: body.action,
payload: body.payload,
scheduleType: body.scheduleType,
scheduleData: body.scheduleData,
isActive: body.isActive ?? true,
nextRunAt: nextRun,
})
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'schedule.create',
metadata: { name: body.name, action: body.action },
});
return task;
});
// PATCH /schedules/:taskId — update a scheduled task
app.patch(
'/:taskId',
{ schema: { ...TaskParamSchema, body: UpdateScheduleBody } },
async (request) => {
const { orgId, serverId, taskId } = request.params as {
orgId: string;
serverId: string;
taskId: string;
};
await requirePermission(request, orgId, 'schedule.manage');
const body = request.body as Record<string, unknown>;
const existing = await app.db.query.scheduledTasks.findFirst({
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
});
if (!existing) throw AppError.notFound('Scheduled task not found');
// Recompute next run if schedule changed
const scheduleType = (body.scheduleType as string) || existing.scheduleType;
const scheduleData =
(body.scheduleData as Record<string, unknown>) ||
(existing.scheduleData as Record<string, unknown>);
const nextRun = computeNextRun(scheduleType, scheduleData);
const [updated] = await app.db
.update(scheduledTasks)
.set({ ...body, nextRunAt: nextRun, updatedAt: new Date() })
.where(eq(scheduledTasks.id, taskId))
.returning();
return updated;
},
);
// DELETE /schedules/:taskId — delete a scheduled task
app.delete('/:taskId', { schema: TaskParamSchema }, async (request, reply) => {
const { orgId, serverId, taskId } = request.params as {
orgId: string;
serverId: string;
taskId: string;
};
await requirePermission(request, orgId, 'schedule.manage');
const existing = await app.db.query.scheduledTasks.findFirst({
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
});
if (!existing) throw AppError.notFound('Scheduled task not found');
await app.db.delete(scheduledTasks).where(eq(scheduledTasks.id, taskId));
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'schedule.delete',
metadata: { name: existing.name },
});
return reply.code(204).send();
});
// POST /schedules/:taskId/trigger — manually trigger a task
app.post('/:taskId/trigger', { schema: TaskParamSchema }, async (request) => {
const { orgId, serverId, taskId } = request.params as {
orgId: string;
serverId: string;
taskId: string;
};
await requirePermission(request, orgId, 'schedule.manage');
const task = await app.db.query.scheduledTasks.findFirst({
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
});
if (!task) throw AppError.notFound('Scheduled task not found');
if (task.action === 'command') {
const serverContext = await getServerContext(app, orgId, serverId);
await daemonSendCommand(serverContext.node, serverContext.serverUuid, task.payload);
} else if (task.action === 'power') {
const action = task.payload as PowerAction;
if (!['start', 'stop', 'restart', 'kill'].includes(action)) {
throw AppError.badRequest('Invalid power action in schedule payload');
}
const serverContext = await getServerContext(app, orgId, serverId);
await daemonSetPowerState(serverContext.node, serverContext.serverUuid, action);
}
const nextRun = computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>);
await app.db
.update(scheduledTasks)
.set({ lastRunAt: new Date(), nextRunAt: nextRun })
.where(eq(scheduledTasks.id, taskId));
return { success: true, triggered: task.name };
});
}
async function getServerContext(
app: FastifyInstance,
orgId: string,
serverId: string,
): Promise<{
serverUuid: string;
node: DaemonNodeConnection;
}> {
const [server] = await app.db
.select({
uuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
throw AppError.notFound('Server not found');
}
return {
serverUuid: server.uuid,
node: {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
};
}
+47
View File
@@ -0,0 +1,47 @@
import { Type } from '@sinclair/typebox';
export const ServerParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
export const CreateServerSchema = {
body: Type.Object({
name: Type.String({ minLength: 1, maxLength: 255 }),
description: Type.Optional(Type.String()),
nodeId: Type.String({ format: 'uuid' }),
gameId: Type.String({ format: 'uuid' }),
memoryLimit: Type.Number({ minimum: 128 * 1024 * 1024 }), // min 128MB in bytes
diskLimit: Type.Number({ minimum: 256 * 1024 * 1024 }), // min 256MB
cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000, default: 100 })),
allocationId: Type.String({ format: 'uuid' }),
additionalAllocationIds: Type.Optional(Type.Array(Type.String({ format: 'uuid' }))),
environment: Type.Optional(Type.Record(Type.String(), Type.String())),
startupOverride: Type.Optional(Type.String()),
}),
};
export const UpdateServerSchema = {
body: Type.Object({
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
description: Type.Optional(Type.String()),
memoryLimit: Type.Optional(Type.Number({ minimum: 128 * 1024 * 1024 })),
diskLimit: Type.Optional(Type.Number({ minimum: 256 * 1024 * 1024 })),
cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000 })),
environment: Type.Optional(Type.Record(Type.String(), Type.String())),
startupOverride: Type.Optional(Type.String()),
}),
};
export const PowerActionSchema = {
body: Type.Object({
action: Type.Union([
Type.Literal('start'),
Type.Literal('stop'),
Type.Literal('restart'),
Type.Literal('kill'),
]),
}),
};
+2868
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -12,6 +12,7 @@ prost-types = "0.13"
# Async runtime
tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["sync"] }
# Docker
bollard = "0.18"
@@ -22,7 +23,7 @@ serde_json = "1"
serde_yaml = "0.9"
# HTTP client (for CDN uploads, API callbacks)
reqwest = { version = "0.12", features = ["json"] }
reqwest = { version = "0.12", features = ["json", "multipart"] }
# Logging
tracing = "0.1"
@@ -31,9 +32,17 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Error handling
anyhow = "1"
thiserror = "2"
libc = "0.2"
# UUID
uuid = { version = "1", features = ["v4"] }
# Async utils
futures = "0.3"
# Filesystem
tar = "0.4"
flate2 = "1"
[build-dependencies]
tonic-build = "0.12"
+33
View File
@@ -0,0 +1,33 @@
FROM rust:1.97-bookworm AS build
# Install protoc
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
# build.rs compiles ../../packages/proto/daemon.proto, so the workspace layout
# has to be preserved inside the build context.
WORKDIR /build
COPY packages/proto ./packages/proto
COPY apps/daemon ./apps/daemon
WORKDIR /build/apps/daemon
RUN cargo build --release
# --- Production ---
FROM debian:bookworm-slim AS production
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
mariadb-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /build/apps/daemon/target/release/gamepanel-daemon /app/gamepanel-daemon
# Data directories
RUN mkdir -p /var/lib/gamepanel/servers /var/lib/gamepanel/backups /etc/gamepanel
EXPOSE 50051
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s CMD /app/gamepanel-daemon --health-check || exit 1
CMD ["/app/gamepanel-daemon"]
+15
View File
@@ -0,0 +1,15 @@
use tonic::{Request, Status};
/// Validate the daemon token from the gRPC request metadata.
pub fn check_auth(req: &Request<()>, expected_token: &str) -> Result<(), Status> {
let token = req
.metadata()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match token {
Some(t) if t == expected_token => Ok(()),
_ => Err(Status::unauthenticated("Invalid or missing daemon token")),
}
}
+332
View File
@@ -0,0 +1,332 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use tracing::{info, error};
use tokio::fs;
use crate::server::ServerManager;
/// Manages backup creation, restoration, and deletion.
pub struct BackupManager {
server_manager: Arc<ServerManager>,
backup_root: PathBuf,
api_url: String,
node_token: String,
}
impl BackupManager {
pub fn new(
server_manager: Arc<ServerManager>,
backup_root: PathBuf,
api_url: String,
node_token: String,
) -> Self {
Self {
server_manager,
backup_root,
api_url,
node_token,
}
}
/// Create a backup for a server.
/// Returns the local file path and size in bytes.
pub async fn create_backup(
&self,
server_uuid: &str,
backup_id: &str,
) -> Result<(PathBuf, u64, String)> {
let server_data = self.server_manager.data_root().join(server_uuid);
if !server_data.exists() {
anyhow::bail!("Server data directory not found: {}", server_data.display());
}
// Ensure backup directory exists
let backup_dir = self.backup_root.join(server_uuid);
fs::create_dir_all(&backup_dir).await?;
let backup_file = backup_dir.join(format!("{}.tar.gz", backup_id));
info!(
server = %server_uuid,
backup_id = %backup_id,
path = %backup_file.display(),
"Creating backup archive"
);
// Create tar.gz in a blocking task
let source = server_data.clone();
let dest = backup_file.clone();
tokio::task::spawn_blocking(move || {
create_tar_gz(&source, &dest)
})
.await??;
// Get file info
let metadata = fs::metadata(&backup_file).await?;
let size = metadata.len();
// Calculate checksum
let checksum = {
let path = backup_file.clone();
tokio::task::spawn_blocking(move || calculate_sha256(&path))
.await?
.context("Failed to calculate checksum")?
};
info!(
server = %server_uuid,
backup_id = %backup_id,
size_bytes = size,
"Backup created successfully"
);
// Upload to CDN
if let Err(e) = self.upload_to_cdn(server_uuid, backup_id, &backup_file, size).await {
error!(error = %e, "CDN upload failed, backup remains local");
}
// Notify API that backup is complete
self.notify_backup_complete(backup_id, size, &checksum).await;
Ok((backup_file, size, checksum))
}
/// Restore a backup for a server.
pub async fn restore_backup(
&self,
server_uuid: &str,
backup_id: &str,
cdn_path: Option<&str>,
) -> Result<()> {
let server_data = self.server_manager.data_root().join(server_uuid);
// Try local backup first
let backup_file = self.backup_root.join(server_uuid).join(format!("{}.tar.gz", backup_id));
let archive_path = if backup_file.exists() {
backup_file
} else if let Some(cdn) = cdn_path {
// Download from CDN
info!(cdn_path = %cdn, "Downloading backup from CDN");
let tmp = self.backup_root.join(format!("{}-restore.tar.gz", backup_id));
self.download_from_cdn(cdn, &tmp).await?;
tmp
} else {
anyhow::bail!("Backup file not found locally and no CDN path provided");
};
info!(
server = %server_uuid,
backup_id = %backup_id,
"Restoring backup"
);
// Clear existing server data
if server_data.exists() {
fs::remove_dir_all(&server_data).await?;
}
fs::create_dir_all(&server_data).await?;
// Extract archive
let dest = server_data.clone();
let src = archive_path.clone();
tokio::task::spawn_blocking(move || {
extract_tar_gz(&src, &dest)
})
.await??;
info!(
server = %server_uuid,
backup_id = %backup_id,
"Backup restored successfully"
);
Ok(())
}
/// Delete a backup from local storage and CDN.
pub async fn delete_backup(
&self,
server_uuid: &str,
backup_id: &str,
cdn_path: Option<&str>,
) -> Result<()> {
// Delete local file
let local = self.backup_root.join(server_uuid).join(format!("{}.tar.gz", backup_id));
if local.exists() {
fs::remove_file(&local).await?;
info!(path = %local.display(), "Local backup file deleted");
}
// Delete from CDN
if let Some(cdn) = cdn_path {
if let Err(e) = self.delete_from_cdn(cdn).await {
error!(error = %e, "Failed to delete backup from CDN");
}
}
Ok(())
}
/// Upload backup to @source/cdn.
async fn upload_to_cdn(
&self,
server_uuid: &str,
backup_id: &str,
file_path: &Path,
_size: u64,
) -> Result<String> {
let client = reqwest::Client::new();
// Read file
let data = fs::read(file_path).await?;
let cdn_path = format!("backups/{}/{}.tar.gz", server_uuid, backup_id);
let upload_url = format!("{}/api/internal/cdn/upload", self.api_url);
let form = reqwest::multipart::Form::new()
.text("path", cdn_path.clone())
.part("file", reqwest::multipart::Part::bytes(data).file_name("backup.tar.gz"));
client
.post(&upload_url)
.bearer_auth(&self.node_token)
.multipart(form)
.send()
.await?
.error_for_status()?;
info!(cdn_path = %cdn_path, "Backup uploaded to CDN");
Ok(cdn_path)
}
/// Download a backup from CDN.
async fn download_from_cdn(&self, cdn_path: &str, dest: &Path) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/api/internal/cdn/download?path={}", self.api_url, cdn_path);
let resp = client
.get(&url)
.bearer_auth(&self.node_token)
.send()
.await?
.error_for_status()?;
let bytes = resp.bytes().await?;
fs::write(dest, &bytes).await?;
Ok(())
}
/// Delete a backup from CDN.
async fn delete_from_cdn(&self, cdn_path: &str) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/api/internal/cdn/delete", self.api_url);
client
.delete(&url)
.bearer_auth(&self.node_token)
.json(&serde_json::json!({ "path": cdn_path }))
.send()
.await?
.error_for_status()?;
Ok(())
}
/// Notify the panel API that a backup is complete.
async fn notify_backup_complete(&self, backup_id: &str, size: u64, checksum: &str) {
let client = reqwest::Client::new();
let url = format!("{}/api/internal/backups/{}/complete", self.api_url, backup_id);
let result = client
.post(&url)
.bearer_auth(&self.node_token)
.json(&serde_json::json!({
"size_bytes": size,
"checksum": checksum,
}))
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
info!(backup_id = %backup_id, "Backup completion notified");
}
Ok(resp) => {
error!(status = %resp.status(), "Failed to notify backup completion");
}
Err(e) => {
error!(error = %e, "Failed to notify backup completion");
}
}
}
}
/// Create a tar.gz archive from a source directory.
fn create_tar_gz(source: &Path, dest: &Path) -> Result<()> {
use flate2::write::GzEncoder;
use flate2::Compression;
let file = std::fs::File::create(dest)?;
let encoder = GzEncoder::new(file, Compression::default());
let mut archive = tar::Builder::new(encoder);
archive.append_dir_all(".", source)?;
archive.finish()?;
Ok(())
}
/// Extract a tar.gz archive to a destination directory.
fn extract_tar_gz(source: &Path, dest: &Path) -> Result<()> {
use flate2::read::GzDecoder;
let file = std::fs::File::open(source)?;
let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(dest)?;
Ok(())
}
/// Calculate SHA-256 checksum of a file.
fn calculate_sha256(path: &Path) -> Result<String> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
/// Simple SHA-256 implementation using the digest approach.
/// In production you'd use the `sha2` crate; this is a placeholder
/// that hashes via a simple checksum for now.
struct Sha256 {
state: u64,
}
impl Sha256 {
fn new() -> Self {
Self { state: 0xcbf29ce484222325 }
}
fn update(&mut self, data: &[u8]) {
// FNV-1a 64-bit hash (simple, not cryptographic — placeholder)
for &byte in data {
self.state ^= byte as u64;
self.state = self.state.wrapping_mul(0x100000001b3);
}
}
fn finalize(self) -> u64 {
self.state
}
}
+152
View File
@@ -0,0 +1,152 @@
use std::collections::HashMap;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use anyhow::{anyhow, Result};
use tokio::sync::{mpsc, oneshot, RwLock};
use tracing::{debug, warn};
use crate::server::ServerManager;
const DEFAULT_QUEUE_CAPACITY: usize = 256;
#[derive(Debug)]
struct CommandJob {
command: String,
response_tx: oneshot::Sender<Result<()>>,
}
#[derive(Clone)]
struct WorkerHandle {
id: u64,
sender: mpsc::Sender<CommandJob>,
}
pub struct CommandDispatcher {
server_manager: Arc<ServerManager>,
workers: Arc<RwLock<HashMap<String, WorkerHandle>>>,
next_worker_id: Arc<AtomicU64>,
queue_capacity: usize,
}
impl CommandDispatcher {
pub fn new(server_manager: Arc<ServerManager>) -> Self {
Self {
server_manager,
workers: Arc::new(RwLock::new(HashMap::new())),
next_worker_id: Arc::new(AtomicU64::new(1)),
queue_capacity: DEFAULT_QUEUE_CAPACITY,
}
}
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
let cmd = command.trim();
if cmd.is_empty() {
return Err(anyhow!("Command cannot be empty"));
}
// Retry once if the current worker channel is unexpectedly closed.
for _ in 0..2 {
let worker = self.get_or_create_worker(server_uuid).await;
let (response_tx, response_rx) = oneshot::channel();
let job = CommandJob {
command: cmd.to_string(),
response_tx,
};
match worker.sender.send(job).await {
Ok(_) => {
return response_rx
.await
.unwrap_or_else(|_| Err(anyhow!("Command worker dropped response channel")));
}
Err(send_err) => {
warn!(
server_uuid = %server_uuid,
worker_id = worker.id,
error = %send_err,
"Command worker queue send failed, rotating worker",
);
self.remove_worker_if_matches(server_uuid, worker.id).await;
}
}
}
Err(anyhow!("Failed to dispatch command after retry"))
}
async fn get_or_create_worker(&self, server_uuid: &str) -> WorkerHandle {
if let Some(existing) = self.workers.read().await.get(server_uuid).cloned() {
return existing;
}
let worker_id = self.next_worker_id.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = mpsc::channel::<CommandJob>(self.queue_capacity);
let handle = WorkerHandle {
id: worker_id,
sender: sender.clone(),
};
{
let mut workers = self.workers.write().await;
if let Some(existing) = workers.get(server_uuid).cloned() {
return existing;
}
workers.insert(server_uuid.to_string(), handle.clone());
}
self.spawn_worker(server_uuid.to_string(), worker_id, receiver);
handle
}
fn spawn_worker(
&self,
server_uuid: String,
worker_id: u64,
mut receiver: mpsc::Receiver<CommandJob>,
) {
let server_manager = self.server_manager.clone();
let workers = self.workers.clone();
tokio::spawn(async move {
debug!(server_uuid = %server_uuid, worker_id, "Command worker started");
while let Some(job) = receiver.recv().await {
let result = execute_command(server_manager.clone(), &server_uuid, &job.command).await;
let _ = job.response_tx.send(result);
}
let mut map = workers.write().await;
if let Some(current) = map.get(&server_uuid) {
if current.id == worker_id {
map.remove(&server_uuid);
}
}
debug!(server_uuid = %server_uuid, worker_id, "Command worker stopped");
});
}
async fn remove_worker_if_matches(&self, server_uuid: &str, worker_id: u64) {
let mut workers = self.workers.write().await;
if let Some(current) = workers.get(server_uuid) {
if current.id == worker_id {
workers.remove(server_uuid);
}
}
}
}
async fn execute_command(
server_manager: Arc<ServerManager>,
server_uuid: &str,
command: &str,
) -> Result<()> {
server_manager
.docker()
.send_command(server_uuid, command)
.await?;
Ok(())
}
+39 -1
View File
@@ -12,8 +12,16 @@ pub struct DaemonConfig {
pub docker: DockerConfig,
#[serde(default = "default_data_path")]
pub data_path: PathBuf,
/// Where `data_path` lives on the Docker host. Only differs from `data_path`
/// when the daemon itself runs in a container: bind mounts for the game
/// containers are resolved by the host Docker engine, not by the daemon's
/// own mount namespace. Defaults to `data_path`.
#[serde(default)]
pub host_data_path: Option<PathBuf>,
#[serde(default = "default_backup_path")]
pub backup_path: PathBuf,
#[serde(default)]
pub managed_mysql: Option<ManagedMysqlConfig>,
}
#[derive(Debug, Deserialize)]
@@ -36,6 +44,19 @@ impl Default for DockerConfig {
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct ManagedMysqlConfig {
pub url: String,
#[serde(default)]
pub connection_host: Option<String>,
#[serde(default)]
pub connection_port: Option<u16>,
#[serde(default)]
pub phpmyadmin_url: Option<String>,
#[serde(default)]
pub bin: Option<String>,
}
fn default_grpc_port() -> u16 {
50051
}
@@ -77,7 +98,24 @@ grpc_port: 50051
.to_string()
});
let config: DaemonConfig = serde_yaml::from_str(&content)?;
let mut config: DaemonConfig = serde_yaml::from_str(&content)?;
// Environment overrides make containerised deployments configurable
// without templating the YAML file.
if let Ok(host_data_path) = std::env::var("DAEMON_HOST_DATA_PATH") {
let trimmed = host_data_path.trim();
if !trimmed.is_empty() {
config.host_data_path = Some(PathBuf::from(trimmed));
}
}
Ok(config)
}
/// Path prefix the Docker host uses for server data directories.
pub fn host_data_path(&self) -> PathBuf {
self.host_data_path
.clone()
.unwrap_or_else(|| self.data_path.clone())
}
}
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use anyhow::Result;
use bollard::Docker;
use bollard::network::CreateNetworkOptions;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle;
use tracing::info;
use crate::config::DaemonConfig;
type AttachedInput = Pin<Box<dyn AsyncWrite + Send>>;
pub(crate) struct CommandStreamHandle {
/// Docker id of the container this stdin stream was opened against. A
/// container that gets recreated (or restarted) keeps the same name but
/// gets a new id, and writes to the old hijacked socket are silently
/// swallowed — so the id is what makes a cached stream reusable.
container_id: String,
input: Mutex<AttachedInput>,
drain_task: JoinHandle<()>,
}
impl CommandStreamHandle {
pub(crate) fn new(
container_id: String,
input: AttachedInput,
drain_task: JoinHandle<()>,
) -> Self {
Self {
container_id,
input: Mutex::new(input),
drain_task,
}
}
pub(crate) fn container_id(&self) -> &str {
&self.container_id
}
pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> {
let mut input = self.input.lock().await;
input.write_all(bytes).await?;
input.flush().await?;
Ok(())
}
pub(crate) fn abort(&self) {
self.drain_task.abort();
}
}
/// Manages the Docker client and network setup.
#[derive(Clone)]
pub struct DockerManager {
client: Docker,
network_name: String,
data_root: PathBuf,
host_data_root: PathBuf,
command_streams: Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>,
}
impl DockerManager {
pub async fn new(config: &DaemonConfig) -> Result<Self> {
let client = Docker::connect_with_socket(
&config.docker.socket,
120, // timeout
bollard::API_DEFAULT_VERSION,
)?;
// Verify connection
let version = client.version().await?;
info!(
docker_version = version.version.as_deref().unwrap_or("unknown"),
"Connected to Docker"
);
let data_root = config.data_path.clone();
let host_data_root = config.host_data_path();
if data_root != host_data_root {
info!(
data_root = %data_root.display(),
host_data_root = %host_data_root.display(),
"Server data directories are bind-mounted from a different host path",
);
}
let manager = Self {
client,
network_name: config.docker.network.clone(),
data_root,
host_data_root,
command_streams: Arc::new(RwLock::new(HashMap::new())),
};
manager.ensure_network(&config.docker.network_subnet).await?;
Ok(manager)
}
pub fn client(&self) -> &Docker {
&self.client
}
pub fn network_name(&self) -> &str {
&self.network_name
}
/// Translate a daemon-local server data directory into the path the Docker
/// host must bind-mount. These differ when the daemon runs in a container.
pub fn host_bind_source(&self, data_path: &Path) -> PathBuf {
if self.data_root == self.host_data_root {
return data_path.to_path_buf();
}
match data_path.strip_prefix(&self.data_root) {
Ok(relative) => self.host_data_root.join(relative),
Err(_) => data_path.to_path_buf(),
}
}
/// Inverse of [`Self::host_bind_source`]: turn a bind-mount source reported
/// by Docker back into a path the daemon can read and write itself.
pub fn daemon_data_path(&self, host_path: &Path) -> PathBuf {
if self.data_root == self.host_data_root {
return host_path.to_path_buf();
}
match host_path.strip_prefix(&self.host_data_root) {
Ok(relative) => self.data_root.join(relative),
Err(_) => host_path.to_path_buf(),
}
}
pub(crate) fn command_streams(&self) -> &Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>> {
&self.command_streams
}
async fn ensure_network(&self, subnet: &str) -> Result<()> {
let networks = self.client.list_networks::<String>(None).await?;
let exists = networks
.iter()
.any(|n| n.name.as_deref() == Some(&self.network_name));
if !exists {
info!(network = %self.network_name, "Creating Docker network");
let ipam_config = bollard::models::IpamConfig {
subnet: Some(subnet.to_string()),
..Default::default()
};
let ipam = bollard::models::Ipam {
config: Some(vec![ipam_config]),
..Default::default()
};
self.client
.create_network(CreateNetworkOptions {
name: self.network_name.clone(),
driver: "bridge".to_string(),
ipam,
..Default::default()
})
.await?;
info!(network = %self.network_name, "Docker network created");
}
Ok(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod container;
pub mod manager;
pub use manager::DockerManager;
+52
View File
@@ -0,0 +1,52 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DaemonError {
#[error("Docker error: {0}")]
Docker(#[from] bollard::errors::Error),
#[error("Server not found: {0}")]
ServerNotFound(String),
#[error("Server already exists: {0}")]
ServerAlreadyExists(String),
#[error("Invalid state transition: {current} -> {requested}")]
InvalidStateTransition { current: String, requested: String },
#[error("Filesystem error: {0}")]
Filesystem(String),
#[error("Path traversal attempt: {0}")]
PathTraversal(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Authentication failed")]
AuthFailed,
#[error("{0}")]
Internal(String),
}
impl From<DaemonError> for tonic::Status {
fn from(err: DaemonError) -> Self {
match &err {
DaemonError::ServerNotFound(_) => tonic::Status::not_found(err.to_string()),
DaemonError::ServerAlreadyExists(_) => {
tonic::Status::already_exists(err.to_string())
}
DaemonError::InvalidStateTransition { .. } => {
tonic::Status::failed_precondition(err.to_string())
}
DaemonError::PathTraversal(_) => {
tonic::Status::permission_denied(err.to_string())
}
DaemonError::AuthFailed => {
tonic::Status::unauthenticated(err.to_string())
}
_ => tonic::Status::internal(err.to_string()),
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod operations;
pub use operations::FileSystem;
+233
View File
@@ -0,0 +1,233 @@
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::debug;
use crate::error::DaemonError;
/// Filesystem operations with path jail enforcement.
pub struct FileSystem {
root: PathBuf,
}
impl FileSystem {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
/// Resolve a relative path within the jail. Prevents path traversal.
fn resolve(&self, relative: &str) -> Result<PathBuf, DaemonError> {
let clean = relative.trim_start_matches('/');
let resolved = self.root.join(clean);
// Canonicalize both to compare (handle .. and symlinks)
// For non-existent paths, check the parent
let check_path = if resolved.exists() {
resolved.canonicalize().map_err(DaemonError::Io)?
} else {
let parent = resolved
.parent()
.ok_or_else(|| DaemonError::PathTraversal(relative.to_string()))?;
if !parent.exists() {
// Parent doesn't exist either — check the root prefix
let normalized = self.root.join(clean);
if !normalized.starts_with(&self.root) {
return Err(DaemonError::PathTraversal(relative.to_string()));
}
return Ok(normalized);
}
let canonical_parent = parent.canonicalize().map_err(DaemonError::Io)?;
canonical_parent.join(resolved.file_name().unwrap_or_default())
};
let canonical_root = self.root.canonicalize().unwrap_or_else(|_| self.root.clone());
if !check_path.starts_with(&canonical_root) {
return Err(DaemonError::PathTraversal(relative.to_string()));
}
Ok(resolved)
}
/// List files in a directory.
pub async fn list_files(&self, path: &str) -> Result<Vec<FileEntry>, DaemonError> {
let resolved = self.resolve(path)?;
let mut entries = Vec::new();
let mut reader = fs::read_dir(&resolved).await.map_err(DaemonError::Io)?;
while let Some(entry) = reader.next_entry().await.map_err(DaemonError::Io)? {
let metadata = entry.metadata().await.map_err(DaemonError::Io)?;
let name = entry.file_name().to_string_lossy().to_string();
let relative_path = format!(
"{}/{}",
path.trim_end_matches('/'),
&name
);
entries.push(FileEntry {
name,
path: relative_path,
is_directory: metadata.is_dir(),
size: metadata.len() as i64,
modified_at: metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0),
});
}
entries.sort_by(|a, b| {
// Directories first, then by name
b.is_directory.cmp(&a.is_directory).then(a.name.cmp(&b.name))
});
Ok(entries)
}
/// Read file contents.
pub async fn read_file(&self, path: &str) -> Result<Vec<u8>, DaemonError> {
let resolved = self.resolve(path)?;
debug!(path = %resolved.display(), "Reading file");
fs::read(&resolved).await.map_err(DaemonError::Io)
}
/// Write file contents.
pub async fn write_file(&self, path: &str, data: &[u8]) -> Result<(), DaemonError> {
let resolved = self.resolve(path)?;
let owner = resolved
.parent()
.and_then(resolve_target_ownership);
// Ensure parent directory exists
if let Some(parent) = resolved.parent() {
fs::create_dir_all(parent).await.map_err(DaemonError::Io)?;
if let Some(ref owner) = owner {
apply_ownership_to_path_chain(parent, &owner)?;
}
}
debug!(path = %resolved.display(), "Writing file");
fs::write(&resolved, data).await.map_err(DaemonError::Io)?;
if let Some(ref owner) = owner {
apply_ownership(&resolved, owner.uid, owner.gid)?;
}
Ok(())
}
/// Delete files or directories.
pub async fn delete_paths(&self, paths: &[String]) -> Result<(), DaemonError> {
for path in paths {
let resolved = self.resolve(path)?;
if resolved.is_dir() {
fs::remove_dir_all(&resolved).await.map_err(DaemonError::Io)?;
} else {
fs::remove_file(&resolved).await.map_err(DaemonError::Io)?;
}
debug!(path = %resolved.display(), "Deleted");
}
Ok(())
}
}
#[derive(Clone, Debug)]
struct OwnershipTarget {
anchor: PathBuf,
uid: u32,
gid: u32,
}
#[cfg(unix)]
fn resolve_target_ownership(start: &Path) -> Option<OwnershipTarget> {
use std::os::unix::fs::MetadataExt;
let mut cursor = Some(start);
let mut fallback: Option<OwnershipTarget> = None;
while let Some(path) = cursor {
if let Ok(metadata) = std::fs::metadata(path) {
let candidate = OwnershipTarget {
anchor: path.to_path_buf(),
uid: metadata.uid(),
gid: metadata.gid(),
};
if fallback.is_none() {
fallback = Some(candidate.clone());
}
if candidate.uid != 0 || candidate.gid != 0 {
return Some(candidate);
}
}
cursor = path.parent();
}
fallback
}
#[cfg(not(unix))]
fn resolve_target_ownership(_start: &Path) -> Option<OwnershipTarget> {
None
}
#[cfg(unix)]
fn apply_ownership_to_path_chain(target: &Path, owner: &OwnershipTarget) -> Result<(), DaemonError> {
if !target.starts_with(&owner.anchor) {
return Ok(());
}
let mut current = owner.anchor.clone();
apply_ownership(&current, owner.uid, owner.gid)?;
let remainder = match target.strip_prefix(&owner.anchor) {
Ok(path) => path,
Err(_) => return Ok(()),
};
for component in remainder.components() {
current.push(component.as_os_str());
apply_ownership(&current, owner.uid, owner.gid)?;
}
Ok(())
}
#[cfg(not(unix))]
fn apply_ownership_to_path_chain(_target: &Path, _owner: &OwnershipTarget) -> Result<(), DaemonError> {
Ok(())
}
#[cfg(unix)]
fn apply_ownership(path: &Path, uid: u32, gid: u32) -> Result<(), DaemonError> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let bytes = path.as_os_str().as_bytes();
let c_path = CString::new(bytes).map_err(|err| {
DaemonError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid path for chown: {err}"),
))
})?;
let result = unsafe { libc::chown(c_path.as_ptr(), uid, gid) };
if result != 0 {
return Err(DaemonError::Io(std::io::Error::last_os_error()));
}
Ok(())
}
#[cfg(not(unix))]
fn apply_ownership(_path: &Path, _uid: u32, _gid: u32) -> Result<(), DaemonError> {
Ok(())
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub name: String,
pub path: String,
pub is_directory: bool,
pub size: i64,
pub modified_at: i64,
}
+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());
}
}
+166
View File
@@ -0,0 +1,166 @@
use anyhow::Result;
use tracing::info;
use super::rcon::RconClient;
/// Player information from CS2 RCON.
pub struct Cs2Player {
pub name: String,
pub steamid: String,
pub score: i32,
pub ping: u32,
}
/// Query CS2 server for active players using RCON `status` command.
pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<(Vec<Cs2Player>, u32)> {
let mut client = RconClient::connect(rcon_address, rcon_password).await?;
let response = client.command("status").await?;
let (players, max) = parse_status_response(&response);
info!(
count = players.len(),
max = max,
"CS2 player list retrieved"
);
Ok((players, max))
}
fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
let mut players = Vec::new();
let mut max_players = 0u32;
let mut in_player_section = false;
for line in response.lines() {
let trimmed = line.trim();
// Parse max players from status line variants:
// "players : X humans, Y bots (Z/M max)"
// "players : X humans, Y bots (Z max)"
if trimmed.starts_with("players") {
if let Some(parsed_max) = parse_max_players_from_line(trimmed) {
max_players = parsed_max;
}
}
if trimmed.contains("---------players--------") || trimmed.starts_with("# userid") {
in_player_section = true;
continue;
}
if in_player_section && (trimmed == "#end" || trimmed.starts_with("---------")) {
in_player_section = false;
continue;
}
// Parse player lines for both old and current CS2 status formats.
if in_player_section {
if let Some((name, steamid)) = parse_player_line(trimmed) {
players.push(Cs2Player {
name,
steamid,
score: 0,
ping: 0,
});
}
}
}
(players, max_players)
}
fn parse_max_players_from_line(line: &str) -> Option<u32> {
let start = line.find('(')?;
let end = line[start + 1..].find(')')? + start + 1;
let inside = &line[start + 1..end];
inside
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<u32>().ok())
.max()
}
fn parse_player_line(line: &str) -> Option<(String, String)> {
// Skip table/header rows.
if line.is_empty()
|| line.starts_with("id ")
|| line.contains("userid")
|| line.contains("steamid")
|| line.contains("adr name")
{
return None;
}
// Legacy format: # 2 "Player" STEAM_...
if let Some(quote_start) = line.find('"') {
let quote_end = line[quote_start + 1..].find('"')? + quote_start + 1;
let name = line[quote_start + 1..quote_end].trim().to_string();
if name.is_empty() {
return None;
}
let rest = line[quote_end + 1..].trim();
let steamid = rest.split_whitespace().next()?.to_string();
if steamid.is_empty() {
return None;
}
return Some((name, steamid));
}
// Current CS2 format: ... 'PlayerName'
let quote_end = line.rfind('\'')?;
let before_end = &line[..quote_end];
let quote_start = before_end.rfind('\'')?;
if quote_start >= quote_end {
return None;
}
let name = line[quote_start + 1..quote_end].trim().to_string();
if name.is_empty() {
return None;
}
// New status output does not include steamid in player rows.
Some((name, String::new()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_status_basic() {
let response = r#"hostname: Test Server
version : 2.0.0
players : 2 humans, 0 bots (16/0 max) (not hibernating)
# userid name steamid connected ping loss state rate
# 2 "Player1" STEAM_1:0:12345 00:05 50 0 active 128000
# 3 "Player2" STEAM_1:0:67890 00:10 30 0 active 128000
"#;
let (players, max) = parse_status_response(response);
assert_eq!(max, 16);
assert_eq!(players.len(), 2);
}
#[test]
fn test_parse_status_current_cs2_format() {
let response = r#"Server: Running [0.0.0.0:27015]
players : 1 humans, 2 bots (0 max) (not hibernating) (unreserved)
---------players--------
id time ping loss state rate adr name
65535 [NoChan] 0 0 challenging 0unknown ''
1 BOT 0 0 active 0 'Rezan'
2 00:21 11 0 active 786432 212.154.6.153:57008 'hibna'
3 BOT 0 0 active 0 'Squad'
#end
"#;
let (players, max) = parse_status_response(response);
assert_eq!(max, 0);
assert_eq!(players.len(), 3);
assert_eq!(players[0].name, "Rezan");
assert_eq!(players[1].name, "hibna");
assert_eq!(players[2].name, "Squad");
}
}
+95
View File
@@ -0,0 +1,95 @@
use anyhow::Result;
use tracing::info;
use super::rcon::RconClient;
/// Player information from Minecraft RCON.
pub struct MinecraftPlayer {
pub name: String,
}
/// Query Minecraft server for active players using RCON `list` command.
pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<(Vec<MinecraftPlayer>, u32)> {
let mut client = RconClient::connect(rcon_address, rcon_password).await?;
let response = client.command("list").await?;
// Parse response: "There are X of a max of Y players online: player1, player2"
let (count, max, players) = parse_list_response(&response);
info!(
count = count,
max = max,
"Minecraft player list retrieved"
);
Ok((players, max))
}
fn parse_list_response(response: &str) -> (u32, u32, Vec<MinecraftPlayer>) {
// Format: "There are X of a max of Y players online: player1, player2, ..."
// Or: "There are X of a max Y players online:"
let parts: Vec<&str> = response.splitn(2, ':').collect();
let mut count = 0u32;
let mut max = 0u32;
let mut found_count = false;
if let Some(header) = parts.first() {
// Extract numbers from "There are X of a max of Y players online"
let words: Vec<&str> = header.split_whitespace().collect();
for word in words.iter() {
if let Ok(n) = word.parse::<u32>() {
if !found_count {
count = n;
found_count = true;
} else {
max = n;
}
}
}
}
let mut players = Vec::new();
if parts.len() > 1 {
let player_list = parts[1].trim();
if !player_list.is_empty() {
for name in player_list.split(',') {
let name = name.trim();
if !name.is_empty() {
players.push(MinecraftPlayer {
name: name.to_string(),
});
}
}
}
}
(count, max, players)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_list_response() {
let (count, max, players) = parse_list_response(
"There are 3 of a max of 20 players online: Steve, Alex, Notch",
);
assert_eq!(count, 3);
assert_eq!(max, 20);
assert_eq!(players.len(), 3);
assert_eq!(players[0].name, "Steve");
assert_eq!(players[1].name, "Alex");
assert_eq!(players[2].name, "Notch");
}
#[test]
fn test_parse_empty_list() {
let (count, max, players) = parse_list_response(
"There are 0 of a max of 20 players online:",
);
assert_eq!(count, 0);
assert_eq!(max, 20);
assert_eq!(players.len(), 0);
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod rcon;
pub mod minecraft;
pub mod cs2;
pub mod ark;
+87
View File
@@ -0,0 +1,87 @@
use anyhow::{Result, Context};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tracing::debug;
/// RCON packet types
const PACKET_LOGIN: i32 = 3;
const PACKET_COMMAND: i32 = 2;
const PACKET_RESPONSE: i32 = 0;
/// A minimal Source RCON client.
pub struct RconClient {
stream: TcpStream,
request_id: i32,
}
impl RconClient {
/// Connect to an RCON server and authenticate.
pub async fn connect(address: &str, password: &str) -> Result<Self> {
let stream = TcpStream::connect(address)
.await
.context("Failed to connect to RCON")?;
let mut client = Self {
stream,
request_id: 0,
};
// Authenticate
let response = client.send_packet(PACKET_LOGIN, password).await?;
if response.id == -1 {
anyhow::bail!("RCON authentication failed");
}
debug!(address = %address, "RCON connected and authenticated");
Ok(client)
}
/// Send a command and return the response body.
pub async fn command(&mut self, cmd: &str) -> Result<String> {
let response = self.send_packet(PACKET_COMMAND, cmd).await?;
Ok(response.body)
}
async fn send_packet(&mut self, packet_type: i32, body: &str) -> Result<RconPacket> {
self.request_id += 1;
let id = self.request_id;
let body_bytes = body.as_bytes();
let length = 4 + 4 + body_bytes.len() + 2; // id + type + body + 2 null bytes
// Write packet
self.stream.write_i32_le(length as i32).await?;
self.stream.write_i32_le(id).await?;
self.stream.write_i32_le(packet_type).await?;
self.stream.write_all(body_bytes).await?;
self.stream.write_all(&[0, 0]).await?; // two null terminators
self.stream.flush().await?;
// Read response
let resp_length = self.stream.read_i32_le().await?;
let resp_id = self.stream.read_i32_le().await?;
let resp_type = self.stream.read_i32_le().await?;
let body_length = (resp_length - 4 - 4 - 2) as usize;
let mut body_buf = vec![0u8; body_length];
self.stream.read_exact(&mut body_buf).await?;
// Read two null terminators
let mut null_buf = [0u8; 2];
self.stream.read_exact(&mut null_buf).await?;
let response_body = String::from_utf8_lossy(&body_buf).to_string();
Ok(RconPacket {
id: resp_id,
packet_type: resp_type,
body: response_body,
})
}
}
struct RconPacket {
id: i32,
packet_type: i32,
body: String,
}
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
set -e
NUMCHECK='^[0-9]+$'
MSGWARNING="\033[0;33mWARNING:\033[0m"
if ! [[ "$SERVERGAMEPORT" =~ $NUMCHECK ]]; then
printf "Invalid server port given: %s\n" "$SERVERGAMEPORT"
SERVERGAMEPORT="7777"
fi
printf "Setting server port to %s\n" "$SERVERGAMEPORT"
if ! [[ "$SERVERMESSAGINGPORT" =~ $NUMCHECK ]]; then
printf "Invalid messaging port given: %s\n" "$SERVERMESSAGINGPORT"
SERVERMESSAGINGPORT="8888"
fi
printf "Setting messaging port to %s\n" "$SERVERMESSAGINGPORT"
if ! [[ "$AUTOSAVENUM" =~ $NUMCHECK ]]; then
printf "Invalid autosave number given: %s\n" "$AUTOSAVENUM"
AUTOSAVENUM="5"
fi
printf "Setting autosave number to %s\n" "$AUTOSAVENUM"
if ! [[ "$MAXOBJECTS" =~ $NUMCHECK ]]; then
printf "Invalid max objects number given: %s\n" "$MAXOBJECTS"
MAXOBJECTS="2162688"
fi
printf "Setting max objects to %s\n" "$MAXOBJECTS"
if ! [[ "$MAXTICKRATE" =~ $NUMCHECK ]]; then
printf "Invalid max tick rate number given: %s\n" "$MAXTICKRATE"
MAXTICKRATE="30"
fi
printf "Setting max tick rate to %s\n" "$MAXTICKRATE"
[[ "${SERVERSTREAMING,,}" == "true" ]] && SERVERSTREAMING="1" || SERVERSTREAMING="0"
printf "Setting server streaming to %s\n" "$SERVERSTREAMING"
if ! [[ "$TIMEOUT" =~ $NUMCHECK ]]; then
printf "Invalid timeout number given: %s\n" "$TIMEOUT"
TIMEOUT="30"
fi
printf "Setting timeout to %s\n" "$TIMEOUT"
if ! [[ "$MAXPLAYERS" =~ $NUMCHECK ]]; then
printf "Invalid max players given: %s\n" "$MAXPLAYERS"
MAXPLAYERS="4"
fi
printf "Setting max players to %s\n" "$MAXPLAYERS"
if [[ "${DISABLESEASONALEVENTS,,}" == "true" ]]; then
printf "Disabling seasonal events\n"
DISABLESEASONALEVENTS="-DisableSeasonalEvents"
else
DISABLESEASONALEVENTS=""
fi
if [[ "$MULTIHOME" != "" ]]; then
if [[ "$MULTIHOME" == "::" ]]; then
printf "Multihome will accept IPv4 and IPv6 connections\n"
fi
printf "Setting multihome to %s\n" "$MULTIHOME"
MULTIHOME="-multihome=$MULTIHOME"
fi
ini_args=(
"-ini:Engine:[/Script/FactoryGame.FGSaveSession]:mNumRotatingAutosaves=$AUTOSAVENUM"
"-ini:Engine:[/Script/Engine.GarbageCollectionSettings]:gc.MaxObjectsInEditor=$MAXOBJECTS"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:LanServerMaxTickRate=$MAXTICKRATE"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:NetServerMaxTickRate=$MAXTICKRATE"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:ConnectionTimeout=$TIMEOUT"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:InitialConnectTimeout=$TIMEOUT"
"-ini:Engine:[ConsoleVariables]:wp.Runtime.EnableServerStreaming=$SERVERSTREAMING"
"-ini:Game:[/Script/Engine.GameSession]:ConnectionTimeout=$TIMEOUT"
"-ini:Game:[/Script/Engine.GameSession]:InitialConnectTimeout=$TIMEOUT"
"-ini:Game:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
"-ini:GameUserSettings:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
"$DISABLESEASONALEVENTS"
"$MULTIHOME"
)
if [[ "${SKIPUPDATE,,}" != "false" ]] && [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
printf "%s Skip update is set, but no game files exist. Updating anyway\n" "$MSGWARNING"
SKIPUPDATE="false"
fi
if [[ "${SKIPUPDATE,,}" != "true" ]]; then
STEAMBETAPASSWORD=""
if [[ -n "${STEAMBETAID}" ]]; then
printf "STEAMBETAID is set. Using beta ID: %s\n" "$STEAMBETAID"
STEAMBETAFLAG="$STEAMBETAID"
if [[ -n "${STEAMBETAKEY}" ]]; then
STEAMBETAPASSWORD="-betapassword $STEAMBETAKEY"
printf "Beta password provided\n"
fi
elif [[ "${STEAMBETA,,}" == "true" ]]; then
printf "Experimental flag is set. Experimental will be downloaded instead of Early Access.\n"
STEAMBETAFLAG="experimental"
else
STEAMBETAFLAG=""
fi
STORAGEAVAILABLE=$(stat -f -c "%a*%S" .)
STORAGEAVAILABLE=$((STORAGEAVAILABLE/1024/1024/1024))
printf "Checking available storage: %sGB detected\n" "$STORAGEAVAILABLE"
if [[ "$STORAGEAVAILABLE" -lt 8 ]]; then
printf "You have less than 8GB (%sGB detected) of available storage to download the game.\nIf this is a fresh install, it will probably fail.\n" "$STORAGEAVAILABLE"
fi
printf "\nDownloading the latest version of the game...\n"
if [ -f "/config/gamefiles/steamapps/appmanifest_1690800.acf" ]; then
printf "\nRemoving the app manifest to force Steam to check for an update...\n"
rm "/config/gamefiles/steamapps/appmanifest_1690800.acf" || true
fi
if [[ -n "$STEAMBETAFLAG" ]]; then
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" -beta "$STEAMBETAFLAG" $STEAMBETAPASSWORD validate +quit
else
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" validate +quit
fi
cp -r /home/steam/.steam/steam/logs/* "/config/logs/steam" || printf "Failed to store Steam logs\n"
else
printf "Skipping update as flag is set\n"
fi
printf "Launching game server\n\n"
cp -r "/config/saved/server/." "/config/backups/" 2>/dev/null || true
cp -r "${GAMESAVESDIR}/server/." "/config/backups" 2>/dev/null || true
rm -rf "$GAMESAVESDIR"
ln -sf "/config/saved" "$GAMESAVESDIR"
if [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
printf "FactoryServer launch script is missing.\n"
exit 1
fi
cd /config/gamefiles || exit 1
chmod +x FactoryServer.sh || true
./FactoryServer.sh -Port="$SERVERGAMEPORT" -ReliablePort="$SERVERMESSAGINGPORT" -ExternalReliablePort="$SERVERMESSAGINGPORT" "${ini_args[@]}" "$@" &
sleep 2
satisfactory_pid=$(ps --ppid ${!} o pid=)
shutdown() {
printf "\nReceived SIGINT. Shutting down.\n"
kill -INT $satisfactory_pid 2>/dev/null
}
trap shutdown SIGINT SIGTERM
wait
+3
View File
@@ -0,0 +1,3 @@
pub mod service;
pub use service::DaemonServiceImpl;
File diff suppressed because it is too large Load Diff
+145 -8
View File
@@ -1,11 +1,47 @@
use std::sync::Arc;
use anyhow::Result;
use tonic::transport::Server;
use tracing::info;
use tracing_subscriber::EnvFilter;
mod auth;
mod backup;
mod command;
mod config;
mod docker;
mod error;
mod filesystem;
mod game;
mod grpc;
mod managed_mysql;
mod scheduler;
mod server;
use crate::docker::DockerManager;
use crate::grpc::DaemonServiceImpl;
use crate::grpc::service::pb::daemon_service_server::DaemonServiceServer;
use crate::managed_mysql::ManagedMysqlManager;
use crate::server::ServerManager;
use crate::command::CommandDispatcher;
const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024;
#[tokio::main]
async fn main() -> Result<()> {
// `--health-check` is what the container HEALTHCHECK runs: succeed only if
// the gRPC listener is actually accepting connections.
if std::env::args().any(|arg| arg == "--health-check") {
let config = config::DaemonConfig::load()?;
let address = format!("127.0.0.1:{}", config.grpc_port);
return match tokio::net::TcpStream::connect(&address).await {
Ok(_) => Ok(()),
Err(error) => {
eprintln!("daemon health check failed for {address}: {error}");
std::process::exit(1);
}
};
}
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(
@@ -13,20 +49,121 @@ async fn main() -> Result<()> {
)
.init();
info!("GamePanel Daemon starting...");
info!("GamePanel Daemon v{} starting...", env!("CARGO_PKG_VERSION"));
// Load config
let config = config::DaemonConfig::load()?;
info!(grpc_port = config.grpc_port, "Configuration loaded");
// TODO: Initialize Docker client
// TODO: Start gRPC server
// TODO: Begin heartbeat loop
// Initialize Docker
let docker = Arc::new(DockerManager::new(&config).await?);
info!("Docker manager initialized");
info!("GamePanel Daemon ready");
// Initialize server manager
let server_manager = Arc::new(ServerManager::new(docker, &config));
info!("Server manager initialized");
// Keep the process running
tokio::signal::ctrl_c().await?;
info!("Shutting down...");
let recovered_servers = server_manager.recover_existing_servers().await?;
info!(recovered_servers, "Recovered managed servers from Docker");
// Initialize shared command dispatcher (single command pipeline for all games/sources)
let command_dispatcher = Arc::new(CommandDispatcher::new(server_manager.clone()));
info!("Command dispatcher initialized");
let managed_mysql = Arc::new(ManagedMysqlManager::new(config.managed_mysql.clone())?);
info!(enabled = managed_mysql.is_enabled(), "Managed MySQL initialized");
// Create gRPC service
let daemon_service = DaemonServiceImpl::new(
server_manager.clone(),
command_dispatcher.clone(),
config.node_token.clone(),
config.backup_path.clone(),
config.api_url.clone(),
managed_mysql.clone(),
);
// Start gRPC server
let addr = format!("0.0.0.0:{}", config.grpc_port).parse()?;
info!(addr = %addr, "Starting gRPC server");
// Heartbeat task
let api_url = config.api_url.clone();
let node_token = config.node_token.clone();
let sm = server_manager.clone();
tokio::spawn(async move {
heartbeat_loop(&api_url, &node_token, sm).await;
});
// Scheduler task
let sched = Arc::new(scheduler::Scheduler::new(
server_manager.clone(),
command_dispatcher.clone(),
config.api_url.clone(),
config.node_token.clone(),
));
tokio::spawn(async move {
sched.run().await;
});
info!("Scheduler initialized");
// Start serving
let daemon_service = DaemonServiceServer::new(daemon_service)
.max_decoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES)
.max_encoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES);
Server::builder()
.add_service(daemon_service)
.serve_with_shutdown(addr, async {
tokio::signal::ctrl_c().await.ok();
info!("Shutdown signal received");
})
.await?;
info!("GamePanel Daemon stopped");
Ok(())
}
/// Periodically report node status to the panel API.
async fn heartbeat_loop(
api_url: &str,
node_token: &str,
server_manager: Arc<ServerManager>,
) {
let client = reqwest::Client::new();
let heartbeat_url = format!("{}/api/nodes/heartbeat", api_url);
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
let servers = server_manager.list_servers().await;
let active = servers
.iter()
.filter(|s| s.state.to_string() == "running")
.count();
let payload = serde_json::json!({
"active_servers": active,
"total_servers": servers.len(),
"version": env!("CARGO_PKG_VERSION"),
});
match client
.post(&heartbeat_url)
.bearer_auth(node_token)
.json(&payload)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
tracing::debug!("Heartbeat sent successfully");
}
Ok(resp) => {
tracing::warn!(status = %resp.status(), "Heartbeat failed");
}
Err(e) => {
tracing::warn!(error = %e, "Heartbeat request failed");
}
}
}
}
+463
View File
@@ -0,0 +1,463 @@
use std::io::ErrorKind;
use std::process::Stdio;
use reqwest::Url;
use thiserror::Error;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tonic::Status;
use uuid::Uuid;
use crate::config::ManagedMysqlConfig;
#[derive(Debug, Clone)]
struct ManagedMysqlRuntimeConfig {
admin_database: String,
admin_host: String,
admin_password: String,
admin_port: u16,
admin_username: String,
client_bin: Option<String>,
connection_host: String,
connection_port: u16,
phpmyadmin_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ManagedMysqlDatabase {
pub database_name: String,
pub username: String,
pub password: String,
pub host: String,
pub port: u16,
pub phpmyadmin_url: Option<String>,
}
#[derive(Debug, Error)]
pub enum ManagedMysqlError {
#[error("Managed MySQL is not configured on this node")]
NotConfigured,
#[error("Managed MySQL configuration is invalid: {0}")]
InvalidConfig(String),
#[error("Managed MySQL client binary is not installed on this node")]
ClientMissing,
#[error("Managed MySQL command failed: {0}")]
CommandFailed(String),
#[error("Managed MySQL I/O error: {0}")]
Io(#[from] std::io::Error),
}
impl From<ManagedMysqlError> for Status {
fn from(error: ManagedMysqlError) -> Self {
match error {
ManagedMysqlError::NotConfigured | ManagedMysqlError::ClientMissing => {
Status::failed_precondition(error.to_string())
}
ManagedMysqlError::InvalidConfig(_) => Status::internal(error.to_string()),
ManagedMysqlError::CommandFailed(_) => Status::internal(error.to_string()),
ManagedMysqlError::Io(_) => Status::internal(error.to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct ManagedMysqlManager {
config: Option<ManagedMysqlRuntimeConfig>,
}
impl ManagedMysqlManager {
pub fn new(config: Option<ManagedMysqlConfig>) -> Result<Self, ManagedMysqlError> {
let runtime = match config {
Some(config) => Some(resolve_runtime_config(config)?),
None => None,
};
Ok(Self { config: runtime })
}
pub fn is_enabled(&self) -> bool {
self.config.is_some()
}
pub async fn create_database(
&self,
server_uuid: &str,
label: &str,
password: Option<&str>,
) -> Result<ManagedMysqlDatabase, ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
let label = label.trim();
if label.is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"Database name is required".to_string(),
));
}
let database_name = build_database_name(server_uuid, label);
let username = build_username(server_uuid);
let password = build_password(password);
self.run_sql(
config,
&format!(
"CREATE DATABASE {} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
escape_identifier(&database_name)
),
)
.await?;
if let Err(error) = self
.run_sql(
config,
&format!(
"CREATE USER {}@'%' IDENTIFIED BY {};GRANT ALL PRIVILEGES ON {}.* TO {}@'%'",
escape_string(&username),
escape_string(&password),
escape_identifier(&database_name),
escape_string(&username),
),
)
.await
{
let _ = self
.run_sql(
config,
&format!("DROP DATABASE IF EXISTS {}", escape_identifier(&database_name)),
)
.await;
return Err(error);
}
Ok(ManagedMysqlDatabase {
database_name: database_name.clone(),
username,
password,
host: config.connection_host.clone(),
port: config.connection_port,
phpmyadmin_url: build_phpmyadmin_url(config.phpmyadmin_url.as_deref(), &database_name),
})
}
pub async fn update_password(
&self,
username: &str,
password: &str,
) -> Result<(), ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
let password = password.trim();
if password.is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"Database password is required".to_string(),
));
}
self.run_sql(
config,
&format!(
"ALTER USER {}@'%' IDENTIFIED BY {}",
escape_string(username),
escape_string(password),
),
)
.await
}
pub async fn import_sql(
&self,
database_name: &str,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
let database_name = database_name.trim();
if database_name.is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"Database name is required".to_string(),
));
}
if sql.trim().is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"SQL payload is required".to_string(),
));
}
self.run_sql_script(config, database_name, sql).await
}
pub async fn delete_database(
&self,
database_name: &str,
username: &str,
) -> Result<(), ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
self.run_sql(
config,
&format!(
"DROP DATABASE IF EXISTS {};DROP USER IF EXISTS {}@'%'",
escape_identifier(database_name),
escape_string(username),
),
)
.await
}
async fn run_sql(
&self,
config: &ManagedMysqlRuntimeConfig,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let binaries = match config.client_bin.as_deref() {
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
_ => vec!["mariadb".to_string(), "mysql".to_string()],
};
let mut missing_binary = false;
for binary in binaries {
let output = Command::new(&binary)
.args([
"--protocol=TCP",
"--batch",
"--skip-column-names",
"-h",
&config.admin_host,
"-P",
&config.admin_port.to_string(),
"-u",
&config.admin_username,
&config.admin_database,
"-e",
sql,
])
.env("MYSQL_PWD", &config.admin_password)
.output()
.await;
match output {
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("{} exited with status {}", binary, output.status)
};
return Err(ManagedMysqlError::CommandFailed(message));
}
Err(error) if error.kind() == ErrorKind::NotFound => {
missing_binary = true;
continue;
}
Err(error) => return Err(ManagedMysqlError::Io(error)),
}
}
if missing_binary {
return Err(ManagedMysqlError::ClientMissing);
}
Err(ManagedMysqlError::ClientMissing)
}
async fn run_sql_script(
&self,
config: &ManagedMysqlRuntimeConfig,
database_name: &str,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let binaries = match config.client_bin.as_deref() {
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
_ => vec!["mariadb".to_string(), "mysql".to_string()],
};
let mut missing_binary = false;
for binary in binaries {
let child = Command::new(&binary)
.args([
"--protocol=TCP",
"--batch",
"--skip-column-names",
"-h",
&config.admin_host,
"-P",
&config.admin_port.to_string(),
"-u",
&config.admin_username,
database_name,
])
.env("MYSQL_PWD", &config.admin_password)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
match child {
Ok(mut child) => {
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(sql.as_bytes()).await?;
}
let output = child.wait_with_output().await?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("{} exited with status {}", binary, output.status)
};
return Err(ManagedMysqlError::CommandFailed(message));
}
Err(error) if error.kind() == ErrorKind::NotFound => {
missing_binary = true;
continue;
}
Err(error) => return Err(ManagedMysqlError::Io(error)),
}
}
if missing_binary {
return Err(ManagedMysqlError::ClientMissing);
}
Err(ManagedMysqlError::ClientMissing)
}
}
fn resolve_runtime_config(
config: ManagedMysqlConfig,
) -> Result<ManagedMysqlRuntimeConfig, ManagedMysqlError> {
let parsed = Url::parse(&config.url)
.map_err(|error| ManagedMysqlError::InvalidConfig(error.to_string()))?;
if parsed.scheme() != "mysql" && parsed.scheme() != "mariadb" {
return Err(ManagedMysqlError::InvalidConfig(
"url must use mysql:// or mariadb://".to_string(),
));
}
let admin_host = parsed.host_str().unwrap_or_default().trim().to_string();
let admin_username = parsed.username().trim().to_string();
if admin_host.is_empty() || admin_username.is_empty() {
return Err(ManagedMysqlError::InvalidConfig(
"url must include host and username".to_string(),
));
}
let admin_database = {
let trimmed = parsed.path().trim_start_matches('/').trim();
if trimmed.is_empty() {
"mysql".to_string()
} else {
trimmed.to_string()
}
};
Ok(ManagedMysqlRuntimeConfig {
admin_database,
admin_host: admin_host.clone(),
admin_password: parsed.password().unwrap_or_default().to_string(),
admin_port: parsed.port().unwrap_or(3306),
admin_username,
client_bin: config.bin,
connection_host: config.connection_host.unwrap_or(admin_host),
connection_port: config.connection_port.unwrap_or(parsed.port().unwrap_or(3306)),
phpmyadmin_url: config.phpmyadmin_url,
})
}
fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String {
let mut normalized = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
normalized.push(ch.to_ascii_lowercase());
} else if !normalized.ends_with('_') {
normalized.push('_');
}
}
let trimmed = normalized.trim_matches('_');
if trimmed.is_empty() {
return fallback.to_string();
}
trimmed
.chars()
.take(max_len)
.collect::<String>()
.trim_end_matches('_')
.to_string()
}
fn build_database_name(server_uuid: &str, label: &str) -> String {
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 12);
let label_token = normalize_token(label, "db", 16);
let suffix = Uuid::new_v4().simple().to_string();
format!("srv_{}_{}_{}", server_token, label_token, &suffix[..8])
.chars()
.take(64)
.collect::<String>()
.trim_end_matches('_')
.to_string()
}
fn build_username(server_uuid: &str) -> String {
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 8);
let suffix = Uuid::new_v4().simple().to_string();
format!("u_{}_{}", server_token, &suffix[..8])
.chars()
.take(32)
.collect::<String>()
.trim_end_matches('_')
.to_string()
}
fn build_password(password: Option<&str>) -> String {
match password {
Some(password) if !password.trim().is_empty() => password.trim().to_string(),
_ => {
let first = Uuid::new_v4().simple().to_string();
let second = Uuid::new_v4().simple().to_string();
format!("{}{}", first, second)
}
}
}
fn escape_identifier(value: &str) -> String {
format!("`{}`", value.replace('`', "``"))
}
fn escape_string(value: &str) -> String {
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''"))
}
fn build_phpmyadmin_url(base_url: Option<&str>, database_name: &str) -> Option<String> {
let base_url = base_url?.trim();
if base_url.is_empty() {
return None;
}
match Url::parse(base_url) {
Ok(mut url) => {
url.query_pairs_mut().append_pair("db", database_name);
Some(url.to_string())
}
Err(_) => Some(base_url.to_string()),
}
}
+167
View File
@@ -0,0 +1,167 @@
use std::sync::Arc;
use anyhow::Result;
use tokio::time::{interval, Duration};
use tracing::{info, error, warn};
use serde::Deserialize;
use crate::command::CommandDispatcher;
use crate::server::ServerManager;
/// A scheduled task received from the panel API.
#[derive(Debug, Clone, Deserialize)]
pub struct ScheduledTask {
pub id: String,
pub server_uuid: String,
pub action: String, // "command", "power", "backup"
pub payload: String, // command string, power action, or "backup"
pub schedule_type: String,
pub is_active: bool,
pub next_run_at: Option<String>, // ISO 8601
}
/// Scheduler that polls the panel API for due tasks and executes them.
pub struct Scheduler {
server_manager: Arc<ServerManager>,
command_dispatcher: Arc<CommandDispatcher>,
api_url: String,
node_token: String,
poll_interval_secs: u64,
}
impl Scheduler {
pub fn new(
server_manager: Arc<ServerManager>,
command_dispatcher: Arc<CommandDispatcher>,
api_url: String,
node_token: String,
) -> Self {
Self {
server_manager,
command_dispatcher,
api_url,
node_token,
poll_interval_secs: 15,
}
}
/// Run the scheduler loop. This should be spawned as a tokio task.
pub async fn run(self: Arc<Self>) {
info!("Scheduler started (poll interval: {}s)", self.poll_interval_secs);
let mut tick = interval(Duration::from_secs(self.poll_interval_secs));
loop {
tick.tick().await;
if let Err(e) = self.poll_and_execute().await {
error!(error = %e, "Scheduler poll failed");
}
}
}
/// Poll the API for due tasks and execute them.
async fn poll_and_execute(&self) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/api/internal/schedules/due", self.api_url);
let resp = client
.get(&url)
.bearer_auth(&self.node_token)
.send()
.await?;
if !resp.status().is_success() {
warn!(status = %resp.status(), "Failed to fetch due tasks");
return Ok(());
}
#[derive(Deserialize)]
struct DueResponse {
tasks: Vec<ScheduledTask>,
}
let due: DueResponse = resp.json().await?;
if due.tasks.is_empty() {
return Ok(());
}
info!(count = due.tasks.len(), "Processing due scheduled tasks");
for task in &due.tasks {
if let Err(e) = self.execute_task(task).await {
error!(
task_id = %task.id,
server = %task.server_uuid,
error = %e,
"Failed to execute scheduled task"
);
}
// Notify API that task was executed
let ack_url = format!(
"{}/api/internal/schedules/{}/ack",
self.api_url, task.id
);
let _ = client
.post(&ack_url)
.bearer_auth(&self.node_token)
.send()
.await;
}
Ok(())
}
/// Execute a single scheduled task.
async fn execute_task(&self, task: &ScheduledTask) -> Result<()> {
info!(
task_id = %task.id,
action = %task.action,
server = %task.server_uuid,
"Executing scheduled task"
);
match task.action.as_str() {
"command" => {
self.command_dispatcher
.send_command(&task.server_uuid, &task.payload)
.await?;
}
"power" => {
match task.payload.as_str() {
"start" => self.server_manager.start_server(&task.server_uuid).await?,
"stop" => self.server_manager.stop_server(&task.server_uuid, None, 0).await?,
"restart" => {
let _ = self.server_manager.stop_server(&task.server_uuid, None, 0).await;
tokio::time::sleep(Duration::from_secs(3)).await;
self.server_manager.start_server(&task.server_uuid).await?;
}
"kill" => self.server_manager.kill_server(&task.server_uuid).await?,
_ => warn!(payload = %task.payload, "Unknown power action"),
}
}
"backup" => {
// Trigger backup via the backup module
info!(
server = %task.server_uuid,
"Backup scheduled task — delegating to backup module"
);
// Backup is handled by sending callback to API
let client = reqwest::Client::new();
let url = format!(
"{}/api/internal/servers/{}/backup",
self.api_url, task.server_uuid
);
let _ = client
.post(&url)
.bearer_auth(&self.node_token)
.json(&serde_json::json!({ "name": format!("auto-{}", task.id) }))
.send()
.await;
}
_ => {
warn!(action = %task.action, "Unknown scheduled action");
}
}
Ok(())
}
}
+467
View File
@@ -0,0 +1,467 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{info, error, warn};
use anyhow::Result;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::config::DaemonConfig;
use crate::docker::DockerManager;
use crate::error::DaemonError;
use super::state::{ServerState, ServerSpec, ServerRuntime, PortMap};
/// Manages all game server instances on this node.
pub struct ServerManager {
servers: Arc<RwLock<HashMap<String, ServerSpec>>>,
docker: Arc<DockerManager>,
data_root: PathBuf,
}
impl ServerManager {
async fn ensure_server_data_dir(&self, data_path: &PathBuf) -> Result<(), DaemonError> {
tokio::fs::create_dir_all(data_path)
.await
.map_err(DaemonError::Io)?;
#[cfg(unix)]
{
// Containers may run with non-root users (e.g. steam uid 1000).
// Keep server directory writable to avoid install/start failures.
let permissions = std::fs::Permissions::from_mode(0o777);
tokio::fs::set_permissions(data_path, permissions)
.await
.map_err(DaemonError::Io)?;
}
Ok(())
}
fn is_running_state(state: &str) -> bool {
matches!(state, "running" | "restarting")
}
pub fn new(docker: Arc<DockerManager>, config: &DaemonConfig) -> Self {
Self {
servers: Arc::new(RwLock::new(HashMap::new())),
docker,
data_root: config.data_path.clone(),
}
}
/// Rebuild in-memory server specs from existing managed Docker containers.
pub async fn recover_existing_servers(&self) -> Result<usize, DaemonError> {
let recovered = self
.docker
.recover_managed_server_specs(&self.data_root)
.await
.map_err(|error| DaemonError::Internal(format!("Failed to recover managed containers: {}", error)))?;
let recovered_count = recovered.len();
let mut servers = self.servers.write().await;
servers.clear();
for spec in recovered {
self.ensure_server_data_dir(&spec.data_path).await?;
info!(
uuid = %spec.uuid,
state = %spec.state,
image = %spec.docker_image,
"Recovered managed server from Docker runtime"
);
servers.insert(spec.uuid.clone(), spec);
}
Ok(recovered_count)
}
/// Get server spec by UUID.
pub async fn get_server(&self, uuid: &str) -> Result<ServerSpec, DaemonError> {
let servers = self.servers.read().await;
servers
.get(uuid)
.cloned()
.ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))
}
/// Get all servers.
pub async fn list_servers(&self) -> Vec<ServerSpec> {
let servers = self.servers.read().await;
servers.values().cloned().collect()
}
/// Create a new game server.
pub async fn create_server(
&self,
uuid: String,
docker_image: String,
memory_limit: i64,
disk_limit: i64,
cpu_limit: i32,
startup_command: String,
environment: HashMap<String, String>,
ports: Vec<PortMap>,
runtime: ServerRuntime,
) -> Result<(), DaemonError> {
let mut servers = self.servers.write().await;
if servers.contains_key(&uuid) {
return Err(DaemonError::ServerAlreadyExists(uuid));
}
let data_path = self.data_root.join(&uuid);
self.ensure_server_data_dir(&data_path).await?;
let spec = ServerSpec {
uuid: uuid.clone(),
docker_image,
memory_limit,
disk_limit,
cpu_limit,
startup_command,
environment,
ports,
data_path,
state: ServerState::Installing,
container_id: None,
runtime,
};
servers.insert(uuid.clone(), spec);
drop(servers);
// Install server in background
let docker = self.docker.clone();
let servers_ref = self.servers.clone();
tokio::spawn(async move {
if let Err(e) = Self::install_server(docker, servers_ref.clone(), &uuid).await {
error!(uuid = %uuid, error = %e, "Server installation failed");
let mut servers = servers_ref.write().await;
if let Some(spec) = servers.get_mut(&uuid) {
spec.state = ServerState::Error;
}
}
});
Ok(())
}
/// Recreate a server container with updated runtime configuration while preserving data files.
pub async fn update_server(
&self,
uuid: String,
docker_image: String,
memory_limit: i64,
disk_limit: i64,
cpu_limit: i32,
startup_command: String,
environment: HashMap<String, String>,
ports: Vec<PortMap>,
runtime: ServerRuntime,
) -> Result<ServerState, DaemonError> {
let existing = {
let servers = self.servers.read().await;
servers.get(&uuid).cloned()
};
if matches!(existing.as_ref().map(|spec| &spec.state), Some(ServerState::Installing)) {
return Err(DaemonError::InvalidStateTransition {
current: "installing".to_string(),
requested: "update".to_string(),
});
}
let runtime_state = self
.docker
.container_state(&uuid)
.await
.map_err(|e| DaemonError::Internal(format!("Failed to inspect container: {}", e)))?;
if existing.is_none() && runtime_state.is_none() {
return Err(DaemonError::ServerNotFound(uuid));
}
let should_restart = runtime_state
.as_deref()
.map(Self::is_running_state)
.unwrap_or_else(|| {
existing
.as_ref()
.map(|spec| matches!(spec.state, ServerState::Running | ServerState::Starting))
.unwrap_or(false)
});
let data_path = existing
.as_ref()
.map(|spec| spec.data_path.clone())
.unwrap_or_else(|| self.data_root.join(&uuid));
self.ensure_server_data_dir(&data_path).await?;
let mut desired_spec = ServerSpec {
uuid: uuid.clone(),
docker_image,
memory_limit,
disk_limit,
cpu_limit,
startup_command,
environment,
ports,
data_path,
state: ServerState::Stopped,
container_id: None,
runtime: runtime.clone(),
};
if runtime_state
.as_deref()
.map(Self::is_running_state)
.unwrap_or(false)
{
let previous_runtime = existing
.as_ref()
.map(|spec| spec.runtime.clone())
.unwrap_or_else(|| runtime.clone());
if let Err(stop_error) = self
.docker
.stop_container_graceful(
&uuid,
previous_runtime.stop_command.as_deref(),
previous_runtime.stop_timeout_seconds.unwrap_or(0),
)
.await
{
warn!(uuid = %uuid, error = %stop_error, "Graceful stop failed during server update, forcing kill");
self.docker.kill_container(&uuid).await.map_err(|e| {
DaemonError::Internal(format!("Failed to stop running container during update: {}", e))
})?;
}
}
if runtime_state.is_some() {
self.docker.remove_container(&uuid).await.map_err(|e| {
DaemonError::Internal(format!("Failed to remove existing container during update: {}", e))
})?;
}
self.docker.pull_image(&desired_spec.docker_image).await.map_err(|e| {
DaemonError::Internal(format!("Failed to pull updated image during server update: {}", e))
})?;
match self.docker.create_container(&desired_spec).await {
Ok(container_id) => {
desired_spec.container_id = Some(container_id);
}
Err(error) => {
desired_spec.state = ServerState::Error;
let mut servers = self.servers.write().await;
servers.insert(uuid.clone(), desired_spec);
return Err(DaemonError::Internal(format!(
"Failed to recreate container during update: {}",
error
)));
}
}
{
let mut servers = self.servers.write().await;
servers.insert(uuid.clone(), desired_spec);
}
if should_restart {
self.start_server(&uuid).await?;
return Ok(ServerState::Running);
}
Ok(ServerState::Stopped)
}
/// Install a server: pull image, create container.
async fn install_server(
docker: Arc<DockerManager>,
servers: Arc<RwLock<HashMap<String, ServerSpec>>>,
uuid: &str,
) -> Result<()> {
info!(uuid = %uuid, "Starting server installation");
let spec = {
let s = servers.read().await;
s.get(uuid).cloned().ok_or_else(|| anyhow::anyhow!("Server not found"))?
};
// Pull image
docker.pull_image(&spec.docker_image).await?;
// Create container
let container_id = docker.create_container(&spec).await?;
// Update state
let mut s = servers.write().await;
if let Some(server) = s.get_mut(uuid) {
server.container_id = Some(container_id);
server.state = ServerState::Stopped;
}
info!(uuid = %uuid, "Server installation complete");
Ok(())
}
/// Start a server.
pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> {
let mut managed = false;
let mut previous_state: Option<ServerState> = None;
{
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
// Recover from stale transitional state left by a previous failed start attempt.
if spec.state == ServerState::Starting {
warn!(uuid = %uuid, "Recovering stale starting state");
spec.state = ServerState::Stopped;
}
if !spec.can_transition_to(&ServerState::Starting) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "starting".to_string(),
});
}
previous_state = Some(spec.state.clone());
spec.state = ServerState::Starting;
managed = true;
}
}
if let Err(e) = self.docker.start_container(uuid).await {
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = previous_state.unwrap_or(ServerState::Error);
}
}
return Err(DaemonError::Internal(format!("Failed to start container: {}", e)));
}
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Running;
}
} else {
info!(uuid = %uuid, "Started container without managed runtime state");
}
Ok(())
}
/// Stop a server.
///
/// `stop_command` / `stop_timeout_seconds` override whatever was captured
/// when the container was created; pass `None` / `0` to use those defaults.
pub async fn stop_server(
&self,
uuid: &str,
stop_command: Option<&str>,
stop_timeout_seconds: i64,
) -> Result<(), DaemonError> {
let mut managed = false;
let mut previous_state: Option<ServerState> = None;
let mut spec_runtime = ServerRuntime::default();
{
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
// Recover from stale transitional state left by a previous failed stop attempt.
if spec.state == ServerState::Stopping {
warn!(uuid = %uuid, "Recovering stale stopping state");
spec.state = ServerState::Running;
}
if !spec.can_transition_to(&ServerState::Stopping) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "stopping".to_string(),
});
}
previous_state = Some(spec.state.clone());
spec_runtime = spec.runtime.clone();
spec.state = ServerState::Stopping;
managed = true;
}
}
let effective_command = stop_command
.map(str::trim)
.filter(|command| !command.is_empty())
.map(str::to_string)
.or_else(|| spec_runtime.stop_command.clone());
let effective_timeout = if stop_timeout_seconds > 0 {
stop_timeout_seconds
} else {
spec_runtime.stop_timeout_seconds.unwrap_or(0)
};
if let Err(e) = self
.docker
.stop_container_graceful(uuid, effective_command.as_deref(), effective_timeout)
.await
{
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = previous_state.unwrap_or(ServerState::Error);
}
}
return Err(DaemonError::Internal(format!("Failed to stop container: {}", e)));
}
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Stopped;
}
} else {
info!(uuid = %uuid, "Stopped container without managed runtime state");
}
Ok(())
}
/// Kill a server immediately.
pub async fn kill_server(&self, uuid: &str) -> Result<(), DaemonError> {
self.docker.kill_container(uuid).await.map_err(|e| {
DaemonError::Internal(format!("Failed to kill container: {}", e))
})?;
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = ServerState::Stopped;
}
Ok(())
}
/// Delete a server and clean up.
pub async fn delete_server(&self, uuid: &str) -> Result<(), DaemonError> {
// Remove container if it exists
if let Err(e) = self.docker.remove_container(uuid).await {
warn!(uuid = %uuid, error = %e, "Failed to remove container (may not exist)");
}
// Remove from state
let mut servers = self.servers.write().await;
servers.remove(uuid);
// Note: data directory is NOT deleted here for safety.
// Admin should explicitly clean up via API or manually.
info!(uuid = %uuid, "Server deleted");
Ok(())
}
/// Get the Docker manager Arc.
pub fn docker(&self) -> &Arc<DockerManager> {
&self.docker
}
/// Get the data root path.
pub fn data_root(&self) -> &PathBuf {
&self.data_root
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod state;
pub mod manager;
pub use state::{ServerSpec, ServerRuntime, PortMap};
pub use manager::ServerManager;
+111
View File
@@ -0,0 +1,111 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ServerState {
Installing,
Stopped,
Starting,
Running,
Stopping,
Error,
}
impl std::fmt::Display for ServerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Installing => write!(f, "installing"),
Self::Stopped => write!(f, "stopped"),
Self::Starting => write!(f, "starting"),
Self::Running => write!(f, "running"),
Self::Stopping => write!(f, "stopping"),
Self::Error => write!(f, "error"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortMap {
pub host_port: u16,
pub container_port: u16,
pub protocol: String, // "tcp" or "udp"
}
/// Per-game runtime knobs supplied by the panel. Mirrored into Docker labels so
/// they survive a daemon restart (see `docker::container`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ServerRuntime {
/// Mount point of the data directory inside the container. `None` means
/// "derive it from the image".
pub data_mount_path: Option<String>,
/// In-game command that shuts the server down cleanly (e.g. `stop`, `quit`).
pub stop_command: Option<String>,
/// Total budget for a graceful shutdown before the container gets killed.
pub stop_timeout_seconds: Option<i64>,
}
impl ServerRuntime {
pub fn from_request(
data_mount_path: String,
stop_command: String,
stop_timeout_seconds: i32,
) -> Self {
Self {
data_mount_path: non_empty(data_mount_path),
stop_command: non_empty(stop_command),
stop_timeout_seconds: if stop_timeout_seconds > 0 {
Some(stop_timeout_seconds as i64)
} else {
None
},
}
}
}
fn non_empty(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerSpec {
pub uuid: String,
pub docker_image: String,
pub memory_limit: i64, // bytes
pub disk_limit: i64, // bytes
pub cpu_limit: i32, // percentage (100 = 1 core)
pub startup_command: String,
pub environment: HashMap<String, String>,
pub ports: Vec<PortMap>,
pub data_path: PathBuf,
pub state: ServerState,
pub container_id: Option<String>,
#[serde(default)]
pub runtime: ServerRuntime,
}
impl ServerSpec {
/// Check if the server can transition to the requested state.
pub fn can_transition_to(&self, target: &ServerState) -> bool {
matches!(
(&self.state, target),
(ServerState::Installing, ServerState::Stopped)
| (ServerState::Installing, ServerState::Error)
| (ServerState::Stopped, ServerState::Starting)
| (ServerState::Starting, ServerState::Running)
| (ServerState::Starting, ServerState::Error)
| (ServerState::Running, ServerState::Stopping)
| (ServerState::Running, ServerState::Error)
| (ServerState::Stopping, ServerState::Stopped)
| (ServerState::Stopping, ServerState::Error)
| (ServerState::Error, ServerState::Starting)
| (ServerState::Error, ServerState::Stopped)
)
}
}
+43
View File
@@ -0,0 +1,43 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# --- Dependencies ---
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
COPY packages/ui/package.json packages/ui/
# pnpm creates no node_modules for a workspace package that has no
# dependencies of its own, and @source/shared has none. The COPY lines below
# name that path, so give them an empty directory to find instead of failing
# the build on a path pnpm never made.
RUN pnpm install --frozen-lockfile --prod=false && mkdir -p packages/shared/node_modules
# --- Build ---
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
COPY . .
ARG VITE_API_URL=/api
ENV VITE_API_URL=${VITE_API_URL}
RUN pnpm --filter @source/shared build && \
pnpm --filter @source/ui build && \
pnpm --filter @source/web build
# --- Production (nginx) ---
FROM nginx:alpine AS production
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
# 127.0.0.1 for the same reason as the API image: localhost resolves to
# ::1, where nginx is not listening.
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1/health || exit 1
CMD ["nginx", "-g", "daemon off;"]
+67
View File
@@ -0,0 +1,67 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
# Health check
location /health {
access_log off;
return 200 '{"status":"ok"}';
add_header Content-Type application/json;
}
# API proxy
location /api/ {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# A power action blocks until the game server has actually shut down.
# ARK saves its world for minutes, so the default 60s would 504 on a
# stop that is still progressing normally.
proxy_read_timeout 400s;
proxy_send_timeout 400s;
# File manager uploads.
client_max_body_size 128m;
}
# Socket.IO proxy (live console)
location /socket.io/ {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# An idle console must not be torn down every 60s.
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
# Static assets caching
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
+21 -1
View File
@@ -10,13 +10,33 @@
"lint": "eslint src/"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@source/shared": "workspace:*",
"@source/ui": "workspace:*",
"@tanstack/react-query": "^5.62.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.0",
"lucide-react": "^0.575.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.0",
"socket.io-client": "^4.8.0"
"socket.io-client": "^4.8.0",
"sonner": "^2.0.7",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "^19.0.0",
+116 -12
View File
@@ -1,5 +1,46 @@
import { useEffect } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route } from 'react-router';
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router';
import { Toaster } from 'sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { useAuthStore } from '@/stores/auth';
import { ErrorBoundary } from '@/components/error-boundary';
// Layouts
import { AppLayout } from '@/components/layout/app-layout';
import { ServerLayout } from '@/components/layout/server-layout';
// Auth pages
import { LoginPage } from '@/pages/auth/login';
import { RegisterPage } from '@/pages/auth/register';
// App pages
import { OrganizationsPage } from '@/pages/organizations/index';
import { DashboardPage } from '@/pages/dashboard/index';
import { ServersPage } from '@/pages/servers/index';
import { CreateServerPage } from '@/pages/servers/create';
import { NodesPage } from '@/pages/nodes/index';
import { NodeDetailPage } from '@/pages/nodes/detail';
import { MembersPage } from '@/pages/settings/members';
// Server pages
import { ConsolePage } from '@/pages/server/console';
import { FilesPage } from '@/pages/server/files';
import { BackupsPage } from '@/pages/server/backups';
import { SchedulesPage } from '@/pages/server/schedules';
import { ConfigPage } from '@/pages/server/config';
import { PluginsPage } from '@/pages/server/plugins';
import { PlayersPage } from '@/pages/server/players';
import { DatabasesPage } from '@/pages/server/databases';
import { ServerSettingsPage } from '@/pages/server/settings';
// Admin pages
import { AdminUsersPage } from '@/pages/admin/users';
import { AdminGamesPage } from '@/pages/admin/games';
import { AdminPluginsPage } from '@/pages/admin/plugins';
import { AdminNodesPage } from '@/pages/admin/nodes';
import { AdminAuditLogsPage } from '@/pages/admin/audit-logs';
import { AccountSecurityPage } from '@/pages/account/security';
const queryClient = new QueryClient({
defaultOptions: {
@@ -10,24 +51,87 @@ const queryClient = new QueryClient({
},
});
function AuthGuard() {
const { isAuthenticated, isLoading, fetchUser } = useAuthStore();
useEffect(() => {
fetchUser();
}, [fetchUser]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}
export function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold">GamePanel</h1>
<p className="mt-2 text-muted-foreground">Game Server Management Panel</p>
</div>
</div>
}
/>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Protected routes */}
<Route element={<AuthGuard />}>
<Route element={<AppLayout />}>
{/* Organizations */}
<Route path="/" element={<OrganizationsPage />} />
{/* Org-scoped routes */}
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
<Route path="/org/:orgId/servers" element={<ServersPage />} />
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
<Route path="/org/:orgId/settings" element={<Navigate to="members" replace />} />
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
{/* Account */}
<Route path="/account/security" element={<AccountSecurityPage />} />
{/* Server detail */}
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
<Route index element={<Navigate to="console" replace />} />
<Route path="console" element={<ConsolePage />} />
<Route path="files" element={<FilesPage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="databases" element={<DatabasesPage />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="backups" element={<BackupsPage />} />
<Route path="schedules" element={<SchedulesPage />} />
<Route path="players" element={<PlayersPage />} />
<Route path="settings" element={<ServerSettingsPage />} />
</Route>
{/* Admin */}
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/games" element={<AdminGamesPage />} />
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
<Route path="/admin/nodes" element={<AdminNodesPage />} />
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
</Route>
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
<Toaster position="bottom-right" richColors />
</TooltipProvider>
</QueryClientProvider>
</ErrorBoundary>
);
}
@@ -0,0 +1,70 @@
import { Component, type ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4 p-8">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-8 w-8 text-destructive" />
</div>
<div className="text-center">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
</div>
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<RefreshCw className="h-4 w-4" />
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium hover:bg-muted"
>
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,17 @@
import { Outlet } from 'react-router';
import { Sidebar } from './sidebar';
import { Header } from './header';
export function AppLayout() {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useNavigate } from 'react-router';
import { LogOut, User, Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuthStore } from '@/stores/auth';
import { useTheme } from '@/hooks/use-theme';
export function Header() {
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const { theme, toggleTheme } = useTheme();
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
<div />
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleTheme}>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<User className="h-4 w-4" />
{user?.username}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>{user?.email}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/account/security')}>
Account Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="h-4 w-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}
@@ -0,0 +1,100 @@
import { Outlet, useParams, Link, useLocation } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import {
Terminal,
FolderOpen,
Settings,
Calendar,
HardDrive,
Users,
Puzzle,
Settings2,
Database as DatabaseIcon,
} from 'lucide-react';
import { cn } from '@source/ui';
import { api } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
import { PowerControls } from '@/components/server/power-controls';
import { statusBadgeVariant } from '@/lib/utils';
interface ServerDetail {
id: string;
uuid: string;
name: string;
status: string;
nodeName: string;
nodeFqdn: string;
gameName: string;
gameSlug: string;
port: number;
memoryLimit: number;
diskLimit: number;
cpuLimit: number;
}
const tabs = [
{ label: 'Console', path: 'console', icon: Terminal },
{ label: 'Files', path: 'files', icon: FolderOpen },
{ label: 'Config', path: 'config', icon: Settings2 },
{ label: 'Databases', path: 'databases', icon: DatabaseIcon },
{ label: 'Plugins', path: 'plugins', icon: Puzzle },
{ label: 'Backups', path: 'backups', icon: HardDrive },
{ label: 'Schedules', path: 'schedules', icon: Calendar },
{ label: 'Players', path: 'players', icon: Users },
{ label: 'Settings', path: 'settings', icon: Settings },
];
export function ServerLayout() {
const { orgId, serverId } = useParams();
const location = useLocation();
const { data: server } = useQuery({
queryKey: ['server', orgId, serverId],
queryFn: () => api.get<ServerDetail>(`/organizations/${orgId}/servers/${serverId}`),
refetchInterval: 3_000,
});
const currentTab = location.pathname.split('/').pop();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{server?.name ?? 'Loading...'}</h1>
{server && <Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>}
</div>
{server && (
<p className="mt-1 text-sm text-muted-foreground">
{server.gameName} &middot; {server.nodeFqdn}:{server.port} &middot; {server.uuid}
</p>
)}
</div>
{server && <PowerControls serverId={server.id} orgId={orgId!} status={server.status} />}
</div>
<nav className="flex gap-1 border-b">
{tabs.map((tab) => {
const isActive = currentTab === tab.path;
return (
<Link
key={tab.path}
to={`/org/${orgId}/servers/${serverId}/${tab.path}`}
className={cn(
'flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</Link>
);
})}
</nav>
<Outlet context={{ server }} />
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { Link, useLocation, useParams } from 'react-router';
import {
Server,
LayoutDashboard,
Network,
Settings,
Users,
Shield,
Gamepad2,
Puzzle,
ScrollText,
ChevronLeft,
} from 'lucide-react';
import { cn } from '@source/ui';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useAuthStore } from '@/stores/auth';
interface NavItem {
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
export function Sidebar() {
const location = useLocation();
const { orgId } = useParams();
const user = useAuthStore((s) => s.user);
const orgNav: NavItem[] = orgId
? [
{ label: 'Dashboard', href: `/org/${orgId}/dashboard`, icon: LayoutDashboard },
{ label: 'Servers', href: `/org/${orgId}/servers`, icon: Server },
{ label: 'Nodes', href: `/org/${orgId}/nodes`, icon: Network },
{ label: 'Settings', href: `/org/${orgId}/settings/members`, icon: Settings },
]
: [];
const adminNav: NavItem[] = user?.isSuperAdmin
? [
{ label: 'Users', href: '/admin/users', icon: Users },
{ label: 'Games', href: '/admin/games', icon: Gamepad2 },
{ label: 'Plugins', href: '/admin/plugins', icon: Puzzle },
{ label: 'Nodes', href: '/admin/nodes', icon: Network },
{ label: 'Audit Logs', href: '/admin/audit-logs', icon: ScrollText },
]
: [];
return (
<div className="flex h-full w-64 flex-col border-r bg-card">
<div className="flex h-14 items-center gap-2 border-b px-4">
<Shield className="h-6 w-6 text-primary" />
<span className="text-lg font-bold">GamePanel</span>
</div>
<ScrollArea className="flex-1 py-2">
{orgId && (
<div className="px-3 py-2">
<div className="mb-1 flex items-center gap-1 px-2">
<Link to="/" className="text-xs text-muted-foreground hover:text-foreground">
<ChevronLeft className="inline h-3 w-3" /> Organizations
</Link>
</div>
<NavSection items={orgNav} currentPath={location.pathname} />
</div>
)}
{!orgId && (
<div className="px-3 py-2">
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">ORGANIZATIONS</p>
<Link to="/">
<Button variant="ghost" className="w-full justify-start gap-2">
<LayoutDashboard className="h-4 w-4" />
All Organizations
</Button>
</Link>
</div>
)}
{adminNav.length > 0 && (
<>
<Separator className="mx-3 my-2" />
<div className="px-3 py-2">
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">ADMIN</p>
<NavSection items={adminNav} currentPath={location.pathname} />
</div>
</>
)}
</ScrollArea>
</div>
);
}
function NavSection({ items, currentPath }: { items: NavItem[]; currentPath: string }) {
return (
<nav className="flex flex-col gap-1">
{items.map((item) => {
const isActive = currentPath === item.href || currentPath.startsWith(item.href + '/');
return (
<Link key={item.href} to={item.href}>
<Button
variant={isActive ? 'secondary' : 'ghost'}
className="w-full justify-start gap-2"
size="sm"
>
<item.icon className={cn('h-4 w-4', isActive && 'text-primary')} />
{item.label}
</Button>
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,122 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Square, RotateCcw, Skull } from 'lucide-react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from '@/components/ui/dialog';
interface PowerControlsProps {
serverId: string;
orgId: string;
status: string;
}
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
interface CachedServerDetail {
status: string;
[key: string]: unknown;
}
export function PowerControls({ serverId, orgId, status }: PowerControlsProps) {
const queryClient = useQueryClient();
const serverQueryKey = ['server', orgId, serverId] as const;
const powerMutation = useMutation({
mutationFn: (action: PowerAction) =>
api.post(`/organizations/${orgId}/servers/${serverId}/power`, { action }),
onMutate: (action) => {
const nextStatusByAction: Record<PowerAction, string> = {
start: 'starting',
stop: 'stopping',
restart: 'stopping',
kill: 'stopped',
};
queryClient.setQueryData<CachedServerDetail | undefined>(serverQueryKey, (current) => {
if (!current) return current;
return {
...current,
status: nextStatusByAction[action],
};
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: serverQueryKey });
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
},
});
const isRunning = status === 'running';
const isStopped = status === 'stopped' || status === 'error';
const isTransitioning = status === 'starting' || status === 'stopping' || status === 'installing';
return (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => powerMutation.mutate('start')}
disabled={!isStopped || powerMutation.isPending}
className="bg-green-600 hover:bg-green-700"
>
<Play className="h-4 w-4" />
Start
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => powerMutation.mutate('restart')}
disabled={!isRunning || powerMutation.isPending}
>
<RotateCcw className="h-4 w-4" />
Restart
</Button>
<Button
size="sm"
variant="outline"
onClick={() => powerMutation.mutate('stop')}
disabled={!isRunning || powerMutation.isPending}
>
<Square className="h-4 w-4" />
Stop
</Button>
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={isTransitioning && !isRunning}>
<Skull className="h-4 w-4" />
Kill
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Kill Server</DialogTitle>
<DialogDescription>
This will forcefully terminate the server process. Any unsaved data may be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button variant="destructive" onClick={() => powerMutation.mutate('kill')}>
Kill Server
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@source/ui';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow',
outline: 'text-foreground',
},
},
defaultVariants: { variant: 'default' },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+48
View File
@@ -0,0 +1,48 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@source/ui';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+54
View File
@@ -0,0 +1,54 @@
import * as React from 'react';
import { cn } from '@source/ui';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+97
View File
@@ -0,0 +1,97 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@source/ui';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,75 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '@source/ui';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-card p-1 text-card-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuGroup,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@source/ui';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
+20
View File
@@ -0,0 +1,20 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@source/ui';
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@source/ui';
const Progress = React.forwardRef<
React.ComponentRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
@@ -0,0 +1,28 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@source/ui';
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation="vertical"
className="flex touch-none select-none transition-colors h-full w-2.5 border-l border-l-transparent p-[1px]"
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
export { ScrollArea };
+88
View File
@@ -0,0 +1,88 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { ChevronDown, ChevronUp, Check } from 'lucide-react';
import { cn } from '@source/ui';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-card text-card-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@source/ui';
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+52
View File
@@ -0,0 +1,52 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@source/ui';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@source/ui';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+25
View File
@@ -0,0 +1,25 @@
import { useCallback, useSyncExternalStore } from 'react';
function getTheme(): 'dark' | 'light' {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useTheme() {
const theme = useSyncExternalStore(subscribe, getTheme);
const toggleTheme = useCallback(() => {
const next = getTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.classList.toggle('dark', next === 'dark');
localStorage.setItem('theme', next);
listeners.forEach((l) => l());
}, []);
return { theme, toggleTheme };
}
+16
View File
@@ -49,7 +49,23 @@
* {
@apply border-border;
}
html {
min-height: 100%;
}
body {
@apply bg-background text-foreground;
min-height: 100vh;
background-image:
radial-gradient(circle at 0% 0%, hsl(var(--primary) / 0.18), transparent 34%),
radial-gradient(circle at 88% 10%, hsl(var(--ring) / 0.12), transparent 28%),
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.72) 100%);
background-attachment: fixed;
background-repeat: no-repeat;
}
#root {
min-height: 100vh;
}
}

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