fix: something

This commit is contained in:
hibna
2026-08-02 20:26:54 +03:00
parent 5215560ede
commit 11924416a9
38 changed files with 2198 additions and 466 deletions
+11
View File
@@ -38,6 +38,17 @@ WEB_PORT=80
# --- Daemon --- # --- Daemon ---
DAEMON_CONFIG=/etc/gamepanel/config.yml DAEMON_CONFIG=/etc/gamepanel/config.yml
DAEMON_GRPC_PORT=50051 DAEMON_GRPC_PORT=50051
DAEMON_TOKEN=CHANGE_ME_GENERATE_A_SECURE_TOKEN
# Host directories for game server files and backups. The daemon hands these
# exact paths to the host Docker engine when creating game containers, so they
# must exist on the host — not inside the daemon container.
DAEMON_DATA_PATH=/var/lib/gamepanel/servers
DAEMON_BACKUP_PATH=/var/lib/gamepanel/backups
# --- Managed config persistence ---
# How long the panel keeps restoring panel-managed config files after a start.
# Steam images can re-validate for a long time before overwriting them.
MANAGED_CONFIG_SUSTAIN_MS=1800000
# --- CDN (Plugin Artifacts) --- # --- CDN (Plugin Artifacts) ---
CDN_BASE_URL=https://cdn.hibna.com.tr CDN_BASE_URL=https://cdn.hibna.com.tr
+4 -2
View File
@@ -23,8 +23,10 @@ Thumbs.db
apps/daemon/target/ apps/daemon/target/
# Database # Database
packages/database/drizzle/* # Hand-written data migrations in drizzle/*.sql are part of the repo — the
!packages/database/drizzle/0007_satisfactory_game.sql # schema itself is applied with `drizzle-kit push`, so only drizzle-kit's local
# snapshot files are noise.
packages/database/drizzle/meta/*_snapshot.json
# Common JS/TS # Common JS/TS
coverage/ coverage/
+129 -94
View File
@@ -65,19 +65,21 @@ docker compose -f docker-compose.dev.yml up -d
### 1.4 Database Setup ### 1.4 Database Setup
```bash ```bash
# Generate migration files (if schema changed) # Sync the schema from packages/database/src/schema, then apply the
pnpm db:generate # hand-written data migrations in packages/database/drizzle/*.sql
# Apply migrations to create all tables
pnpm db:migrate pnpm db:migrate
# Seed admin user and default games # Seed admin user and default games
pnpm db:seed pnpm db:seed
``` ```
All three steps are idempotent, so re-running them after a `git pull` is the
normal way to pick up schema and default-game changes.
After seeding, you'll have: After seeding, you'll have:
- **Admin account**: `admin@gamepanel.local` / `admin123` - **Admin account**: `admin@gamepanel.local` / `admin123`
- **Games**: Minecraft Java, CS2, Minecraft Bedrock, Terraria, Rust - **Games**: Minecraft Java & Bedrock, CS2, Terraria, Rust, Satisfactory,
FiveM, ARK: Survival Evolved
### 1.5 Start Development Servers ### 1.5 Start Development Servers
@@ -129,123 +131,156 @@ cargo build --release # Production build
## 2. Docker Production Deployment ## 2. Docker Production Deployment
### 2.1 Prepare Environment The whole panel comes up with two commands. TLS and domain handling are
deliberately **not** included — the panel serves plain HTTP and you put your own
reverse proxy in front of it (see 2.6).
### 2.1 Install
```bash ```bash
git clone https://github.com/your-org/source-gamepanel.git git clone https://github.com/your-org/source-gamepanel.git
cd source-gamepanel cd source-gamepanel
cp .env.example .env ./scripts/install.sh
```
Edit `.env` with production values:
```env
# REQUIRED — Generate unique secrets for each!
JWT_SECRET=<generate-with-openssl-rand-hex-64>
JWT_REFRESH_SECRET=<generate-another-secret>
# Database
DB_USER=gamepanel
DB_PASSWORD=<strong-random-password>
DB_NAME=gamepanel
# Redis
REDIS_PASSWORD=<strong-random-password>
# Networking
CORS_ORIGIN=https://panel.yourdomain.com
WEB_PORT=80
API_PORT=3000
# Rate limiting
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW_MS=60000
```
### 2.2 Configure Daemon
Edit `daemon-config.yml`:
```yaml
api_url: "http://api:3000"
node_token: "<generate-a-secure-token>"
grpc_port: 50051
data_path: "/var/lib/gamepanel/servers"
backup_path: "/var/lib/gamepanel/backups"
docker:
socket: "/var/run/docker.sock"
network: "gamepanel_nw"
network_subnet: "172.18.0.0/16"
```
### 2.3 Build and Start
```bash
# Build and start all services
docker compose up -d --build docker compose up -d --build
``` ```
This starts 5 services: `scripts/install.sh` generates `.env` with fresh secrets, writes a
`daemon-config.yml` with a matching node token, and creates the host data
directories. It never overwrites files that already exist, so it is safe to
re-run.
Then open `http://<server-ip>:80` and sign in with
`admin@gamepanel.local` / `admin123` — change the password immediately.
### 2.2 What gets started
| Service | Port | Description | | Service | Port | Description |
|---------|------|-------------| |---------|------|-------------|
| `postgres` | 5432 | PostgreSQL database | | `postgres` | internal | PostgreSQL database |
| `redis` | 6379 | Rate limiting & cache | | `redis` | internal | Rate limiting & cache |
| `api` | 3000 | Fastify REST API | | `migrate` | — | Applies the schema + seed, then exits |
| `web` | 80 | nginx + React SPA | | `api` | internal | Fastify REST API |
| `daemon` | 50051 | Rust gRPC daemon | | `web` | `WEB_PORT` (80) | nginx + React SPA, proxies `/api` and `/socket.io` |
| `daemon` | `DAEMON_GRPC_PORT` (50051) | Rust gRPC daemon |
### 2.4 Initialize Database Only `web` and `daemon` publish ports. Postgres, Redis and the API stay on the
internal Compose network.
```bash The `migrate` service runs on every `docker compose up`; all three of its steps
# Run migrations (`drizzle-kit push`, the data migrations, the seed) are idempotent.
docker compose exec api node -e "
import('drizzle-kit').then(m => console.log('Use drizzle-kit migrate'))
"
# Or use the pnpm scripts with the container's DATABASE_URL ### 2.3 Register the node
docker compose exec api sh -c 'cd /app && node apps/api/dist/index.js'
```
For the initial setup, the easiest approach is: In the panel, create a node with:
```bash | Field | Value |
# Run migrations from your host machine pointed at the Docker PostgreSQL |-------|-------|
DATABASE_URL=postgresql://gamepanel:<your-password>@localhost:5432/gamepanel pnpm db:migrate | FQDN | `host.docker.internal` (or the host's IP/hostname) |
DATABASE_URL=postgresql://gamepanel:<your-password>@localhost:5432/gamepanel pnpm db:seed | gRPC port | the `DAEMON_GRPC_PORT` from `.env` |
``` | Daemon token | the `DAEMON_TOKEN` from `.env` |
### 2.4 Where game server files live
`DAEMON_DATA_PATH` in `.env` (default `/var/lib/gamepanel/servers`) is a **host**
directory. The daemon runs in a container but creates game containers through
the host's Docker socket, so their bind mounts are resolved by the host, not by
the daemon container.
That is why the same path is passed twice — once as the daemon's own bind mount
and once as `DAEMON_HOST_DATA_PATH`. If you change `DAEMON_DATA_PATH`, both
follow automatically. Do not replace the bind mount with a named volume: the
daemon and the game servers would then read and write two different
directories, and files edited in the panel would never reach the game.
### 2.5 Verify ### 2.5 Verify
```bash ```bash
# Check all services are healthy
docker compose ps docker compose ps
# Test API health
curl http://localhost:3000/api/health
# {"status":"ok","timestamp":"2025-..."}
# Test web
curl -s http://localhost | head -5
# <!DOCTYPE html>...
```
### 2.6 Monitoring
```bash
# View logs
docker compose logs -f api docker compose logs -f api
docker compose logs -f daemon docker compose logs -f daemon
docker compose logs -f web
# Restart a service curl -s http://localhost/api/health
docker compose restart api # {"status":"ok","timestamp":"..."}
```
# Update to latest ### 2.6 TLS, domain and reverse proxy
The panel intentionally ships without certificate handling. Terminate TLS in
whatever proxy you already run and forward to `WEB_PORT`. WebSocket upgrades
must be forwarded too, otherwise the live console will not connect.
Set `CORS_ORIGIN` in `.env` to the exact origin users open in the browser, then
`docker compose up -d` to apply it.
Caddy (`Caddyfile`):
```
panel.example.com {
reverse_proxy 127.0.0.1:80
}
```
nginx:
```nginx
server {
listen 443 ssl;
server_name panel.example.com;
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
If the proxy runs on the same host, bind the panel to loopback only by setting
`WEB_PORT=127.0.0.1:8080` in `.env`.
### 2.7 Updating
```bash
git pull git pull
docker compose up -d --build docker compose up -d --build
``` ```
The `migrate` service applies schema and seed changes on every start, so no
extra step is needed.
### 2.8 Upgrading from a pre-`install.sh` deployment
Older `docker-compose.yml` versions stored the daemon's server directory in a
named volume (`daemon_data`). That never matched what the game containers
actually used: their bind mounts were resolved by the host, so the real game
files ended up in `/var/lib/gamepanel/servers` on the host while the panel read
and wrote the named volume. Editing a config in the panel appeared to work and
then had no effect, and files could look like they reset themselves.
The compose file now bind-mounts the host directory directly, so after
upgrading, the panel sees the same files the game servers do. Nothing needs to
be moved — the game files were already on the host.
If you had put files into the old named volume through the panel and want them
back, copy them out before removing it:
```bash
docker run --rm -v gamepanel_daemon_data:/from -v /var/lib/gamepanel/servers:/to alpine sh -c 'cp -an /from/. /to/'
docker volume rm gamepanel_daemon_data gamepanel_daemon_backups
```
Also note that `postgres`, `redis` and `api` no longer publish host ports; only
`web` and `daemon` do. If you were proxying straight to `API_PORT`, point your
proxy at `WEB_PORT` instead — nginx forwards `/api` and `/socket.io`.
--- ---
## 3. Manual Production Setup (Ubuntu 22.04+) ## 3. Manual Production Setup (Ubuntu 22.04+)
+18 -10
View File
@@ -159,8 +159,12 @@ source-gamepanel/
| Rust | `didstopia/rust-server` | 28015 | — | — | | Rust | `didstopia/rust-server` | 28015 | — | — |
| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — | | Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — |
| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — | | FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — |
| ARK: Survival Evolved | `hermsi/ark-server` | 7777/udp + 7778/udp + 27015/udp + 27020/tcp | `GameUserSettings.ini`, `Game.ini` | — |
Many games can be added with a database seed entry alone. Some images still need small daemon-side tweaks for mount paths, port protocols, or config parsing. Most games only need a database seed entry: the container mount point, the
in-game stop command and the shutdown budget are all columns on `games`, so no
daemon change is required for a new image. Games whose process ignores stdin
(Source engine, ARK) get their console commands over RCON automatically.
--- ---
@@ -258,19 +262,23 @@ Open `http://localhost:5173` — login with `admin@gamepanel.local` / `admin123`
## Production Deployment ## Production Deployment
```bash ```bash
# Configure environment git clone https://github.com/your-org/source-gamepanel.git
cp .env.example .env cd source-gamepanel
# Edit .env with production values (strong JWT secrets, real DB passwords)
# Deploy full stack # Generates .env with fresh secrets + daemon-config.yml, creates data dirs
./scripts/install.sh
# Builds and starts everything; schema migration and seeding run automatically
docker compose up -d --build docker compose up -d --build
# Run migrations inside the API container
docker compose exec api node -e "..."
# Or connect to the DB directly and run drizzle-kit migrate
``` ```
The web service is exposed on port 80 with nginx handling SPA routing and API proxying. Open `http://<server-ip>` and sign in with `admin@gamepanel.local` / `admin123`.
The panel serves plain HTTP on `WEB_PORT` (default 80) and does **not** manage
TLS or domains — put your own reverse proxy in front of it and set
`CORS_ORIGIN` to the origin users actually open. See
[INSTALLATION.md](INSTALLATION.md#26-tls-domain-and-reverse-proxy) for Caddy and
nginx examples.
--- ---
+17
View File
@@ -22,6 +22,23 @@ RUN pnpm --filter @source/shared build && \
pnpm --filter @source/database build && \ pnpm --filter @source/database build && \
pnpm --filter @source/api build pnpm --filter @source/api build
# --- Migrate + seed (one-shot) ---
# Schema comes from `drizzle-kit push` against src/schema, then the repo's
# data migrations, then the idempotent seed. All three are safe to re-run, so
# this container can start on every `docker compose up`.
FROM base AS migrate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared ./packages/shared
COPY packages/database ./packages/database
WORKDIR /app/packages/database
CMD ["sh", "-c", "pnpm exec drizzle-kit push --force && pnpm exec tsx src/migrate.ts && pnpm exec tsx src/seed.ts"]
# --- Production --- # --- Production ---
FROM node:20-alpine AS production FROM node:20-alpine AS production
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
-178
View File
@@ -1,178 +0,0 @@
import {
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
hostname "GamePanel CS2 Server" // Set server hostname
sv_cheats 0 // Enable or disable cheats
sv_hibernate_when_empty 0 // Disable server hibernation
// Passwords
rcon_password "" // Set rcon password
sv_password "" // Set server password
// CSTV
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
tv_chatgroupsize 0 // Set the default chat group size
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
tv_debug 0 // CSTV debug info.
tv_delay 0 // CSTV broadcast delay in seconds
tv_delaymapchange 1 // Delays map change until broadcast is complete
tv_deltacache 2 // Enable delta entity bit stream cache
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
tv_maxclients 10 // Maximum client number on CSTV server.
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
tv_overridemaster 0 // Overrides the CSTV master root address.
tv_port 27020 // Host SourceTV port
tv_password "changeme" // CSTV password for clients
tv_relaypassword "changeme" // CSTV password for relay proxies
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
tv_timeout 60 // CSTV connection timeout in seconds.
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
tv_transmitall 1 // Transmit all entities (not only director view)
// Logs
log on // Turns logging 'on' or 'off', defaults to 'on'
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
`;
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
// CS2 Server Config
// ============================================
// ---- Sunucu Bilgileri ----
hostname "SourceGamePanel CS2 Server"
sv_password ""
rcon_password "changeme"
sv_cheats 0
// ---- Topluluk Sunucu Gorunurlugu ----
sv_region 3
sv_tags "competitive,community"
sv_lan 0
sv_steamgroup ""
sv_steamgroup_exclusive 0
// ---- Performans ----
sv_maxrate 0
sv_minrate 64000
sv_max_queries_sec 5
sv_max_queries_window 30
sv_parallel_sendsnapshot 1
net_maxroutable 1200
// ---- Baglanti ----
sv_maxclients 16
sv_timeout 60
// ---- GOTV (Tamamen Kapali) ----
tv_enable 0
tv_autorecord 0
tv_delay 0
tv_maxclients 0
tv_port 0
// ---- Loglama ----
log on
mp_logmoney 0
mp_logdetail 0
mp_logdetail_items 0
sv_logfile 1
// ---- Genel Oyun Ayarlari ----
mp_autokick 0
sv_allow_votes 0
sv_alltalk 0
sv_deadtalk 1
sv_voiceenable 1
`;
function normalizePath(path: string): string {
const normalized = path
.trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/\/{2,}/g, '/');
return normalized;
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizeComparableContent(content: string): string {
return content.replace(/\r\n/g, '\n').trim();
}
export function isManagedCs2ServerConfigPath(gameSlug: string, path: string): boolean {
return (
gameSlug.trim().toLowerCase() === 'cs2' &&
normalizePath(path) === CS2_SERVER_CFG_PATH
);
}
export async function readManagedCs2ServerConfig(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<string> {
try {
const persisted = await daemonReadFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH);
return persisted.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
try {
const current = await daemonReadFile(node, serverUuid, CS2_SERVER_CFG_PATH);
const content = current.data.toString('utf8');
const nextContent =
normalizeComparableContent(content) === normalizeComparableContent(LEGACY_IMAGE_CS2_SERVER_CFG)
? DEFAULT_CS2_SERVER_CFG
: content;
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, nextContent);
return nextContent;
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG);
return DEFAULT_CS2_SERVER_CFG;
}
export async function writeManagedCs2ServerConfig(
node: DaemonNodeConnection,
serverUuid: string,
content: string | Buffer,
): Promise<void> {
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, content);
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
}
export async function reapplyManagedCs2ServerConfig(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<void> {
const content = await readManagedCs2ServerConfig(node, serverUuid);
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
}
+39 -3
View File
@@ -25,6 +25,9 @@ export interface DaemonCreateServerRequest {
environment: Record<string, string>; environment: Record<string, string>;
ports: DaemonPortMapping[]; ports: DaemonPortMapping[];
install_plugin_urls: string[]; install_plugin_urls: string[];
data_path: string;
stop_command: string;
stop_timeout_seconds: number;
} }
export interface DaemonUpdateServerRequest { export interface DaemonUpdateServerRequest {
@@ -36,6 +39,16 @@ export interface DaemonUpdateServerRequest {
startup_command: string; startup_command: string;
environment: Record<string, string>; environment: Record<string, string>;
ports: DaemonPortMapping[]; ports: DaemonPortMapping[];
data_path: string;
stop_command: string;
stop_timeout_seconds: number;
}
export interface DaemonPowerOptions {
/** In-game shutdown command; lets the daemon skip the SIGTERM wait entirely. */
stopCommand?: string | null;
/** Total graceful-shutdown budget in seconds. */
stopTimeoutSeconds?: number | null;
} }
interface DaemonServerResponse { interface DaemonServerResponse {
@@ -216,7 +229,12 @@ interface DaemonServiceClient extends grpc.Client {
callback: UnaryCallback<EmptyResponse>, callback: UnaryCallback<EmptyResponse>,
): void; ): void;
setPowerState( setPowerState(
request: { uuid: string; action: number }, request: {
uuid: string;
action: number;
stop_command: string;
stop_timeout_seconds: number;
},
metadata: grpc.Metadata, metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>, callback: UnaryCallback<EmptyResponse>,
): void; ): void;
@@ -437,6 +455,7 @@ function toBuffer(data: Uint8Array | Buffer): Buffer {
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000; const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
const DEFAULT_RPC_TIMEOUT_MS = 20_000; const DEFAULT_RPC_TIMEOUT_MS = 20_000;
const POWER_RPC_TIMEOUT_MS = 45_000; const POWER_RPC_TIMEOUT_MS = 45_000;
const MAX_POWER_RPC_TIMEOUT_MS = 360_000;
interface DaemonRequestTimeoutOptions { interface DaemonRequestTimeoutOptions {
connectTimeoutMs?: number; connectTimeoutMs?: number;
@@ -641,18 +660,35 @@ export async function daemonSetPowerState(
node: DaemonNodeConnection, node: DaemonNodeConnection,
serverUuid: string, serverUuid: string,
action: PowerAction, action: PowerAction,
options: DaemonPowerOptions = {},
): Promise<void> { ): Promise<void> {
const stopTimeoutSeconds = Number(options.stopTimeoutSeconds) > 0
? Math.floor(Number(options.stopTimeoutSeconds))
: 0;
// The daemon waits out the shutdown before replying, so the RPC deadline has
// to outlive the game's own budget (ARK saves its world for minutes).
const rpcTimeoutMs =
action === 'stop' || action === 'restart'
? Math.min(Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS), MAX_POWER_RPC_TIMEOUT_MS)
: POWER_RPC_TIMEOUT_MS;
const client = createClient(node); const client = createClient(node);
try { try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>( await callUnary<EmptyResponse>(
(callback) => (callback) =>
client.setPowerState( client.setPowerState(
{ uuid: serverUuid, action: POWER_ACTIONS[action] }, {
uuid: serverUuid,
action: POWER_ACTIONS[action],
stop_command: options.stopCommand?.trim() ?? '',
stop_timeout_seconds: stopTimeoutSeconds,
},
getMetadata(node.daemonToken), getMetadata(node.daemonToken),
callback, callback,
), ),
POWER_RPC_TIMEOUT_MS, rpcTimeoutMs,
); );
} finally { } finally {
client.close(); client.close();
+387
View File
@@ -0,0 +1,387 @@
import type { FastifyInstance } from 'fastify';
import {
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
/**
* Some game images run a SteamCMD `app_update ... validate` on every container
* start, which rewrites config files that ship with the game back to their
* stock contents. The panel therefore keeps its own copy of every managed
* config file in a hidden sidecar next to the real one, and restores the real
* file whenever the game resets it.
*/
export interface ManagedConfigFile {
/** Path of the real file, relative to the server data directory. */
path: string;
/** Sidecar holding the panel's copy of record. */
shadowPath: string;
/** Base name of the sidecar, so the file browser can hide it. */
shadowFileName: string;
/** Written when neither the real file nor the sidecar exists yet. */
defaultContent: string;
/**
* Stock contents shipped by the image. When the sidecar is adopted from an
* existing install, contents matching one of these are replaced by
* `defaultContent` instead of being preserved.
*/
imageDefaults: string[];
}
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
hostname "GamePanel CS2 Server" // Set server hostname
sv_cheats 0 // Enable or disable cheats
sv_hibernate_when_empty 0 // Disable server hibernation
// Passwords
rcon_password "" // Set rcon password
sv_password "" // Set server password
// CSTV
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
tv_chatgroupsize 0 // Set the default chat group size
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
tv_debug 0 // CSTV debug info.
tv_delay 0 // CSTV broadcast delay in seconds
tv_delaymapchange 1 // Delays map change until broadcast is complete
tv_deltacache 2 // Enable delta entity bit stream cache
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
tv_maxclients 10 // Maximum client number on CSTV server.
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
tv_overridemaster 0 // Overrides the CSTV master root address.
tv_port 27020 // Host SourceTV port
tv_password "changeme" // CSTV password for clients
tv_relaypassword "changeme" // CSTV password for relay proxies
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
tv_timeout 60 // CSTV connection timeout in seconds.
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
tv_transmitall 1 // Transmit all entities (not only director view)
// Logs
log on // Turns logging 'on' or 'off', defaults to 'on'
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
`;
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
// CS2 Server Config
// ============================================
// ---- Sunucu Bilgileri ----
hostname "SourceGamePanel CS2 Server"
sv_password ""
rcon_password "changeme"
sv_cheats 0
// ---- Topluluk Sunucu Gorunurlugu ----
sv_region 3
sv_tags "competitive,community"
sv_lan 0
sv_steamgroup ""
sv_steamgroup_exclusive 0
// ---- Performans ----
sv_maxrate 0
sv_minrate 64000
sv_max_queries_sec 5
sv_max_queries_window 30
sv_parallel_sendsnapshot 1
net_maxroutable 1200
// ---- Baglanti ----
sv_maxclients 16
sv_timeout 60
// ---- GOTV (Tamamen Kapali) ----
tv_enable 0
tv_autorecord 0
tv_delay 0
tv_maxclients 0
tv_port 0
// ---- Loglama ----
log on
mp_logmoney 0
mp_logdetail 0
mp_logdetail_items 0
sv_logfile 1
// ---- Genel Oyun Ayarlari ----
mp_autokick 0
sv_allow_votes 0
sv_alltalk 0
sv_deadtalk 1
sv_voiceenable 1
`;
const MANAGED_CONFIG_FILES: Record<string, ManagedConfigFile[]> = {
cs2: [
{
path: CS2_SERVER_CFG_PATH,
shadowPath: CS2_PERSISTED_SERVER_CFG_PATH,
shadowFileName: CS2_PERSISTED_SERVER_CFG_FILE,
defaultContent: DEFAULT_CS2_SERVER_CFG,
imageDefaults: [LEGACY_IMAGE_CS2_SERVER_CFG],
},
],
};
function normalizePath(path: string): string {
return path
.trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/\/{2,}/g, '/');
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizeComparableContent(content: string): string {
return content.replace(/\r\n/g, '\n').trim();
}
export function managedConfigFilesForGame(gameSlug: string): ManagedConfigFile[] {
return MANAGED_CONFIG_FILES[gameSlug.trim().toLowerCase()] ?? [];
}
/** The managed file a request path refers to, or `null` if it is not managed. */
export function managedConfigFileFor(
gameSlug: string,
path: string,
): ManagedConfigFile | null {
const normalized = normalizePath(path);
return (
managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null
);
}
export function isManagedConfigShadowFile(gameSlug: string, fileName: string): boolean {
const normalized = fileName.trim();
return managedConfigFilesForGame(gameSlug).some(
(file) => file.shadowFileName === normalized,
);
}
/**
* Read the panel's copy of a managed config file, adopting whatever is on disk
* the first time around.
*/
export async function readManagedConfig(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
): Promise<string> {
try {
const persisted = await daemonReadFile(node, serverUuid, file.shadowPath);
return persisted.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
try {
const current = await daemonReadFile(node, serverUuid, file.path);
const content = current.data.toString('utf8');
const isStockContent = file.imageDefaults.some(
(stock) => normalizeComparableContent(stock) === normalizeComparableContent(content),
);
const nextContent = isStockContent ? file.defaultContent : content;
await daemonWriteFile(node, serverUuid, file.shadowPath, nextContent);
return nextContent;
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
await daemonWriteFile(node, serverUuid, file.shadowPath, file.defaultContent);
return file.defaultContent;
}
/** Write a managed config file, keeping the panel's copy in sync. */
export async function writeManagedConfig(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
content: string | Buffer,
): Promise<void> {
await daemonWriteFile(node, serverUuid, file.shadowPath, content);
await daemonWriteFile(node, serverUuid, file.path, content);
}
// === Drift watcher ===
/**
* How long to keep watching after a start. This has to outlast the image's own
* update/validate step — for CS2 that is a multi-gigabyte SteamCMD run that can
* easily take 10+ minutes on a cold cache, and it rewrites `server.cfg` when it
* finishes. Watching for only a minute is why edited configs kept coming back.
*/
const SUSTAIN_WINDOW_MS = Number(process.env.MANAGED_CONFIG_SUSTAIN_MS) || 30 * 60_000;
const FAST_INTERVAL_MS = 5_000;
const SLOW_INTERVAL_MS = 20_000;
const FAST_PHASE_MS = 2 * 60_000;
/** Consecutive drift-free polls needed before the watcher stops early. */
const REQUIRED_STABLE_ROUNDS = 6;
/** Never stop early before this much of the window has elapsed. */
const MIN_WATCH_MS = 3 * 60_000;
/** One watcher per server; a newer start supersedes the one already running. */
const activeWatchers = new Map<string, symbol>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function restoreDriftedFile(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
): Promise<boolean> {
const expected = await readManagedConfig(node, serverUuid, file);
let live: string | null = null;
try {
const current = await daemonReadFile(node, serverUuid, file.path);
live = current.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
if (live !== null && normalizeComparableContent(live) === normalizeComparableContent(expected)) {
return false;
}
await daemonWriteFile(node, serverUuid, file.path, expected);
return true;
}
/** Restore every managed config file for a game to the panel's copy. */
export async function reapplyManagedConfigs(
node: DaemonNodeConnection,
serverUuid: string,
gameSlug: string,
): Promise<void> {
for (const file of managedConfigFilesForGame(gameSlug)) {
await restoreDriftedFile(node, serverUuid, file);
}
}
/**
* Watch a server's managed config files after a start and put the panel's
* version back whenever the game overwrites it.
*
* `isServerActive` lets the caller abort once the server leaves the running
* state, so a stopped server never gets its files rewritten behind its back.
*/
export function sustainManagedConfigsAfterStart(
app: FastifyInstance,
options: {
node: DaemonNodeConnection;
serverId: string;
serverUuid: string;
gameSlug: string;
isServerActive: () => Promise<boolean>;
},
): void {
const files = managedConfigFilesForGame(options.gameSlug);
if (files.length === 0) return;
const token = Symbol(options.serverId);
activeWatchers.set(options.serverId, token);
void (async () => {
const startedAt = Date.now();
const deadline = startedAt + SUSTAIN_WINDOW_MS;
let stableRounds = 0;
try {
while (Date.now() < deadline) {
const elapsed = Date.now() - startedAt;
await sleep(elapsed < FAST_PHASE_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
if (activeWatchers.get(options.serverId) !== token) return;
let active: boolean;
try {
active = await options.isServerActive();
} catch (error) {
app.log.warn(
{ error, serverId: options.serverId },
'Managed config watcher could not read server status',
);
continue;
}
if (!active) {
app.log.debug(
{ serverId: options.serverId },
'Managed config watcher stopping: server is no longer running',
);
return;
}
let drifted = false;
for (const file of files) {
try {
if (await restoreDriftedFile(options.node, options.serverUuid, file)) {
drifted = true;
app.log.info(
{
serverId: options.serverId,
serverUuid: options.serverUuid,
gameSlug: options.gameSlug,
path: file.path,
},
'Restored managed config file after the game reset it',
);
}
} catch (error) {
app.log.warn(
{
error,
serverId: options.serverId,
serverUuid: options.serverUuid,
path: file.path,
},
'Failed to restore managed config file',
);
}
}
stableRounds = drifted ? 0 : stableRounds + 1;
if (
stableRounds >= REQUIRED_STABLE_ROUNDS &&
Date.now() - startedAt >= MIN_WATCH_MS
) {
return;
}
}
} finally {
if (activeWatchers.get(options.serverId) === token) {
activeWatchers.delete(options.serverId);
}
}
})();
}
+1 -1
View File
@@ -22,7 +22,7 @@ import {
CS2_PERSISTED_SERVER_CFG_PATH, CS2_PERSISTED_SERVER_CFG_PATH,
CS2_SERVER_CFG_PATH, CS2_SERVER_CFG_PATH,
DEFAULT_CS2_SERVER_CFG, DEFAULT_CS2_SERVER_CFG,
} from './cs2-server-config.js'; } from './managed-config.js';
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024; const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000; const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
+14 -2
View File
@@ -253,8 +253,11 @@ export default fp(async (app: FastifyInstance) => {
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id }, { error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
'Failed to send console command', 'Failed to send console command',
); );
socket.emit('server:console:output', { line: '[error] Failed to send command' }); // The daemon explains *why* (server not running, no RCON password, …) —
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Failed to send command' }; // showing that beats a generic failure the user cannot act on.
const reason = daemonErrorReason(error);
socket.emit('server:console:output', { line: `[error] ${reason}` });
const ack: ConsoleCommandAck = { requestId, ok: false, error: reason };
socket.emit('server:console:command:ack', ack); socket.emit('server:console:command:ack', ack);
} }
}); });
@@ -277,6 +280,15 @@ export default fp(async (app: FastifyInstance) => {
}); });
}); });
/** Strip the gRPC status prefix so the console shows the daemon's own wording. */
function daemonErrorReason(error: unknown): string {
const raw = error instanceof Error ? error.message.trim() : '';
if (!raw) return 'Failed to send command';
const withoutStatus = raw.replace(/^\d+\s+[A-Z_]+:\s*/, '').trim();
return withoutStatus || 'Failed to send command';
}
async function hasConsolePermission( async function hasConsolePermission(
app: FastifyInstance, app: FastifyInstance,
user: AccessTokenPayload, user: AccessTokenPayload,
+2
View File
@@ -244,6 +244,8 @@ export default async function adminRoutes(app: FastifyInstance) {
defaultPort: number; defaultPort: number;
startupCommand: string; startupCommand: string;
stopCommand?: string; stopCommand?: string;
stopTimeoutSeconds?: number;
containerDataPath?: string;
configFiles?: unknown[]; configFiles?: unknown[];
environmentVars?: unknown[]; environmentVars?: unknown[];
automationRules?: unknown[]; automationRules?: unknown[];
+4
View File
@@ -8,6 +8,8 @@ export const CreateGameSchema = {
defaultPort: Type.Number({ minimum: 1, maximum: 65535 }), defaultPort: Type.Number({ minimum: 1, maximum: 65535 }),
startupCommand: Type.String({ minLength: 1 }), startupCommand: Type.String({ minLength: 1 }),
stopCommand: Type.Optional(Type.String()), stopCommand: Type.Optional(Type.String()),
stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })),
containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })),
configFiles: Type.Optional(Type.Array(Type.Any())), configFiles: Type.Optional(Type.Array(Type.Any())),
environmentVars: Type.Optional(Type.Array(Type.Any())), environmentVars: Type.Optional(Type.Array(Type.Any())),
automationRules: Type.Optional(Type.Array(Type.Any())), automationRules: Type.Optional(Type.Array(Type.Any())),
@@ -21,6 +23,8 @@ export const UpdateGameSchema = {
defaultPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })), defaultPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
startupCommand: Type.Optional(Type.String({ minLength: 1 })), startupCommand: Type.Optional(Type.String({ minLength: 1 })),
stopCommand: Type.Optional(Type.String()), stopCommand: Type.Optional(Type.String()),
stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })),
containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })),
configFiles: Type.Optional(Type.Array(Type.Any())), configFiles: Type.Optional(Type.Array(Type.Any())),
environmentVars: Type.Optional(Type.Array(Type.Any())), environmentVars: Type.Optional(Type.Array(Type.Any())),
automationRules: Type.Optional(Type.Array(Type.Any())), automationRules: Type.Optional(Type.Array(Type.Any())),
+12 -11
View File
@@ -8,10 +8,10 @@ import { requirePermission } from '../../lib/permissions.js';
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js'; import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js'; import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
import { import {
isManagedCs2ServerConfigPath, managedConfigFileFor,
readManagedCs2ServerConfig, readManagedConfig,
writeManagedCs2ServerConfig, writeManagedConfig,
} from '../../lib/cs2-server-config.js'; } from '../../lib/managed-config.js';
const ParamSchema = { const ParamSchema = {
params: Type.Object({ params: Type.Object({
@@ -70,8 +70,9 @@ export default async function configRoutes(app: FastifyInstance) {
let raw = ''; let raw = '';
try { try {
if (isManagedCs2ServerConfigPath(game.slug, configFile.path)) { const managedFile = managedConfigFileFor(game.slug, configFile.path);
raw = await readManagedCs2ServerConfig(node, server.uuid); if (managedFile) {
raw = await readManagedConfig(node, server.uuid, managedFile);
} else { } else {
const file = await daemonReadFile(node, server.uuid, configFile.path); const file = await daemonReadFile(node, server.uuid, configFile.path);
raw = file.data.toString('utf8'); raw = file.data.toString('utf8');
@@ -120,13 +121,13 @@ export default async function configRoutes(app: FastifyInstance) {
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex); const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
const isManagedCs2Config = isManagedCs2ServerConfigPath(game.slug, configFile.path); const managedFile = managedConfigFileFor(game.slug, configFile.path);
let originalContent: string | undefined; let originalContent: string | undefined;
let originalEntries: { key: string; value: string }[] = []; let originalEntries: { key: string; value: string }[] = [];
try { try {
if (isManagedCs2Config) { if (managedFile) {
originalContent = await readManagedCs2ServerConfig(node, server.uuid); originalContent = await readManagedConfig(node, server.uuid, managedFile);
} else { } else {
const current = await daemonReadFile(node, server.uuid, configFile.path); const current = await daemonReadFile(node, server.uuid, configFile.path);
originalContent = current.data.toString('utf8'); originalContent = current.data.toString('utf8');
@@ -161,8 +162,8 @@ export default async function configRoutes(app: FastifyInstance) {
originalContent, originalContent,
); );
if (isManagedCs2Config) { if (managedFile) {
await writeManagedCs2ServerConfig(node, server.uuid, content); await writeManagedConfig(node, server.uuid, managedFile, content);
} else { } else {
await daemonWriteFile(node, server.uuid, configFile.path, content); await daemonWriteFile(node, server.uuid, configFile.path, content);
} }
+28 -19
View File
@@ -12,12 +12,11 @@ import {
type DaemonNodeConnection, type DaemonNodeConnection,
} from '../../lib/daemon.js'; } from '../../lib/daemon.js';
import { import {
CS2_PERSISTED_SERVER_CFG_PATH, isManagedConfigShadowFile,
CS2_PERSISTED_SERVER_CFG_FILE, managedConfigFileFor,
isManagedCs2ServerConfigPath, readManagedConfig,
readManagedCs2ServerConfig, writeManagedConfig,
writeManagedCs2ServerConfig, } from '../../lib/managed-config.js';
} from '../../lib/cs2-server-config.js';
const FileParamSchema = { const FileParamSchema = {
params: Type.Object({ params: Type.Object({
@@ -27,8 +26,8 @@ const FileParamSchema = {
}; };
function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean { function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean {
if (isManagedConfigShadowFile(gameSlug, fileName)) return true;
if (gameSlug !== 'cs2') return false; if (gameSlug !== 'cs2') return false;
if (fileName.trim() === CS2_PERSISTED_SERVER_CFG_FILE) return true;
if (isDirectory) return false; if (isDirectory) return false;
const normalizedName = fileName.trim().toLowerCase(); const normalizedName = fileName.trim().toLowerCase();
@@ -108,9 +107,10 @@ export default async function fileRoutes(app: FastifyInstance) {
let payload: Buffer; let payload: Buffer;
let mimeType = 'text/plain'; let mimeType = 'text/plain';
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) { const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
if (managedFile) {
payload = Buffer.from( payload = Buffer.from(
await readManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid), await readManagedConfig(serverContext.node, serverContext.serverUuid, managedFile),
'utf8', 'utf8',
); );
} else { } else {
@@ -156,8 +156,14 @@ export default async function fileRoutes(app: FastifyInstance) {
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data; const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) { const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
await writeManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid, payload); if (managedFile) {
await writeManagedConfig(
serverContext.node,
serverContext.serverUuid,
managedFile,
payload,
);
} else { } else {
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload); await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
} }
@@ -182,16 +188,19 @@ export default async function fileRoutes(app: FastifyInstance) {
await requirePermission(request, orgId, 'files.delete'); await requirePermission(request, orgId, 'files.delete');
const serverContext = await getServerContext(app, orgId, serverId); const serverContext = await getServerContext(app, orgId, serverId);
const resolvedPaths = paths.flatMap((path) => // Deleting a managed config also drops the panel's sidecar copy,
isManagedCs2ServerConfigPath(serverContext.gameSlug, path) // otherwise the next start would resurrect the file.
? [ const resolvedPaths = paths.flatMap((path) => {
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
if (!managedFile) return [path];
return [
path, path,
path.trim().startsWith('/') path.trim().startsWith('/')
? `/${CS2_PERSISTED_SERVER_CFG_PATH}` ? `/${managedFile.shadowPath}`
: CS2_PERSISTED_SERVER_CFG_PATH, : managedFile.shadowPath,
] ];
: [path], });
);
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths); await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths);
return { success: true, paths }; return { success: true, paths };
+103 -56
View File
@@ -26,7 +26,7 @@ import {
type DaemonNodeConnection, type DaemonNodeConnection,
type DaemonPortMapping, type DaemonPortMapping,
} from '../../lib/daemon.js'; } from '../../lib/daemon.js';
import { reapplyManagedCs2ServerConfig } from '../../lib/cs2-server-config.js'; import { sustainManagedConfigsAfterStart } from '../../lib/managed-config.js';
import { import {
ServerParamSchema, ServerParamSchema,
CreateServerSchema, CreateServerSchema,
@@ -146,6 +146,14 @@ function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequ
]; ];
} }
if (slug === 'ark-se') {
return [
{ key: 'ark-raw-udp', label: 'Raw UDP Socket Port', protocols: ['udp'] },
{ key: 'ark-query', label: 'Steam Query Port', protocols: ['udp'] },
{ key: 'ark-rcon', label: 'RCON Port', protocols: ['tcp'] },
];
}
return []; return [];
} }
@@ -188,12 +196,12 @@ function applyGameRuntimeEnvironment(
additionalPortsRaw: unknown, additionalPortsRaw: unknown,
): Record<string, string> { ): Record<string, string> {
const slug = gameSlug.trim().toLowerCase(); const slug = gameSlug.trim().toLowerCase();
if (slug !== 'satisfactory') return environment;
const additionalPorts = normalizeAdditionalServerPorts(additionalPortsRaw); const additionalPorts = normalizeAdditionalServerPorts(additionalPortsRaw);
const messagingPort = additionalPorts.find( const findPort = (key: string, protocol: PortProtocol) =>
(port) => port.key === 'satisfactory-messaging' && port.protocol === 'tcp', additionalPorts.find((port) => port.key === key && port.protocol === protocol);
);
if (slug === 'satisfactory') {
const messagingPort = findPort('satisfactory-messaging', 'tcp');
return { return {
...environment, ...environment,
@@ -202,6 +210,26 @@ function applyGameRuntimeEnvironment(
}; };
} }
if (slug === 'ark-se') {
// The ARK image binds exactly the ports it is told about, so the
// allocations have to be mirrored into its environment.
const rconPort = findPort('ark-rcon', 'tcp')?.hostPort ?? 27020;
const adminPassword = environment.ADMIN_PASSWORD ?? '';
return {
...environment,
GAME_CLIENT_PORT: String(allocationPort),
UDP_SOCKET_PORT: String(findPort('ark-raw-udp', 'udp')?.hostPort ?? allocationPort + 1),
SERVER_LIST_PORT: String(findPort('ark-query', 'udp')?.hostPort ?? 27015),
RCON_PORT: String(rconPort),
// The daemon reads these when it opens an RCON console session.
RCON_PASSWORD: adminPassword,
};
}
return environment;
}
function buildDaemonPorts( function buildDaemonPorts(
gameSlug: string, gameSlug: string,
allocationPort: number, allocationPort: number,
@@ -231,6 +259,19 @@ function buildDaemonPorts(
]; ];
} }
if (slug === 'ark-se') {
// Host and container ports match because the image is configured with the
// very same numbers via its environment.
return [
{ host_port: allocationPort, container_port: allocationPort, protocol: 'udp' },
...additionalPorts.map((port) => ({
host_port: port.hostPort,
container_port: port.hostPort,
protocol: port.protocol,
})),
];
}
if (slug === 'cs2' || slug === 'csgo' || slug === 'fivem') { if (slug === 'cs2' || slug === 'csgo' || slug === 'fivem') {
return [ return [
{ host_port: allocationPort, container_port: containerPort, protocol: 'udp' }, { host_port: allocationPort, container_port: containerPort, protocol: 'udp' },
@@ -512,6 +553,11 @@ async function syncServerInstallStatus(
'Synchronized install status from daemon', 'Synchronized install status from daemon',
); );
if (mapped === 'running') {
// First boot resets configs the same way a restart does.
sustainManagedConfigAfterPowerStart(app, node, serverId, serverUuid, gameSlug);
}
if (mapped === 'running' || mapped === 'stopped') { if (mapped === 'running' || mapped === 'stopped') {
if (needsManagedProvisioning) { if (needsManagedProvisioning) {
void runManagedInstallProvisioning(app, { void runManagedInstallProvisioning(app, {
@@ -543,30 +589,35 @@ async function syncServerInstallStatus(
app.log.warn({ serverId, serverUuid }, 'Timed out while waiting for daemon install completion'); app.log.warn({ serverId, serverUuid }, 'Timed out while waiting for daemon install completion');
} }
async function sustainCs2ServerConfigAfterPowerStart( /**
* Keep panel-managed config files alive across a start.
*
* Steam-based images re-validate the game install on every start and put their
* own `server.cfg` back afterwards — often many minutes in, long after a fixed
* short retry window would have given up. So the watcher polls for drift over a
* long window and stops once the file has stayed put (or the server stops).
*/
function sustainManagedConfigAfterPowerStart(
app: FastifyInstance, app: FastifyInstance,
node: DaemonNodeConnection, node: DaemonNodeConnection,
serverId: string, serverId: string,
serverUuid: string, serverUuid: string,
gameSlug: string, gameSlug: string,
): Promise<void> { ): void {
if (gameSlug.trim().toLowerCase() !== 'cs2') return; sustainManagedConfigsAfterStart(app, {
node,
serverId,
serverUuid,
gameSlug,
isServerActive: async () => {
const [row] = await app.db
.select({ status: servers.status })
.from(servers)
.where(eq(servers.id, serverId));
const attempts = 6; return row?.status === 'running' || row?.status === 'installing';
const intervalMs = 10_000; },
});
for (let attempt = 1; attempt <= attempts; attempt += 1) {
await sleep(intervalMs);
try {
await reapplyManagedCs2ServerConfig(node, serverUuid);
} catch (error) {
app.log.warn(
{ error, serverId, serverUuid, attempt },
'Failed to reapply managed CS2 server.cfg after power start',
);
}
}
} }
export default async function serverRoutes(app: FastifyInstance) { export default async function serverRoutes(app: FastifyInstance) {
@@ -827,6 +878,9 @@ export default async function serverRoutes(app: FastifyInstance) {
), ),
ports: buildDaemonPorts(game.slug, allocation.port, game.defaultPort, additionalServerPorts), ports: buildDaemonPorts(game.slug, allocation.port, game.defaultPort, additionalServerPorts),
install_plugin_urls: [], install_plugin_urls: [],
data_path: game.containerDataPath ?? '',
stop_command: game.stopCommand ?? '',
stop_timeout_seconds: game.stopTimeoutSeconds ?? 0,
}; };
let createdServerResponse = server; let createdServerResponse = server;
@@ -1195,6 +1249,9 @@ export default async function serverRoutes(app: FastifyInstance) {
gameDefaultPort: games.defaultPort, gameDefaultPort: games.defaultPort,
gameSlug: games.slug, gameSlug: games.slug,
gameStartupCommand: games.startupCommand, gameStartupCommand: games.startupCommand,
gameStopCommand: games.stopCommand,
gameStopTimeoutSeconds: games.stopTimeoutSeconds,
gameContainerDataPath: games.containerDataPath,
gameEnvironmentVars: games.environmentVars, gameEnvironmentVars: games.environmentVars,
}) })
.from(servers) .from(servers)
@@ -1256,6 +1313,9 @@ export default async function serverRoutes(app: FastifyInstance) {
current.gameDefaultPort, current.gameDefaultPort,
current.additionalPorts, current.additionalPorts,
), ),
data_path: current.gameContainerDataPath ?? '',
stop_command: current.gameStopCommand ?? '',
stop_timeout_seconds: current.gameStopTimeoutSeconds ?? 0,
}, },
); );
nextStatus = mapDaemonStatus(response.status); nextStatus = mapDaemonStatus(response.status);
@@ -1412,9 +1472,14 @@ export default async function serverRoutes(app: FastifyInstance) {
nodeFqdn: nodes.fqdn, nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort, nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken, nodeDaemonToken: nodes.daemonToken,
gameSlug: games.slug,
gameStopCommand: games.stopCommand,
gameStopTimeoutSeconds: games.stopTimeoutSeconds,
gameAutomationRules: games.automationRules,
}) })
.from(servers) .from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id)) .innerJoin(nodes, eq(servers.nodeId, nodes.id))
.innerJoin(games, eq(servers.gameId, games.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) throw AppError.notFound('Server not found'); if (!server) throw AppError.notFound('Server not found');
@@ -1422,16 +1487,17 @@ export default async function serverRoutes(app: FastifyInstance) {
throw AppError.badRequest('Cannot send power action to a suspended server'); throw AppError.badRequest('Cannot send power action to a suspended server');
} }
try { const nodeConnection: DaemonNodeConnection = {
await daemonSetPowerState(
{
fqdn: server.nodeFqdn, fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort, grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken, daemonToken: server.nodeDaemonToken,
}, };
server.uuid,
action, try {
); await daemonSetPowerState(nodeConnection, server.uuid, action, {
stopCommand: server.gameStopCommand,
stopTimeoutSeconds: server.gameStopTimeoutSeconds,
});
} catch (error) { } catch (error) {
app.log.error( app.log.error(
{ error, serverId: server.id, serverUuid: server.uuid, action }, { error, serverId: server.id, serverUuid: server.uuid, action },
@@ -1457,42 +1523,23 @@ export default async function serverRoutes(app: FastifyInstance) {
.where(eq(servers.id, serverId)); .where(eq(servers.id, serverId));
if (action === 'start' || action === 'restart') { if (action === 'start' || action === 'restart') {
const [serverWithGame] = await app.db sustainManagedConfigAfterPowerStart(
.select({
gameSlug: games.slug,
automationRules: games.automationRules,
})
.from(servers)
.innerJoin(games, eq(servers.gameId, games.id))
.where(eq(servers.id, serverId));
if (serverWithGame) {
void sustainCs2ServerConfigAfterPowerStart(
app, app,
{ nodeConnection,
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
serverId, serverId,
server.uuid, server.uuid,
serverWithGame.gameSlug, server.gameSlug,
); );
void runServerAutomationEvent(app, { void runServerAutomationEvent(app, {
serverId, serverId,
serverUuid: server.uuid, serverUuid: server.uuid,
gameSlug: serverWithGame.gameSlug, gameSlug: server.gameSlug,
event: 'server.power.started', event: 'server.power.started',
node: { node: nodeConnection,
fqdn: server.nodeFqdn, automationRulesRaw: server.gameAutomationRules,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
automationRulesRaw: serverWithGame.automationRules,
}); });
} }
}
await createAuditLog(app.db, request, { await createAuditLog(app.db, request, {
organizationId: orgId, organizationId: orgId,
+8 -4
View File
@@ -3,9 +3,13 @@ FROM rust:1.83-bookworm AS build
# Install protoc # Install protoc
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
WORKDIR /app # build.rs compiles ../../packages/proto/daemon.proto, so the workspace layout
COPY apps/daemon/ . # has to be preserved inside the build context.
WORKDIR /build
COPY packages/proto ./packages/proto
COPY apps/daemon ./apps/daemon
WORKDIR /build/apps/daemon
RUN cargo build --release RUN cargo build --release
# --- Production --- # --- Production ---
@@ -18,12 +22,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=build /app/target/release/gamepanel-daemon /app/gamepanel-daemon COPY --from=build /build/apps/daemon/target/release/gamepanel-daemon /app/gamepanel-daemon
# Data directories # Data directories
RUN mkdir -p /var/lib/gamepanel/servers /var/lib/gamepanel/backups /etc/gamepanel RUN mkdir -p /var/lib/gamepanel/servers /var/lib/gamepanel/backups /etc/gamepanel
EXPOSE 50051 EXPOSE 50051
HEALTHCHECK --interval=30s --timeout=5s CMD /app/gamepanel-daemon --health-check || exit 1 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s CMD /app/gamepanel-daemon --health-check || exit 1
CMD ["/app/gamepanel-daemon"] CMD ["/app/gamepanel-daemon"]
+24 -1
View File
@@ -12,6 +12,12 @@ pub struct DaemonConfig {
pub docker: DockerConfig, pub docker: DockerConfig,
#[serde(default = "default_data_path")] #[serde(default = "default_data_path")]
pub data_path: PathBuf, pub data_path: PathBuf,
/// Where `data_path` lives on the Docker host. Only differs from `data_path`
/// when the daemon itself runs in a container: bind mounts for the game
/// containers are resolved by the host Docker engine, not by the daemon's
/// own mount namespace. Defaults to `data_path`.
#[serde(default)]
pub host_data_path: Option<PathBuf>,
#[serde(default = "default_backup_path")] #[serde(default = "default_backup_path")]
pub backup_path: PathBuf, pub backup_path: PathBuf,
#[serde(default)] #[serde(default)]
@@ -92,7 +98,24 @@ grpc_port: 50051
.to_string() .to_string()
}); });
let config: DaemonConfig = serde_yaml::from_str(&content)?; let mut config: DaemonConfig = serde_yaml::from_str(&content)?;
// Environment overrides make containerised deployments configurable
// without templating the YAML file.
if let Ok(host_data_path) = std::env::var("DAEMON_HOST_DATA_PATH") {
let trimmed = host_data_path.trim();
if !trimmed.is_empty() {
config.host_data_path = Some(PathBuf::from(trimmed));
}
}
Ok(config) Ok(config)
} }
/// Path prefix the Docker host uses for server data directories.
pub fn host_data_path(&self) -> PathBuf {
self.host_data_path
.clone()
.unwrap_or_else(|| self.data_path.clone())
}
} }
+394 -18
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Cursor; use std::io::Cursor;
use std::path::{Path, PathBuf}; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use bollard::container::{ use bollard::container::{
@@ -14,13 +14,24 @@ use tokio::time::{sleep, Duration};
use tracing::{debug, info}; use tracing::{debug, info};
use crate::docker::DockerManager; use crate::docker::DockerManager;
use crate::server::ServerSpec; use crate::server::{ServerRuntime, ServerSpec};
use crate::server::state::ServerState; use crate::server::state::ServerState;
/// Container name prefix for all managed game servers. /// Container name prefix for all managed game servers.
const CONTAINER_PREFIX: &str = "gp_"; const CONTAINER_PREFIX: &str = "gp_";
const SATISFACTORY_RUN_SH: &str = include_str!("../game/satisfactory_run.sh"); const SATISFACTORY_RUN_SH: &str = include_str!("../game/satisfactory_run.sh");
/// Labels used to persist panel-supplied runtime options on the container, so
/// they survive a daemon restart (in-memory specs are rebuilt from Docker).
const LABEL_DATA_PATH: &str = "gamepanel.data_mount_path";
const LABEL_STOP_COMMAND: &str = "gamepanel.stop_command";
const LABEL_STOP_TIMEOUT: &str = "gamepanel.stop_timeout_seconds";
/// Docker's SIGTERM grace period once the in-game stop command has had its turn.
const SIGTERM_GRACE_SECS: i64 = 15;
/// Fallback shutdown budget when the game defines no explicit timeout.
pub const DEFAULT_STOP_TIMEOUT_SECS: i64 = 30;
pub fn container_name(server_uuid: &str) -> String { pub fn container_name(server_uuid: &str) -> String {
format!("{}{}", CONTAINER_PREFIX, server_uuid) format!("{}{}", CONTAINER_PREFIX, server_uuid)
} }
@@ -47,9 +58,63 @@ fn container_data_path_for_image(image: &str) -> &'static str {
if normalized.contains("wolveix/satisfactory-server") { if normalized.contains("wolveix/satisfactory-server") {
return "/config"; return "/config";
} }
if normalized.contains("ark-server") || normalized.contains("ark-survival-evolved") {
return "/app";
}
"/data" "/data"
} }
/// Mount point of the server data directory inside the container. The panel can
/// override the image-derived default per game.
fn container_data_path(spec: &ServerSpec) -> String {
spec.runtime
.data_mount_path
.as_deref()
.map(str::trim)
.filter(|path| path.starts_with('/'))
.map(str::to_string)
.unwrap_or_else(|| container_data_path_for_image(&spec.docker_image).to_string())
}
/// Games whose process does not read stdin, so console commands have to go over
/// RCON instead of the container's attached stdin.
fn prefers_rcon_console(image: &str) -> bool {
let normalized = image.to_ascii_lowercase();
normalized.contains("cs2")
|| normalized.contains("csgo")
|| normalized.contains("ark-server")
|| normalized.contains("ark-survival-evolved")
}
fn runtime_labels(spec: &ServerSpec) -> HashMap<String, String> {
let mut labels = HashMap::new();
labels.insert(LABEL_DATA_PATH.to_string(), container_data_path(spec));
if let Some(stop_command) = spec.runtime.stop_command.as_deref() {
labels.insert(LABEL_STOP_COMMAND.to_string(), stop_command.to_string());
}
if let Some(timeout) = spec.runtime.stop_timeout_seconds {
labels.insert(LABEL_STOP_TIMEOUT.to_string(), timeout.to_string());
}
labels
}
fn runtime_from_labels(labels: Option<&HashMap<String, String>>) -> ServerRuntime {
let Some(labels) = labels else {
return ServerRuntime::default();
};
ServerRuntime {
data_mount_path: labels.get(LABEL_DATA_PATH).cloned(),
stop_command: labels.get(LABEL_STOP_COMMAND).cloned(),
stop_timeout_seconds: labels
.get(LABEL_STOP_TIMEOUT)
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0),
}
}
fn is_wolveix_satisfactory_image(image: &str) -> bool { fn is_wolveix_satisfactory_image(image: &str) -> bool {
image image
.to_ascii_lowercase() .to_ascii_lowercase()
@@ -69,6 +134,7 @@ impl DockerManager {
async fn attach_command_stream( async fn attach_command_stream(
&self, &self,
container_name: &str, container_name: &str,
container_id: String,
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> { ) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
let bollard::container::AttachContainerResults { mut output, input } = self let bollard::container::AttachContainerResults { mut output, input } = self
.client() .client()
@@ -93,28 +159,80 @@ impl DockerManager {
debug!(container = %name, "Container stdin attach stream ended"); debug!(container = %name, "Container stdin attach stream ended");
}); });
Ok(Arc::new(crate::docker::manager::CommandStreamHandle::new(input, drain_task))) Ok(Arc::new(crate::docker::manager::CommandStreamHandle::new(
container_id,
input,
drain_task,
)))
}
/// Resolve the id of the container backing a server, but only while it is
/// actually running. Writing to a stopped container's stdin looks like it
/// succeeds and then goes nowhere, so this is the gate for every command.
async fn running_container_id(&self, server_uuid: &str) -> Result<String> {
let name = container_name(server_uuid);
let info = match self.client().inspect_container(&name, None).await {
Ok(info) => info,
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => {
return Err(anyhow::anyhow!(
"server container does not exist (server has not been installed yet)"
))
}
Err(error) => return Err(error.into()),
};
let running = info
.state
.as_ref()
.and_then(|state| state.running)
.unwrap_or(false);
if !running {
return Err(anyhow::anyhow!(
"server is not running, start it before sending console commands"
));
}
info.id
.ok_or_else(|| anyhow::anyhow!("Docker did not report a container id"))
} }
async fn get_or_attach_command_stream( async fn get_or_attach_command_stream(
&self, &self,
server_uuid: &str, server_uuid: &str,
container_id: &str,
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> { ) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
let name = container_name(server_uuid); let name = container_name(server_uuid);
if let Some(existing) = self.command_streams().read().await.get(&name).cloned() { if let Some(existing) = self.command_streams().read().await.get(&name).cloned() {
if existing.container_id() == container_id {
return Ok(existing); return Ok(existing);
} }
}
let created = self.attach_command_stream(&name).await?; // Container was recreated or restarted since we last attached — the old
// hijacked socket is dead, drop it before opening a fresh one.
self.clear_command_stream(server_uuid).await;
let created = self
.attach_command_stream(&name, container_id.to_string())
.await?;
let mut streams = self.command_streams().write().await; let mut streams = self.command_streams().write().await;
if let Some(existing) = streams.get(&name).cloned() { if let Some(existing) = streams.get(&name).cloned() {
if existing.container_id() == container_id {
created.abort(); created.abort();
return Ok(existing); return Ok(existing);
} }
}
streams.insert(name, created.clone()); // Another task may have raced in with a stream for a different
// container; its drain task has to be stopped or it leaks.
if let Some(displaced) = streams.insert(name, created.clone()) {
displaced.abort();
}
Ok(created) Ok(created)
} }
@@ -202,6 +320,123 @@ impl DockerManager {
.await .await
} }
/// IP of the container on the panel's Docker network. Reaching the game
/// over RCON via the container IP works whether the daemon runs on the host
/// or as a sibling container on the same network — unlike `127.0.0.1`.
pub async fn container_ip(&self, server_uuid: &str) -> Option<String> {
let name = container_name(server_uuid);
let info = self.client().inspect_container(&name, None).await.ok()?;
let networks = info.network_settings.as_ref()?;
if let Some(named) = networks.networks.as_ref() {
if let Some(ip) = named
.get(self.network_name())
.and_then(|net| net.ip_address.clone())
.filter(|ip| !ip.is_empty())
{
return Some(ip);
}
if let Some(ip) = named
.values()
.filter_map(|net| net.ip_address.clone())
.find(|ip| !ip.is_empty())
{
return Some(ip);
}
}
networks
.ip_address
.clone()
.filter(|ip| !ip.is_empty())
}
/// Resolve `(address, password)` for the container's RCON endpoint from its
/// image and environment.
async fn rcon_endpoint(&self, server_uuid: &str) -> Result<(String, String)> {
let (image, env) = self.container_runtime_metadata(server_uuid).await?;
let normalized = image.to_ascii_lowercase();
let lookup = |keys: &[&str]| -> Option<String> {
keys.iter()
.find_map(|key| env.get(*key))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
};
let is_ark = normalized.contains("ark-server")
|| normalized.contains("ark-survival-evolved");
let (password_keys, port_keys, default_port): (&[&str], &[&str], u16) = if is_ark {
(
&["ARK_RCON_PASSWORD", "RCON_PASSWORD", "ADMIN_PASSWORD"],
&["RCON_PORT"],
27020,
)
} else {
(
&["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"],
&["RCON_PORT", "CS2_PORT"],
27015,
)
};
let password = lookup(password_keys)
.ok_or_else(|| anyhow::anyhow!("no RCON password is configured for this server"))?;
let port = lookup(port_keys)
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(default_port);
let host = match lookup(&["RCON_HOST"]) {
Some(host) => host,
None => self
.container_ip(server_uuid)
.await
.unwrap_or_else(|| "127.0.0.1".to_string()),
};
Ok((format!("{host}:{port}"), password))
}
async fn send_command_via_rcon(&self, server_uuid: &str, command: &str) -> Result<()> {
let (address, password) = self.rcon_endpoint(server_uuid).await?;
let mut client = crate::game::rcon::RconClient::connect(&address, &password).await?;
client.command(command).await?;
debug!(server_uuid = %server_uuid, address = %address, "Console command delivered over RCON");
Ok(())
}
async fn send_command_via_stdin(
&self,
server_uuid: &str,
container_id: &str,
command: &str,
) -> Result<()> {
let payload = format!("{command}\n");
for attempt in 0..2 {
let stream = self
.get_or_attach_command_stream(server_uuid, container_id)
.await?;
match stream.write_all(payload.as_bytes()).await {
Ok(_) => return Ok(()),
Err(error) => {
debug!(
server_uuid = %server_uuid,
attempt,
error = %error,
"Failed to write to container stdin, resetting attach stream",
);
self.clear_command_stream(server_uuid).await;
}
}
}
Err(anyhow::anyhow!("failed to write command to container stdin"))
}
/// Pull a Docker image if not already present. /// Pull a Docker image if not already present.
pub async fn pull_image(&self, image: &str) -> Result<()> { pub async fn pull_image(&self, image: &str) -> Result<()> {
info!(image = %image, "Pulling Docker image"); info!(image = %image, "Pulling Docker image");
@@ -230,7 +465,9 @@ impl DockerManager {
/// Create and configure a container for a game server. /// Create and configure a container for a game server.
pub async fn create_container(&self, spec: &ServerSpec) -> Result<String> { pub async fn create_container(&self, spec: &ServerSpec) -> Result<String> {
let name = container_name(&spec.uuid); let name = container_name(&spec.uuid);
let data_mount_path = container_data_path_for_image(&spec.docker_image); let data_mount_path = container_data_path(spec);
let data_mount_path = data_mount_path.as_str();
let bind_source = self.host_bind_source(&spec.data_path);
// Build port bindings // Build port bindings
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new(); let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
@@ -267,8 +504,7 @@ impl DockerManager {
network_mode: Some(self.network_name().to_string()), network_mode: Some(self.network_name().to_string()),
binds: Some(vec![format!( binds: Some(vec![format!(
"{}:{}", "{}:{}",
spec.data_path.display() bind_source.display(),
,
data_mount_path data_mount_path
)]), )]),
..Default::default() ..Default::default()
@@ -280,6 +516,7 @@ impl DockerManager {
env: Some(env), env: Some(env),
exposed_ports: Some(exposed_ports), exposed_ports: Some(exposed_ports),
host_config: Some(host_config), host_config: Some(host_config),
labels: Some(runtime_labels(spec)),
// Preserve image default working directory when no custom startup command is set. // Preserve image default working directory when no custom startup command is set.
// Some game images rely on their built-in WORKDIR and entrypoint scripts. // Some game images rely on their built-in WORKDIR and entrypoint scripts.
working_dir: if spec.startup_command.is_empty() { working_dir: if spec.startup_command.is_empty() {
@@ -342,6 +579,100 @@ impl DockerManager {
Ok(()) Ok(())
} }
/// Shut a server down the way its game expects.
///
/// Sending the in-game stop command first is what makes shutdown fast: most
/// dedicated servers ignore SIGTERM entirely and only exit once Docker's
/// timeout expires and SIGKILL lands, which is why "stop" used to sit there
/// for the full grace period every single time.
pub async fn stop_container_graceful(
&self,
server_uuid: &str,
stop_command: Option<&str>,
stop_timeout_secs: i64,
) -> Result<()> {
let budget = if stop_timeout_secs > 0 {
stop_timeout_secs
} else {
DEFAULT_STOP_TIMEOUT_SECS
}
.max(5);
let stop_command = stop_command
.map(str::trim)
.filter(|command| !command.is_empty());
if let Some(command) = stop_command {
match self.send_command(server_uuid, command).await {
Ok(_) => {
let graceful_budget = (budget - SIGTERM_GRACE_SECS).max(5);
if self.wait_until_exited(server_uuid, graceful_budget).await? {
self.clear_command_stream(server_uuid).await;
info!(
uuid = %server_uuid,
command = %command,
"Server exited after in-game stop command",
);
return Ok(());
}
tracing::warn!(
uuid = %server_uuid,
command = %command,
graceful_budget,
"Server ignored the in-game stop command, falling back to SIGTERM",
);
return self.stop_container(server_uuid, SIGTERM_GRACE_SECS).await;
}
Err(error) => {
tracing::warn!(
uuid = %server_uuid,
command = %command,
error = %error,
"Could not deliver the in-game stop command, falling back to SIGTERM",
);
}
}
}
// No usable stop command: SIGTERM gets the whole budget. Docker returns
// as soon as the container exits, so a well-behaved image (ARK, itzg)
// still stops quickly.
self.stop_container(server_uuid, budget).await
}
/// Poll until the container is no longer running. Returns `false` on timeout.
async fn wait_until_exited(&self, server_uuid: &str, timeout_secs: i64) -> Result<bool> {
let deadline =
tokio::time::Instant::now() + Duration::from_secs(timeout_secs.max(1) as u64);
let name = container_name(server_uuid);
loop {
match self.client().inspect_container(&name, None).await {
Ok(info) => {
let running = info
.state
.as_ref()
.and_then(|state| state.running)
.unwrap_or(false);
if !running {
return Ok(true);
}
}
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => return Ok(true),
Err(error) => return Err(error.into()),
}
if tokio::time::Instant::now() >= deadline {
return Ok(false);
}
sleep(Duration::from_millis(500)).await;
}
}
/// Kill a container immediately. /// Kill a container immediately.
pub async fn kill_container(&self, server_uuid: &str) -> Result<()> { pub async fn kill_container(&self, server_uuid: &str) -> Result<()> {
let name = container_name(server_uuid); let name = container_name(server_uuid);
@@ -476,6 +807,12 @@ impl DockerManager {
.and_then(|cfg| cfg.image.clone()) .and_then(|cfg| cfg.image.clone())
.unwrap_or_default(); .unwrap_or_default();
let runtime = runtime_from_labels(
info.config.as_ref().and_then(|cfg| cfg.labels.as_ref()),
);
// `mount.source` is a host path; map it back into the daemon's own
// mount namespace before we try to read or write it.
let data_mount_path = info let data_mount_path = info
.mounts .mounts
.as_ref() .as_ref()
@@ -484,7 +821,10 @@ impl DockerManager {
if mount.typ != Some(MountPointTypeEnum::BIND) { if mount.typ != Some(MountPointTypeEnum::BIND) {
return None; return None;
} }
mount.source.as_ref().map(PathBuf::from) mount
.source
.as_ref()
.map(|source| self.daemon_data_path(Path::new(source)))
}) })
}) })
.unwrap_or_else(|| data_root.join(&uuid)); .unwrap_or_else(|| data_root.join(&uuid));
@@ -594,6 +934,7 @@ impl DockerManager {
data_path: data_mount_path, data_path: data_mount_path,
state, state,
container_id: info.id, container_id: info.id,
runtime,
}); });
} }
@@ -620,22 +961,57 @@ impl DockerManager {
}) })
} }
/// Send a command to a container via a persistent Docker attach stdin stream. /// Send a console command to a server.
///
/// Most images pipe the game's stdin straight through, so the attached
/// stdin stream is the default. Source-engine and ARK servers never read
/// stdin, so those go over RCON first — with the other transport used as a
/// fallback in both directions.
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> { pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
let trimmed = command.trim_end_matches(|ch| ch == '\r' || ch == '\n'); let trimmed = command.trim_end_matches(|ch| ch == '\r' || ch == '\n');
let payload = format!("{trimmed}\n"); if trimmed.trim().is_empty() {
return Err(anyhow::anyhow!("Command cannot be empty"));
}
for _ in 0..2 { let container_id = self.running_container_id(server_uuid).await?;
let stream = self.get_or_attach_command_stream(server_uuid).await?; let image = self
match stream.write_all(payload.as_bytes()).await { .container_runtime_metadata(server_uuid)
.await
.map(|(image, _)| image)
.unwrap_or_default();
if prefers_rcon_console(&image) {
match self.send_command_via_rcon(server_uuid, trimmed).await {
Ok(_) => return Ok(()), Ok(_) => return Ok(()),
Err(error) => { Err(rcon_error) => {
debug!(server_uuid = %server_uuid, error = %error, "Failed to write to container stdin, resetting attach stream"); debug!(
self.clear_command_stream(server_uuid).await; server_uuid = %server_uuid,
error = %rcon_error,
"RCON console delivery failed, trying container stdin",
);
return self
.send_command_via_stdin(server_uuid, &container_id, trimmed)
.await
.map_err(|stdin_error| {
anyhow::anyhow!(
"RCON failed ({rcon_error}) and stdin failed ({stdin_error})"
)
});
} }
} }
} }
Err(anyhow::anyhow!("failed to write command to container stdin")) match self
.send_command_via_stdin(server_uuid, &container_id, trimmed)
.await
{
Ok(_) => Ok(()),
Err(stdin_error) => self
.send_command_via_rcon(server_uuid, trimmed)
.await
.map_err(|rcon_error| {
anyhow::anyhow!("stdin failed ({stdin_error}) and RCON failed ({rcon_error})")
}),
}
} }
} }
+61 -6
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
@@ -10,23 +11,37 @@ use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::info; use tracing::info;
use crate::config::DockerConfig; use crate::config::DaemonConfig;
type AttachedInput = Pin<Box<dyn AsyncWrite + Send>>; type AttachedInput = Pin<Box<dyn AsyncWrite + Send>>;
pub(crate) struct CommandStreamHandle { pub(crate) struct CommandStreamHandle {
/// Docker id of the container this stdin stream was opened against. A
/// container that gets recreated (or restarted) keeps the same name but
/// gets a new id, and writes to the old hijacked socket are silently
/// swallowed — so the id is what makes a cached stream reusable.
container_id: String,
input: Mutex<AttachedInput>, input: Mutex<AttachedInput>,
drain_task: JoinHandle<()>, drain_task: JoinHandle<()>,
} }
impl CommandStreamHandle { impl CommandStreamHandle {
pub(crate) fn new(input: AttachedInput, drain_task: JoinHandle<()>) -> Self { pub(crate) fn new(
container_id: String,
input: AttachedInput,
drain_task: JoinHandle<()>,
) -> Self {
Self { Self {
container_id,
input: Mutex::new(input), input: Mutex::new(input),
drain_task, drain_task,
} }
} }
pub(crate) fn container_id(&self) -> &str {
&self.container_id
}
pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> { pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> {
let mut input = self.input.lock().await; let mut input = self.input.lock().await;
input.write_all(bytes).await?; input.write_all(bytes).await?;
@@ -44,13 +59,15 @@ impl CommandStreamHandle {
pub struct DockerManager { pub struct DockerManager {
client: Docker, client: Docker,
network_name: String, network_name: String,
data_root: PathBuf,
host_data_root: PathBuf,
command_streams: Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>, command_streams: Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>,
} }
impl DockerManager { impl DockerManager {
pub async fn new(config: &DockerConfig) -> Result<Self> { pub async fn new(config: &DaemonConfig) -> Result<Self> {
let client = Docker::connect_with_socket( let client = Docker::connect_with_socket(
&config.socket, &config.docker.socket,
120, // timeout 120, // timeout
bollard::API_DEFAULT_VERSION, bollard::API_DEFAULT_VERSION,
)?; )?;
@@ -62,13 +79,25 @@ impl DockerManager {
"Connected to Docker" "Connected to Docker"
); );
let data_root = config.data_path.clone();
let host_data_root = config.host_data_path();
if data_root != host_data_root {
info!(
data_root = %data_root.display(),
host_data_root = %host_data_root.display(),
"Server data directories are bind-mounted from a different host path",
);
}
let manager = Self { let manager = Self {
client, client,
network_name: config.network.clone(), network_name: config.docker.network.clone(),
data_root,
host_data_root,
command_streams: Arc::new(RwLock::new(HashMap::new())), command_streams: Arc::new(RwLock::new(HashMap::new())),
}; };
manager.ensure_network(&config.network_subnet).await?; manager.ensure_network(&config.docker.network_subnet).await?;
Ok(manager) Ok(manager)
} }
@@ -81,6 +110,32 @@ impl DockerManager {
&self.network_name &self.network_name
} }
/// Translate a daemon-local server data directory into the path the Docker
/// host must bind-mount. These differ when the daemon runs in a container.
pub fn host_bind_source(&self, data_path: &Path) -> PathBuf {
if self.data_root == self.host_data_root {
return data_path.to_path_buf();
}
match data_path.strip_prefix(&self.data_root) {
Ok(relative) => self.host_data_root.join(relative),
Err(_) => data_path.to_path_buf(),
}
}
/// Inverse of [`Self::host_bind_source`]: turn a bind-mount source reported
/// by Docker back into a path the daemon can read and write itself.
pub fn daemon_data_path(&self, host_path: &Path) -> PathBuf {
if self.data_root == self.host_data_root {
return host_path.to_path_buf();
}
match host_path.strip_prefix(&self.host_data_root) {
Ok(relative) => self.data_root.join(relative),
Err(_) => host_path.to_path_buf(),
}
}
pub(crate) fn command_streams(&self) -> &Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>> { pub(crate) fn command_streams(&self) -> &Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>> {
&self.command_streams &self.command_streams
} }
+80
View File
@@ -0,0 +1,80 @@
use anyhow::Result;
use tracing::info;
use super::rcon::RconClient;
/// Player information from an ARK RCON `ListPlayers` response.
pub struct ArkPlayer {
pub name: String,
pub steamid: String,
}
/// Query an ARK server for its connected players.
pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<Vec<ArkPlayer>> {
let mut client = RconClient::connect(rcon_address, rcon_password).await?;
let response = client.command("ListPlayers").await?;
let players = parse_list_players_response(&response);
info!(count = players.len(), "ARK player list retrieved");
Ok(players)
}
/// Parses lines shaped like `0. PlayerName, 76561198000000000`.
fn parse_list_players_response(response: &str) -> Vec<ArkPlayer> {
let mut players = Vec::new();
for line in response.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// "No Players Connected"
if trimmed.eq_ignore_ascii_case("no players connected") {
break;
}
// Strip the "<index>. " prefix.
let entry = match trimmed.split_once('.') {
Some((index, rest)) if index.trim().chars().all(|c| c.is_ascii_digit()) => rest.trim(),
_ => continue,
};
let (name, steamid) = match entry.rsplit_once(',') {
Some((name, steamid)) => (name.trim(), steamid.trim()),
None => (entry, ""),
};
if name.is_empty() {
continue;
}
players.push(ArkPlayer {
name: name.to_string(),
steamid: steamid.to_string(),
});
}
players
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_connected_players() {
let response = "0. Alper, 76561198000000001\n1. Rezan, 76561198000000002\n";
let players = parse_list_players_response(response);
assert_eq!(players.len(), 2);
assert_eq!(players[0].name, "Alper");
assert_eq!(players[0].steamid, "76561198000000001");
assert_eq!(players[1].name, "Rezan");
}
#[test]
fn handles_empty_server() {
assert!(parse_list_players_response("No Players Connected\n").is_empty());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod rcon; pub mod rcon;
pub mod minecraft; pub mod minecraft;
pub mod cs2; pub mod cs2;
pub mod ark;
+79 -6
View File
@@ -14,7 +14,7 @@ use tonic::{Request, Response, Status};
use tracing::{info, error, warn}; use tracing::{info, error, warn};
use crate::command::CommandDispatcher; use crate::command::CommandDispatcher;
use crate::server::{ServerManager, PortMap}; use crate::server::{ServerManager, ServerRuntime, PortMap};
use crate::filesystem::FileSystem; use crate::filesystem::FileSystem;
use crate::backup::BackupManager; use crate::backup::BackupManager;
use crate::managed_mysql::ManagedMysqlManager; use crate::managed_mysql::ManagedMysqlManager;
@@ -110,6 +110,21 @@ impl DaemonServiceImpl {
Self::env_value(env, keys).and_then(|v| v.parse::<i32>().ok()) Self::env_value(env, keys).and_then(|v| v.parse::<i32>().ok())
} }
/// Host to reach a server's RCON port on. The container's own IP works both
/// when the daemon runs on the host and when it runs as a sibling container;
/// `127.0.0.1` only works in the former case.
async fn rcon_host(&self, uuid: &str, env: &HashMap<String, String>) -> String {
if let Some(host) = Self::env_value(env, &["RCON_HOST"]) {
return host;
}
self.server_manager
.docker()
.container_ip(uuid)
.await
.unwrap_or_else(|| "127.0.0.1".to_string())
}
fn cs2_rcon_password(env: &HashMap<String, String>) -> String { fn cs2_rcon_password(env: &HashMap<String, String>) -> String {
Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"]) Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"])
.unwrap_or_else(|| "changeme".to_string()) .unwrap_or_else(|| "changeme".to_string())
@@ -191,6 +206,12 @@ impl DaemonService for DaemonServiceImpl {
self.check_auth(&request)?; self.check_auth(&request)?;
let req = request.into_inner(); let req = request.into_inner();
let runtime = ServerRuntime::from_request(
req.data_path,
req.stop_command,
req.stop_timeout_seconds,
);
self.server_manager self.server_manager
.create_server( .create_server(
req.uuid.clone(), req.uuid.clone(),
@@ -201,6 +222,7 @@ impl DaemonService for DaemonServiceImpl {
req.startup_command, req.startup_command,
req.environment, req.environment,
Self::map_ports(&req.ports), Self::map_ports(&req.ports),
runtime,
) )
.await .await
.map_err(|e| Status::from(e))?; .map_err(|e| Status::from(e))?;
@@ -218,6 +240,12 @@ impl DaemonService for DaemonServiceImpl {
self.check_auth(&request)?; self.check_auth(&request)?;
let req = request.into_inner(); let req = request.into_inner();
let runtime = ServerRuntime::from_request(
req.data_path,
req.stop_command,
req.stop_timeout_seconds,
);
let state = self.server_manager let state = self.server_manager
.update_server( .update_server(
req.uuid.clone(), req.uuid.clone(),
@@ -228,6 +256,7 @@ impl DaemonService for DaemonServiceImpl {
req.startup_command, req.startup_command,
req.environment, req.environment,
Self::map_ports(&req.ports), Self::map_ports(&req.ports),
runtime,
) )
.await .await
.map_err(Status::from)?; .map_err(Status::from)?;
@@ -378,15 +407,29 @@ impl DaemonService for DaemonServiceImpl {
self.check_auth(&request)?; self.check_auth(&request)?;
let req = request.into_inner(); let req = request.into_inner();
match req.action() { let action = req.action();
let stop_command = if req.stop_command.trim().is_empty() {
None
} else {
Some(req.stop_command.as_str())
};
let stop_timeout = i64::from(req.stop_timeout_seconds);
match action {
PowerAction::Start => { PowerAction::Start => {
self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?; self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?;
} }
PowerAction::Stop => { PowerAction::Stop => {
self.server_manager.stop_server(&req.uuid).await.map_err(Status::from)?; self.server_manager
.stop_server(&req.uuid, stop_command, stop_timeout)
.await
.map_err(Status::from)?;
} }
PowerAction::Restart => { PowerAction::Restart => {
let _ = self.server_manager.stop_server(&req.uuid).await; let _ = self
.server_manager
.stop_server(&req.uuid, stop_command, stop_timeout)
.await;
self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?; self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?;
} }
PowerAction::Kill => { PowerAction::Kill => {
@@ -764,12 +807,42 @@ impl DaemonService for DaemonServiceImpl {
} }
} }
} }
} else if image.contains("ark-server") || image.contains("ark-survival-evolved") {
max_from_runtime_env = Self::env_i32(&env, &["MAX_PLAYERS"]).unwrap_or(0);
let host = self.rcon_host(&uuid, &env).await;
let port = Self::env_u16(&env, &["RCON_PORT"]).unwrap_or(27020);
let password = Self::env_value(
&env,
&["ARK_RCON_PASSWORD", "RCON_PASSWORD", "ADMIN_PASSWORD"],
)
.unwrap_or_default();
let address = format!("{}:{}", host, port);
match crate::game::ark::get_players(&address, &password).await {
Ok(players) => {
let mapped = players
.into_iter()
.map(|p| Player {
name: p.name,
uuid: p.steamid,
connected_at: 0,
})
.collect();
return Ok(Response::new(PlayerList {
players: mapped,
max_players: max_from_runtime_env,
}));
}
Err(e) => {
warn!(uuid = %uuid, error = %e, "ARK RCON player query failed");
}
}
} else if image.contains("csgo") || image.contains("cs2") { } else if image.contains("csgo") || image.contains("cs2") {
max_from_runtime_env = Self::env_i32(&env, &["CS2_MAXPLAYERS", "SRCDS_MAXPLAYERS"]) max_from_runtime_env = Self::env_i32(&env, &["CS2_MAXPLAYERS", "SRCDS_MAXPLAYERS"])
.unwrap_or(0); .unwrap_or(0);
let host = Self::env_value(&env, &["RCON_HOST"]) let host = self.rcon_host(&uuid, &env).await;
.unwrap_or_else(|| "127.0.0.1".to_string());
let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015); let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015);
let password = Self::cs2_rcon_password(&env); let password = Self::cs2_rcon_password(&env);
let address = format!("{}:{}", host, port); let address = format!("{}:{}", host, port);
+15 -1
View File
@@ -28,6 +28,20 @@ const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// `--health-check` is what the container HEALTHCHECK runs: succeed only if
// the gRPC listener is actually accepting connections.
if std::env::args().any(|arg| arg == "--health-check") {
let config = config::DaemonConfig::load()?;
let address = format!("127.0.0.1:{}", config.grpc_port);
return match tokio::net::TcpStream::connect(&address).await {
Ok(_) => Ok(()),
Err(error) => {
eprintln!("daemon health check failed for {address}: {error}");
std::process::exit(1);
}
};
}
// Initialize logging // Initialize logging
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
@@ -42,7 +56,7 @@ async fn main() -> Result<()> {
info!(grpc_port = config.grpc_port, "Configuration loaded"); info!(grpc_port = config.grpc_port, "Configuration loaded");
// Initialize Docker // Initialize Docker
let docker = Arc::new(DockerManager::new(&config.docker).await?); let docker = Arc::new(DockerManager::new(&config).await?);
info!("Docker manager initialized"); info!("Docker manager initialized");
// Initialize server manager // Initialize server manager
+2 -2
View File
@@ -128,9 +128,9 @@ impl Scheduler {
"power" => { "power" => {
match task.payload.as_str() { match task.payload.as_str() {
"start" => self.server_manager.start_server(&task.server_uuid).await?, "start" => self.server_manager.start_server(&task.server_uuid).await?,
"stop" => self.server_manager.stop_server(&task.server_uuid).await?, "stop" => self.server_manager.stop_server(&task.server_uuid, None, 0).await?,
"restart" => { "restart" => {
let _ = self.server_manager.stop_server(&task.server_uuid).await; let _ = self.server_manager.stop_server(&task.server_uuid, None, 0).await;
tokio::time::sleep(Duration::from_secs(3)).await; tokio::time::sleep(Duration::from_secs(3)).await;
self.server_manager.start_server(&task.server_uuid).await?; self.server_manager.start_server(&task.server_uuid).await?;
} }
+47 -4
View File
@@ -10,7 +10,7 @@ use std::os::unix::fs::PermissionsExt;
use crate::config::DaemonConfig; use crate::config::DaemonConfig;
use crate::docker::DockerManager; use crate::docker::DockerManager;
use crate::error::DaemonError; use crate::error::DaemonError;
use super::state::{ServerState, ServerSpec, PortMap}; use super::state::{ServerState, ServerSpec, ServerRuntime, PortMap};
/// Manages all game server instances on this node. /// Manages all game server instances on this node.
pub struct ServerManager { pub struct ServerManager {
@@ -101,6 +101,7 @@ impl ServerManager {
startup_command: String, startup_command: String,
environment: HashMap<String, String>, environment: HashMap<String, String>,
ports: Vec<PortMap>, ports: Vec<PortMap>,
runtime: ServerRuntime,
) -> Result<(), DaemonError> { ) -> Result<(), DaemonError> {
let mut servers = self.servers.write().await; let mut servers = self.servers.write().await;
if servers.contains_key(&uuid) { if servers.contains_key(&uuid) {
@@ -122,6 +123,7 @@ impl ServerManager {
data_path, data_path,
state: ServerState::Installing, state: ServerState::Installing,
container_id: None, container_id: None,
runtime,
}; };
servers.insert(uuid.clone(), spec); servers.insert(uuid.clone(), spec);
@@ -154,6 +156,7 @@ impl ServerManager {
startup_command: String, startup_command: String,
environment: HashMap<String, String>, environment: HashMap<String, String>,
ports: Vec<PortMap>, ports: Vec<PortMap>,
runtime: ServerRuntime,
) -> Result<ServerState, DaemonError> { ) -> Result<ServerState, DaemonError> {
let existing = { let existing = {
let servers = self.servers.read().await; let servers = self.servers.read().await;
@@ -205,6 +208,7 @@ impl ServerManager {
data_path, data_path,
state: ServerState::Stopped, state: ServerState::Stopped,
container_id: None, container_id: None,
runtime: runtime.clone(),
}; };
if runtime_state if runtime_state
@@ -212,7 +216,20 @@ impl ServerManager {
.map(Self::is_running_state) .map(Self::is_running_state)
.unwrap_or(false) .unwrap_or(false)
{ {
if let Err(stop_error) = self.docker.stop_container(&uuid, 30).await { let previous_runtime = existing
.as_ref()
.map(|spec| spec.runtime.clone())
.unwrap_or_else(|| runtime.clone());
if let Err(stop_error) = self
.docker
.stop_container_graceful(
&uuid,
previous_runtime.stop_command.as_deref(),
previous_runtime.stop_timeout_seconds.unwrap_or(0),
)
.await
{
warn!(uuid = %uuid, error = %stop_error, "Graceful stop failed during server update, forcing kill"); warn!(uuid = %uuid, error = %stop_error, "Graceful stop failed during server update, forcing kill");
self.docker.kill_container(&uuid).await.map_err(|e| { self.docker.kill_container(&uuid).await.map_err(|e| {
DaemonError::Internal(format!("Failed to stop running container during update: {}", e)) DaemonError::Internal(format!("Failed to stop running container during update: {}", e))
@@ -335,9 +352,18 @@ impl ServerManager {
} }
/// Stop a server. /// Stop a server.
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> { ///
/// `stop_command` / `stop_timeout_seconds` override whatever was captured
/// when the container was created; pass `None` / `0` to use those defaults.
pub async fn stop_server(
&self,
uuid: &str,
stop_command: Option<&str>,
stop_timeout_seconds: i64,
) -> Result<(), DaemonError> {
let mut managed = false; let mut managed = false;
let mut previous_state: Option<ServerState> = None; let mut previous_state: Option<ServerState> = None;
let mut spec_runtime = ServerRuntime::default();
{ {
let mut servers = self.servers.write().await; let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) { if let Some(spec) = servers.get_mut(uuid) {
@@ -353,12 +379,29 @@ impl ServerManager {
}); });
} }
previous_state = Some(spec.state.clone()); previous_state = Some(spec.state.clone());
spec_runtime = spec.runtime.clone();
spec.state = ServerState::Stopping; spec.state = ServerState::Stopping;
managed = true; managed = true;
} }
} }
if let Err(e) = self.docker.stop_container(uuid, 30).await { let effective_command = stop_command
.map(str::trim)
.filter(|command| !command.is_empty())
.map(str::to_string)
.or_else(|| spec_runtime.stop_command.clone());
let effective_timeout = if stop_timeout_seconds > 0 {
stop_timeout_seconds
} else {
spec_runtime.stop_timeout_seconds.unwrap_or(0)
};
if let Err(e) = self
.docker
.stop_container_graceful(uuid, effective_command.as_deref(), effective_timeout)
.await
{
if managed { if managed {
let mut servers = self.servers.write().await; let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) { if let Some(spec) = servers.get_mut(uuid) {
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod state; pub mod state;
pub mod manager; pub mod manager;
pub use state::{ServerSpec, PortMap}; pub use state::{ServerSpec, ServerRuntime, PortMap};
pub use manager::ServerManager; pub use manager::ServerManager;
+42
View File
@@ -33,6 +33,46 @@ pub struct PortMap {
pub protocol: String, // "tcp" or "udp" pub protocol: String, // "tcp" or "udp"
} }
/// Per-game runtime knobs supplied by the panel. Mirrored into Docker labels so
/// they survive a daemon restart (see `docker::container`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ServerRuntime {
/// Mount point of the data directory inside the container. `None` means
/// "derive it from the image".
pub data_mount_path: Option<String>,
/// In-game command that shuts the server down cleanly (e.g. `stop`, `quit`).
pub stop_command: Option<String>,
/// Total budget for a graceful shutdown before the container gets killed.
pub stop_timeout_seconds: Option<i64>,
}
impl ServerRuntime {
pub fn from_request(
data_mount_path: String,
stop_command: String,
stop_timeout_seconds: i32,
) -> Self {
Self {
data_mount_path: non_empty(data_mount_path),
stop_command: non_empty(stop_command),
stop_timeout_seconds: if stop_timeout_seconds > 0 {
Some(stop_timeout_seconds as i64)
} else {
None
},
}
}
}
fn non_empty(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerSpec { pub struct ServerSpec {
pub uuid: String, pub uuid: String,
@@ -46,6 +86,8 @@ pub struct ServerSpec {
pub data_path: PathBuf, pub data_path: PathBuf,
pub state: ServerState, pub state: ServerState,
pub container_id: Option<String>, pub container_id: Option<String>,
#[serde(default)]
pub runtime: ServerRuntime,
} }
impl ServerSpec { impl ServerSpec {
+14 -1
View File
@@ -28,9 +28,18 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# A power action blocks until the game server has actually shut down.
# ARK saves its world for minutes, so the default 60s would 504 on a
# stop that is still progressing normally.
proxy_read_timeout 400s;
proxy_send_timeout 400s;
# File manager uploads.
client_max_body_size 128m;
} }
# Socket.IO proxy # Socket.IO proxy (live console)
location /socket.io/ { location /socket.io/ {
proxy_pass http://api:3000; proxy_pass http://api:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -39,6 +48,10 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# An idle console must not be torn down every 60s.
proxy_read_timeout 1h;
proxy_send_timeout 1h;
} }
# Static assets caching # Static assets caching
+31 -1
View File
@@ -64,8 +64,9 @@ interface AdditionalPortRequirement {
} }
function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] { function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] {
if (gameSlug.trim().toLowerCase() !== 'satisfactory') return []; const slug = gameSlug.trim().toLowerCase();
if (slug === 'satisfactory') {
return [ return [
{ {
key: 'satisfactory-messaging', key: 'satisfactory-messaging',
@@ -77,6 +78,35 @@ function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequ
]; ];
} }
if (slug === 'ark-se') {
return [
{
key: 'ark-raw-udp',
label: 'Raw UDP Socket Port',
defaultPort: 7778,
protocols: ['udp'],
description: 'ARK opens a second UDP socket, normally the game port + 1.',
},
{
key: 'ark-query',
label: 'Steam Query Port',
defaultPort: 27015,
protocols: ['udp'],
description: 'Used by the Steam server browser to list the server.',
},
{
key: 'ark-rcon',
label: 'RCON Port',
defaultPort: 27020,
protocols: ['tcp'],
description: 'Console commands and the player list are sent over RCON.',
},
];
}
return [];
}
export function CreateServerPage() { export function CreateServerPage() {
const { orgId } = useParams(); const { orgId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
+9
View File
@@ -4,9 +4,18 @@
api_url: "http://api:3000" api_url: "http://api:3000"
node_token: "CHANGE_ME_GENERATE_A_SECURE_TOKEN" node_token: "CHANGE_ME_GENERATE_A_SECURE_TOKEN"
grpc_port: 50051 grpc_port: 50051
# Path inside the daemon container.
data_path: "/var/lib/gamepanel/servers" data_path: "/var/lib/gamepanel/servers"
backup_path: "/var/lib/gamepanel/backups" backup_path: "/var/lib/gamepanel/backups"
# The same directory as seen by the Docker host. Game containers are created
# through the host's Docker socket, so their bind mounts resolve against the
# host filesystem — if this does not match, the panel and the game server end
# up writing to two different directories. docker-compose.yml also supplies
# this as the DAEMON_HOST_DATA_PATH environment variable, which wins.
host_data_path: "/var/lib/gamepanel/servers"
docker: docker:
socket: "/var/run/docker.sock" socket: "/var/run/docker.sock"
network: "gamepanel_nw" network: "gamepanel_nw"
+42 -14
View File
@@ -1,3 +1,12 @@
# GamePanel — single-host deployment.
#
# ./scripts/install.sh generates .env with fresh secrets
# docker compose up -d builds and starts everything
#
# Serves plain HTTP on ${WEB_PORT} by design. Put your own reverse proxy
# (Caddy, nginx, Traefik, Cloudflare Tunnel…) in front of it for TLS and a
# domain — see INSTALLATION.md.
services: services:
# --- PostgreSQL --- # --- PostgreSQL ---
postgres: postgres:
@@ -10,8 +19,9 @@ services:
POSTGRES_DB: ${DB_NAME:-gamepanel} POSTGRES_DB: ${DB_NAME:-gamepanel}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: # Not published by default — only the API needs it. Set DB_PORT to expose it.
- "${DB_PORT:-5432}:5432" expose:
- "5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
interval: 10s interval: 10s
@@ -26,14 +36,28 @@ services:
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-gamepanel} command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-gamepanel}
volumes: volumes:
- redis_data:/data - redis_data:/data
ports: expose:
- "${REDIS_PORT:-6379}:6379" - "6379"
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-gamepanel}", "ping"] test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-gamepanel}", "ping"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
# --- Schema migration + seed (runs to completion, then exits) ---
migrate:
build:
context: .
dockerfile: apps/api/Dockerfile
target: migrate
container_name: gamepanel-migrate
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD:-gamepanel}@postgres:5432/${DB_NAME:-gamepanel}
# --- API --- # --- API ---
api: api:
build: build:
@@ -46,21 +70,23 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
migrate:
condition: service_completed_successfully
environment: environment:
NODE_ENV: production NODE_ENV: production
DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD:-gamepanel}@postgres:5432/${DB_NAME:-gamepanel} DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD:-gamepanel}@postgres:5432/${DB_NAME:-gamepanel}
REDIS_URL: redis://:${REDIS_PASSWORD:-gamepanel}@redis:6379 REDIS_URL: redis://:${REDIS_PASSWORD:-gamepanel}@redis:6379
PORT: 3000 PORT: 3000
HOST: 0.0.0.0 HOST: 0.0.0.0
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env — run ./scripts/install.sh}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?set JWT_REFRESH_SECRET in .env — run ./scripts/install.sh}
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost} CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost}
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-100} RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-100}
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-60000} RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-60000}
ports: expose:
- "${API_PORT:-3000}:3000" - "3000"
# --- Web (nginx + SPA) --- # --- Web (nginx + SPA, also reverse-proxies /api and /socket.io) ---
web: web:
build: build:
context: . context: .
@@ -83,13 +109,17 @@ services:
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- api - api
privileged: true
environment: environment:
DAEMON_CONFIG: /etc/gamepanel/config.yml DAEMON_CONFIG: /etc/gamepanel/config.yml
# Game containers are created through the host's Docker socket, so their
# bind mounts are resolved by the *host*, not by this container. Without
# this the daemon and the game would end up looking at two different
# directories and every file written from the panel would vanish.
DAEMON_HOST_DATA_PATH: ${DAEMON_DATA_PATH:-/var/lib/gamepanel/servers}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- daemon_data:/var/lib/gamepanel/servers - ${DAEMON_DATA_PATH:-/var/lib/gamepanel/servers}:/var/lib/gamepanel/servers
- daemon_backups:/var/lib/gamepanel/backups - ${DAEMON_BACKUP_PATH:-/var/lib/gamepanel/backups}:/var/lib/gamepanel/backups
- ./daemon-config.yml:/etc/gamepanel/config.yml:ro - ./daemon-config.yml:/etc/gamepanel/config.yml:ro
ports: ports:
- "${DAEMON_GRPC_PORT:-50051}:50051" - "${DAEMON_GRPC_PORT:-50051}:50051"
@@ -97,5 +127,3 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
daemon_data:
daemon_backups:
@@ -0,0 +1,157 @@
-- Per-game shutdown controls and container mount overrides.
ALTER TABLE "games"
ADD COLUMN IF NOT EXISTS "stop_timeout_seconds" integer NOT NULL DEFAULT 30;
ALTER TABLE "games"
ADD COLUMN IF NOT EXISTS "container_data_path" text;
-- Mount points the daemon previously derived from the image name. Storing them
-- makes the mapping visible and editable instead of hardcoded.
UPDATE "games" SET "container_data_path" = '/home/steam/cs2-dedicated' WHERE "slug" = 'cs2' AND "container_data_path" IS NULL;
UPDATE "games" SET "container_data_path" = '/config' WHERE "slug" IN ('fivem', 'satisfactory') AND "container_data_path" IS NULL;
UPDATE "games" SET "container_data_path" = '/data' WHERE "slug" IN ('minecraft-java', 'minecraft-bedrock', 'terraria', 'rust') AND "container_data_path" IS NULL;
-- Shutdown budgets. Source servers quit instantly once they get the command;
-- ARK and Satisfactory need to flush a world save first.
UPDATE "games" SET "stop_timeout_seconds" = 60 WHERE "slug" IN ('minecraft-java', 'minecraft-bedrock');
UPDATE "games" SET "stop_timeout_seconds" = 120 WHERE "slug" = 'satisfactory';
-- ARK: Survival Evolved
INSERT INTO "games" (
"slug",
"name",
"docker_image",
"default_port",
"config_files",
"automation_rules",
"startup_command",
"stop_command",
"stop_timeout_seconds",
"container_data_path",
"environment_vars",
"created_at",
"updated_at"
)
VALUES (
'ark-se',
'ARK: Survival Evolved',
'hermsi/ark-server:latest',
7777,
'[
{
"path": "server/ShooterGame/Saved/Config/LinuxServer/GameUserSettings.ini",
"parser": "properties",
"editableKeys": [
"SessionName",
"ServerPassword",
"ServerAdminPassword",
"MaxPlayers",
"ServerPVE",
"ServerCrosshair",
"ServerHardcore",
"AllowThirdPersonPlayer",
"ShowMapPlayerLocation",
"GlobalVoiceChat",
"ProximityChat",
"NoTributeDownloads",
"AllowAnyoneBabyImprintCuddle",
"DifficultyOffset",
"XPMultiplier",
"TamingSpeedMultiplier",
"HarvestAmountMultiplier",
"DayCycleSpeedScale",
"NightTimeSpeedScale",
"PlayerCharacterWaterDrainMultiplier",
"PlayerCharacterFoodDrainMultiplier",
"StructureDamageMultiplier",
"StructureResistanceMultiplier",
"RCONEnabled",
"RCONPort"
]
},
{
"path": "server/ShooterGame/Saved/Config/LinuxServer/Game.ini",
"parser": "properties"
}
]'::jsonb,
'[]'::jsonb,
'',
'',
300,
'/app',
'[
{
"key": "SESSION_NAME",
"default": "SourceGamePanel ARK Server",
"description": "Server name shown in the ARK server browser",
"required": true
},
{
"key": "SERVER_MAP",
"default": "TheIsland",
"description": "Map to load (TheIsland, TheCenter, Ragnarok, Valguero, CrystalIsles, ...)",
"required": true
},
{
"key": "ADMIN_PASSWORD",
"default": "",
"description": "Admin/RCON password. Required — RCON console commands use it.",
"required": true
},
{
"key": "SERVER_PASSWORD",
"default": "",
"description": "Password players need to join. Leave empty for a public server.",
"required": false
},
{
"key": "MAX_PLAYERS",
"default": "20",
"description": "Maximum player count",
"required": false
},
{
"key": "UPDATE_ON_START",
"label": "Update on start",
"default": "false",
"description": "Run a SteamCMD update every time the server starts",
"required": false,
"inputType": "boolean",
"enabledLabel": "Aktif",
"disabledLabel": "Pasif"
},
{
"key": "BACKUP_ON_STOP",
"label": "Backup on stop",
"default": "false",
"description": "Create a world backup during shutdown (makes stopping slower)",
"required": false,
"inputType": "boolean",
"enabledLabel": "Aktif",
"disabledLabel": "Pasif"
},
{
"key": "WARN_ON_STOP",
"label": "Warn players on stop",
"default": "false",
"description": "Broadcast a shutdown warning to connected players before stopping",
"required": false,
"inputType": "boolean",
"enabledLabel": "Aktif",
"disabledLabel": "Pasif"
},
{
"key": "PRE_UPDATE_BACKUP",
"label": "Backup before update",
"default": "true",
"description": "Back the world up before applying a SteamCMD update",
"required": false,
"inputType": "boolean",
"enabledLabel": "Aktif",
"disabledLabel": "Pasif"
}
]'::jsonb,
NOW(),
NOW()
)
ON CONFLICT ("slug") DO NOTHING;
+3 -1
View File
@@ -9,7 +9,9 @@
"build": "tsc", "build": "tsc",
"lint": "eslint src/", "lint": "eslint src/",
"db:generate": "dotenv -e ../../.env -- drizzle-kit generate", "db:generate": "dotenv -e ../../.env -- drizzle-kit generate",
"db:migrate": "dotenv -e ../../.env -- drizzle-kit migrate", "db:push": "dotenv -e ../../.env -- drizzle-kit push --force",
"db:migrate": "pnpm db:push && dotenv -e ../../.env -- tsx src/migrate.ts",
"db:migrate:data": "dotenv -e ../../.env -- tsx src/migrate.ts",
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts", "db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
"db:studio": "dotenv -e ../../.env -- drizzle-kit studio" "db:studio": "dotenv -e ../../.env -- drizzle-kit studio"
}, },
+84
View File
@@ -0,0 +1,84 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import postgres from 'postgres';
/**
* Applies the hand-written data migrations in `drizzle/*.sql`.
*
* Table structure itself comes from `drizzle-kit push`, which diffs the live
* database against `src/schema` that keeps a fresh install working without
* shipping a full generated migration chain. These SQL files carry the data
* changes push cannot know about (default game rows, automation rule updates).
*
* Every file is recorded in `gamepanel_data_migrations`, so re-running is safe.
*/
const MIGRATIONS_TABLE = 'gamepanel_data_migrations';
async function main() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL is required');
process.exit(1);
}
const migrationsDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'drizzle',
);
let files: string[];
try {
files = (await readdir(migrationsDir))
.filter((file) => file.endsWith('.sql'))
.sort();
} catch {
console.log('No data migrations directory found, nothing to apply.');
return;
}
if (files.length === 0) {
console.log('No data migrations to apply.');
return;
}
const sql = postgres(databaseUrl, { max: 1 });
try {
await sql.unsafe(`
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
name text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT NOW()
)
`);
const applied = await sql.unsafe<{ name: string }[]>(
`SELECT name FROM ${MIGRATIONS_TABLE}`,
);
const appliedNames = new Set(applied.map((row) => row.name));
for (const file of files) {
if (appliedNames.has(file)) continue;
const contents = await readFile(path.join(migrationsDir, file), 'utf8');
if (!contents.trim()) continue;
console.log(`Applying data migration: ${file}`);
await sql.begin(async (tx) => {
await tx.unsafe(contents);
await tx.unsafe(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`, [file]);
});
}
console.log('Data migrations up to date.');
} finally {
await sql.end();
}
}
main().catch((error) => {
console.error('Data migration failed:', error);
process.exit(1);
});
+5
View File
@@ -10,6 +10,11 @@ export const games = pgTable('games', {
automationRules: jsonb('automation_rules').default([]).notNull(), automationRules: jsonb('automation_rules').default([]).notNull(),
startupCommand: text('startup_command').notNull(), startupCommand: text('startup_command').notNull(),
stopCommand: text('stop_command'), stopCommand: text('stop_command'),
/// Total budget for a graceful shutdown before the container is killed.
stopTimeoutSeconds: integer('stop_timeout_seconds').default(30).notNull(),
/// Mount point of the server data directory inside the container. Null means
/// "let the daemon derive it from the image".
containerDataPath: text('container_data_path'),
environmentVars: jsonb('environment_vars').default([]).notNull(), environmentVars: jsonb('environment_vars').default([]).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
+137
View File
@@ -100,6 +100,8 @@ async function seed() {
defaultPort: 25565, defaultPort: 25565,
startupCommand: '/start', startupCommand: '/start',
stopCommand: 'stop', stopCommand: 'stop',
stopTimeoutSeconds: 60,
containerDataPath: '/data',
configFiles: [ configFiles: [
{ {
path: 'server.properties', path: 'server.properties',
@@ -147,6 +149,8 @@ async function seed() {
defaultPort: 27015, defaultPort: 27015,
startupCommand: '', startupCommand: '',
stopCommand: 'quit', stopCommand: 'quit',
stopTimeoutSeconds: 30,
containerDataPath: '/home/steam/cs2-dedicated',
configFiles: [ configFiles: [
{ {
path: 'game/csgo/cfg/server.cfg', path: 'game/csgo/cfg/server.cfg',
@@ -332,6 +336,8 @@ async function seed() {
defaultPort: 19132, defaultPort: 19132,
startupCommand: '', startupCommand: '',
stopCommand: 'stop', stopCommand: 'stop',
stopTimeoutSeconds: 60,
containerDataPath: '/data',
configFiles: [ configFiles: [
{ {
path: 'server.properties', path: 'server.properties',
@@ -366,6 +372,8 @@ async function seed() {
defaultPort: 7777, defaultPort: 7777,
startupCommand: '', startupCommand: '',
stopCommand: 'exit', stopCommand: 'exit',
stopTimeoutSeconds: 45,
containerDataPath: '/data',
configFiles: [ configFiles: [
{ {
path: 'serverconfig.txt', path: 'serverconfig.txt',
@@ -391,6 +399,8 @@ async function seed() {
defaultPort: 28015, defaultPort: 28015,
startupCommand: '', startupCommand: '',
stopCommand: 'quit', stopCommand: 'quit',
stopTimeoutSeconds: 60,
containerDataPath: '/data',
configFiles: [], configFiles: [],
environmentVars: [ environmentVars: [
{ {
@@ -421,6 +431,8 @@ async function seed() {
defaultPort: 7777, defaultPort: 7777,
startupCommand: '', startupCommand: '',
stopCommand: 'quit', stopCommand: 'quit',
stopTimeoutSeconds: 120,
containerDataPath: '/config',
configFiles: [], configFiles: [],
automationRules: [], automationRules: [],
environmentVars: [ environmentVars: [
@@ -461,6 +473,8 @@ async function seed() {
defaultPort: 30120, defaultPort: 30120,
startupCommand: '', startupCommand: '',
stopCommand: 'quit', stopCommand: 'quit',
stopTimeoutSeconds: 30,
containerDataPath: '/config',
configFiles: [ configFiles: [
{ {
path: 'server.cfg', path: 'server.cfg',
@@ -486,6 +500,129 @@ async function seed() {
}, },
], ],
}, },
{
slug: 'ark-se',
name: 'ARK: Survival Evolved',
dockerImage: 'hermsi/ark-server:latest',
defaultPort: 7777,
startupCommand: '',
// The image traps SIGTERM and runs `arkmanager stop --saveworld`, so a
// long SIGTERM budget is the correct shutdown path here.
stopCommand: '',
stopTimeoutSeconds: 300,
containerDataPath: '/app',
configFiles: [
{
path: 'server/ShooterGame/Saved/Config/LinuxServer/GameUserSettings.ini',
parser: 'properties',
editableKeys: [
'SessionName',
'ServerPassword',
'ServerAdminPassword',
'MaxPlayers',
'ServerPVE',
'ServerCrosshair',
'ServerHardcore',
'AllowThirdPersonPlayer',
'ShowMapPlayerLocation',
'GlobalVoiceChat',
'ProximityChat',
'NoTributeDownloads',
'AllowAnyoneBabyImprintCuddle',
'DifficultyOffset',
'XPMultiplier',
'TamingSpeedMultiplier',
'HarvestAmountMultiplier',
'DayCycleSpeedScale',
'NightTimeSpeedScale',
'PlayerCharacterWaterDrainMultiplier',
'PlayerCharacterFoodDrainMultiplier',
'StructureDamageMultiplier',
'StructureResistanceMultiplier',
'RCONEnabled',
'RCONPort',
],
},
{
path: 'server/ShooterGame/Saved/Config/LinuxServer/Game.ini',
parser: 'properties',
},
],
automationRules: [],
environmentVars: [
{
key: 'SESSION_NAME',
default: 'SourceGamePanel ARK Server',
description: 'Server name shown in the ARK server browser',
required: true,
},
{
key: 'SERVER_MAP',
default: 'TheIsland',
description:
'Map to load (TheIsland, TheCenter, Ragnarok, Valguero, CrystalIsles, ...)',
required: true,
},
{
key: 'ADMIN_PASSWORD',
default: '',
description: 'Admin/RCON password. Required — RCON console commands use it.',
required: true,
},
{
key: 'SERVER_PASSWORD',
default: '',
description: 'Password players need to join. Leave empty for a public server.',
required: false,
},
{
key: 'MAX_PLAYERS',
default: '20',
description: 'Maximum player count',
required: false,
},
{
key: 'UPDATE_ON_START',
label: 'Update on start',
default: 'false',
description: 'Run a SteamCMD update every time the server starts',
required: false,
inputType: 'boolean',
enabledLabel: 'Aktif',
disabledLabel: 'Pasif',
},
{
key: 'BACKUP_ON_STOP',
label: 'Backup on stop',
default: 'false',
description: 'Create a world backup during shutdown (makes stopping slower)',
required: false,
inputType: 'boolean',
enabledLabel: 'Aktif',
disabledLabel: 'Pasif',
},
{
key: 'WARN_ON_STOP',
label: 'Warn players on stop',
default: 'false',
description: 'Broadcast a shutdown warning to connected players before stopping',
required: false,
inputType: 'boolean',
enabledLabel: 'Aktif',
disabledLabel: 'Pasif',
},
{
key: 'PRE_UPDATE_BACKUP',
label: 'Backup before update',
default: 'true',
description: 'Back the world up before applying a SteamCMD update',
required: false,
inputType: 'boolean',
enabledLabel: 'Aktif',
disabledLabel: 'Pasif',
},
],
},
]) ])
.onConflictDoNothing(); .onConflictDoNothing();
+14
View File
@@ -44,6 +44,13 @@ message CreateServerRequest {
map<string, string> environment = 7; map<string, string> environment = 7;
repeated PortMapping ports = 8; repeated PortMapping ports = 8;
repeated string install_plugin_urls = 9; repeated string install_plugin_urls = 9;
// Mount point of the server data directory inside the container.
// Empty means "derive from the image".
string data_path = 10;
// In-game command that shuts the server down cleanly (e.g. "stop", "quit").
string stop_command = 11;
// Total budget for a graceful shutdown before the container is killed.
int32 stop_timeout_seconds = 12;
} }
message UpdateServerRequest { message UpdateServerRequest {
@@ -55,6 +62,9 @@ message UpdateServerRequest {
string startup_command = 6; string startup_command = 6;
map<string, string> environment = 7; map<string, string> environment = 7;
repeated PortMapping ports = 8; repeated PortMapping ports = 8;
string data_path = 9;
string stop_command = 10;
int32 stop_timeout_seconds = 11;
} }
message ServerResponse { message ServerResponse {
@@ -106,6 +116,10 @@ enum PowerAction {
message PowerRequest { message PowerRequest {
string uuid = 1; string uuid = 1;
PowerAction action = 2; PowerAction action = 2;
// Optional per-request overrides. When empty the daemon falls back to the
// values captured when the container was created.
string stop_command = 3;
int32 stop_timeout_seconds = 4;
} }
// === Server Status === // === Server Status ===
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
#
# GamePanel — one-shot Docker setup.
#
# ./scripts/install.sh
# docker compose up -d --build
#
# Writes a .env with generated secrets and a daemon-config.yml with a matching
# node token. Deliberately does NOT set up TLS or a domain: the panel serves
# plain HTTP and you put your own reverse proxy in front of it.
set -euo pipefail
cd "$(dirname "$0")/.."
ENV_FILE=".env"
DAEMON_CONFIG="daemon-config.yml"
random_hex() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex "$1"
else
head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
fi
}
if [ -f "$ENV_FILE" ]; then
echo "$ENV_FILE already exists — leaving it untouched."
else
echo "Generating $ENV_FILE ..."
JWT_SECRET="$(random_hex 64)"
JWT_REFRESH_SECRET="$(random_hex 64)"
DB_PASSWORD="$(random_hex 24)"
REDIS_PASSWORD="$(random_hex 24)"
DAEMON_TOKEN="$(random_hex 32)"
WEB_PORT="${WEB_PORT:-80}"
cat > "$ENV_FILE" <<EOF
# Generated by scripts/install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
# --- Database ---
DB_USER=gamepanel
DB_PASSWORD=${DB_PASSWORD}
DB_NAME=gamepanel
# --- Redis ---
REDIS_PASSWORD=${REDIS_PASSWORD}
# --- API ---
JWT_SECRET=${JWT_SECRET}
JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW_MS=60000
# --- Web ---
# Port the panel listens on. Point your reverse proxy here for TLS/domain.
WEB_PORT=${WEB_PORT}
# Must match the origin users open in the browser, otherwise CORS blocks login.
CORS_ORIGIN=http://localhost:${WEB_PORT}
# --- Daemon ---
DAEMON_GRPC_PORT=50051
DAEMON_TOKEN=${DAEMON_TOKEN}
# Host directories holding game server files. These are bind-mounted, and the
# daemon passes the same paths to the host Docker engine when it creates game
# containers — they must be real host paths.
DAEMON_DATA_PATH=/var/lib/gamepanel/servers
DAEMON_BACKUP_PATH=/var/lib/gamepanel/backups
EOF
chmod 600 "$ENV_FILE"
echo " secrets generated"
fi
# shellcheck disable=SC1090
set -a; . "./$ENV_FILE"; set +a
DATA_PATH="${DAEMON_DATA_PATH:-/var/lib/gamepanel/servers}"
BACKUP_PATH="${DAEMON_BACKUP_PATH:-/var/lib/gamepanel/backups}"
echo "Creating host data directories ..."
mkdir -p "$DATA_PATH" "$BACKUP_PATH" 2>/dev/null || {
echo " could not create $DATA_PATH / $BACKUP_PATH — rerun with sudo, or set"
echo " DAEMON_DATA_PATH / DAEMON_BACKUP_PATH in $ENV_FILE to a writable path."
exit 1
}
if [ -f "$DAEMON_CONFIG" ] && ! grep -q 'CHANGE_ME_GENERATE_A_SECURE_TOKEN' "$DAEMON_CONFIG"; then
echo "$DAEMON_CONFIG already configured — leaving it untouched."
else
# A pre-existing .env may predate DAEMON_TOKEN; mint one and record it so the
# panel and the daemon agree on the same value.
if [ -z "${DAEMON_TOKEN:-}" ]; then
DAEMON_TOKEN="$(random_hex 32)"
printf '\nDAEMON_TOKEN=%s\n' "$DAEMON_TOKEN" >> "$ENV_FILE"
echo "Added a generated DAEMON_TOKEN to $ENV_FILE"
fi
echo "Writing $DAEMON_CONFIG ..."
cat > "$DAEMON_CONFIG" <<EOF
# Daemon configuration — mounted into the daemon container.
# Generated by scripts/install.sh; safe to edit.
api_url: "http://api:3000"
node_token: "${DAEMON_TOKEN}"
grpc_port: 50051
# Path inside the daemon container.
data_path: "/var/lib/gamepanel/servers"
backup_path: "/var/lib/gamepanel/backups"
# Same directory as seen by the Docker host. Game containers are created
# through the host's Docker socket, so their bind mounts resolve against the
# host filesystem — if this is wrong, files written from the panel never reach
# the game server. docker-compose.yml also passes this as DAEMON_HOST_DATA_PATH.
host_data_path: "${DATA_PATH}"
docker:
socket: "/var/run/docker.sock"
network: "gamepanel_nw"
network_subnet: "172.18.0.0/16"
# Optional node-local MySQL/MariaDB for server databases. Remove this block if
# you do not need managed databases.
# managed_mysql:
# url: "mysql://root:change-me@127.0.0.1:3306/mysql"
# connection_host: "CHANGE_ME_REACHABLE_FROM_GAME_CONTAINERS"
# connection_port: 3306
# phpmyadmin_url: "http://127.0.0.1:8080/"
EOF
fi
cat <<EOF
Setup complete.
1. docker compose up -d --build
2. open http://localhost:${WEB_PORT:-80}
3. sign in as admin@gamepanel.local / admin123 and change the password
Register the node in the panel with:
FQDN host.docker.internal (or this host's IP/hostname)
gRPC port ${DAEMON_GRPC_PORT:-50051}
Token the DAEMON_TOKEN value in .env
TLS and domains are intentionally not configured — put your own reverse proxy
in front of port ${WEB_PORT:-80}. See INSTALLATION.md.
EOF