diff --git a/.env.example b/.env.example index 4952092..c7ff83f 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,17 @@ WEB_PORT=80 # --- Daemon --- DAEMON_CONFIG=/etc/gamepanel/config.yml DAEMON_GRPC_PORT=50051 +DAEMON_TOKEN=CHANGE_ME_GENERATE_A_SECURE_TOKEN +# Host directories for game server files and backups. The daemon hands these +# exact paths to the host Docker engine when creating game containers, so they +# must exist on the host — not inside the daemon container. +DAEMON_DATA_PATH=/var/lib/gamepanel/servers +DAEMON_BACKUP_PATH=/var/lib/gamepanel/backups + +# --- Managed config persistence --- +# How long the panel keeps restoring panel-managed config files after a start. +# Steam images can re-validate for a long time before overwriting them. +MANAGED_CONFIG_SUSTAIN_MS=1800000 # --- CDN (Plugin Artifacts) --- CDN_BASE_URL=https://cdn.hibna.com.tr diff --git a/.gitignore b/.gitignore index 903c0c3..b78c161 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,10 @@ Thumbs.db apps/daemon/target/ # Database -packages/database/drizzle/* -!packages/database/drizzle/0007_satisfactory_game.sql +# Hand-written data migrations in drizzle/*.sql are part of the repo — the +# schema itself is applied with `drizzle-kit push`, so only drizzle-kit's local +# snapshot files are noise. +packages/database/drizzle/meta/*_snapshot.json # Common JS/TS coverage/ diff --git a/INSTALLATION.md b/INSTALLATION.md index af5a2d6..e202563 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -65,19 +65,21 @@ docker compose -f docker-compose.dev.yml up -d ### 1.4 Database Setup ```bash -# Generate migration files (if schema changed) -pnpm db:generate - -# Apply migrations to create all tables +# Sync the schema from packages/database/src/schema, then apply the +# hand-written data migrations in packages/database/drizzle/*.sql pnpm db:migrate # Seed admin user and default games pnpm db:seed ``` +All three steps are idempotent, so re-running them after a `git pull` is the +normal way to pick up schema and default-game changes. + After seeding, you'll have: - **Admin account**: `admin@gamepanel.local` / `admin123` -- **Games**: Minecraft Java, CS2, Minecraft Bedrock, Terraria, Rust +- **Games**: Minecraft Java & Bedrock, CS2, Terraria, Rust, Satisfactory, + FiveM, ARK: Survival Evolved ### 1.5 Start Development Servers @@ -129,123 +131,156 @@ cargo build --release # Production build ## 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 git clone https://github.com/your-org/source-gamepanel.git cd source-gamepanel -cp .env.example .env -``` - -Edit `.env` with production values: - -```env -# REQUIRED — Generate unique secrets for each! -JWT_SECRET= -JWT_REFRESH_SECRET= - -# Database -DB_USER=gamepanel -DB_PASSWORD= -DB_NAME=gamepanel - -# Redis -REDIS_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: "" -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 +./scripts/install.sh 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://:80` and sign in with +`admin@gamepanel.local` / `admin123` — change the password immediately. + +### 2.2 What gets started + | Service | Port | Description | |---------|------|-------------| -| `postgres` | 5432 | PostgreSQL database | -| `redis` | 6379 | Rate limiting & cache | -| `api` | 3000 | Fastify REST API | -| `web` | 80 | nginx + React SPA | -| `daemon` | 50051 | Rust gRPC daemon | +| `postgres` | internal | PostgreSQL database | +| `redis` | internal | Rate limiting & cache | +| `migrate` | — | Applies the schema + seed, then exits | +| `api` | internal | Fastify REST API | +| `web` | `WEB_PORT` (80) | nginx + React SPA, proxies `/api` and `/socket.io` | +| `daemon` | `DAEMON_GRPC_PORT` (50051) | Rust gRPC daemon | -### 2.4 Initialize Database +Only `web` and `daemon` publish ports. Postgres, Redis and the API stay on the +internal Compose network. -```bash -# Run migrations -docker compose exec api node -e " - import('drizzle-kit').then(m => console.log('Use drizzle-kit migrate')) -" +The `migrate` service runs on every `docker compose up`; all three of its steps +(`drizzle-kit push`, the data migrations, the seed) are idempotent. -# Or use the pnpm scripts with the container's DATABASE_URL -docker compose exec api sh -c 'cd /app && node apps/api/dist/index.js' -``` +### 2.3 Register the node -For the initial setup, the easiest approach is: +In the panel, create a node with: -```bash -# Run migrations from your host machine pointed at the Docker PostgreSQL -DATABASE_URL=postgresql://gamepanel:@localhost:5432/gamepanel pnpm db:migrate -DATABASE_URL=postgresql://gamepanel:@localhost:5432/gamepanel pnpm db:seed -``` +| Field | Value | +|-------|-------| +| FQDN | `host.docker.internal` (or the host's IP/hostname) | +| gRPC port | the `DAEMON_GRPC_PORT` from `.env` | +| Daemon token | the `DAEMON_TOKEN` from `.env` | + +### 2.4 Where game server files live + +`DAEMON_DATA_PATH` in `.env` (default `/var/lib/gamepanel/servers`) is a **host** +directory. The daemon runs in a container but creates game containers through +the host's Docker socket, so their bind mounts are resolved by the host, not by +the daemon container. + +That is why the same path is passed twice — once as the daemon's own bind mount +and once as `DAEMON_HOST_DATA_PATH`. If you change `DAEMON_DATA_PATH`, both +follow automatically. Do not replace the bind mount with a named volume: the +daemon and the game servers would then read and write two different +directories, and files edited in the panel would never reach the game. ### 2.5 Verify ```bash -# Check all services are healthy docker compose ps - -# Test API health -curl http://localhost:3000/api/health -# {"status":"ok","timestamp":"2025-..."} - -# Test web -curl -s http://localhost | head -5 -# ... -``` - -### 2.6 Monitoring - -```bash -# View logs docker compose logs -f api docker compose logs -f daemon -docker compose logs -f web -# Restart a service -docker compose restart api +curl -s http://localhost/api/health +# {"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 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+) diff --git a/README.md b/README.md index bfa3cb2..ab7f60a 100644 --- a/README.md +++ b/README.md @@ -159,8 +159,12 @@ source-gamepanel/ | Rust | `didstopia/rust-server` | 28015 | — | — | | Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — | | FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — | +| ARK: Survival Evolved | `hermsi/ark-server` | 7777/udp + 7778/udp + 27015/udp + 27020/tcp | `GameUserSettings.ini`, `Game.ini` | — | -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 ```bash -# Configure environment -cp .env.example .env -# Edit .env with production values (strong JWT secrets, real DB passwords) +git clone https://github.com/your-org/source-gamepanel.git +cd source-gamepanel -# 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 - -# 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://` 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. --- diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 9b8e1ac..24e21e0 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -22,6 +22,23 @@ RUN pnpm --filter @source/shared build && \ pnpm --filter @source/database build && \ pnpm --filter @source/api build +# --- Migrate + seed (one-shot) --- +# Schema comes from `drizzle-kit push` against src/schema, then the repo's +# data migrations, then the idempotent seed. All three are safe to re-run, so +# this container can start on every `docker compose up`. +FROM base AS migrate +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules +COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY packages/shared ./packages/shared +COPY packages/database ./packages/database + +WORKDIR /app/packages/database +CMD ["sh", "-c", "pnpm exec drizzle-kit push --force && pnpm exec tsx src/migrate.ts && pnpm exec tsx src/seed.ts"] + # --- Production --- FROM node:20-alpine AS production RUN corepack enable && corepack prepare pnpm@9.15.4 --activate diff --git a/apps/api/src/lib/cs2-server-config.ts b/apps/api/src/lib/cs2-server-config.ts deleted file mode 100644 index 841fcd0..0000000 --- a/apps/api/src/lib/cs2-server-config.ts +++ /dev/null @@ -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 { - 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 { - 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 { - const content = await readManagedCs2ServerConfig(node, serverUuid); - await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content); -} diff --git a/apps/api/src/lib/daemon.ts b/apps/api/src/lib/daemon.ts index 9c89769..00ec3d0 100644 --- a/apps/api/src/lib/daemon.ts +++ b/apps/api/src/lib/daemon.ts @@ -25,6 +25,9 @@ export interface DaemonCreateServerRequest { environment: Record; ports: DaemonPortMapping[]; install_plugin_urls: string[]; + data_path: string; + stop_command: string; + stop_timeout_seconds: number; } export interface DaemonUpdateServerRequest { @@ -36,6 +39,16 @@ export interface DaemonUpdateServerRequest { startup_command: string; environment: Record; 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 { @@ -216,7 +229,12 @@ interface DaemonServiceClient extends grpc.Client { callback: UnaryCallback, ): void; setPowerState( - request: { uuid: string; action: number }, + request: { + uuid: string; + action: number; + stop_command: string; + stop_timeout_seconds: number; + }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; @@ -437,6 +455,7 @@ function toBuffer(data: Uint8Array | Buffer): Buffer { const DEFAULT_CONNECT_TIMEOUT_MS = 8_000; const DEFAULT_RPC_TIMEOUT_MS = 20_000; const POWER_RPC_TIMEOUT_MS = 45_000; +const MAX_POWER_RPC_TIMEOUT_MS = 360_000; interface DaemonRequestTimeoutOptions { connectTimeoutMs?: number; @@ -641,18 +660,35 @@ export async function daemonSetPowerState( node: DaemonNodeConnection, serverUuid: string, action: PowerAction, + options: DaemonPowerOptions = {}, ): Promise { + const stopTimeoutSeconds = Number(options.stopTimeoutSeconds) > 0 + ? Math.floor(Number(options.stopTimeoutSeconds)) + : 0; + + // The daemon waits out the shutdown before replying, so the RPC deadline has + // to outlive the game's own budget (ARK saves its world for minutes). + const rpcTimeoutMs = + action === 'stop' || action === 'restart' + ? Math.min(Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS), MAX_POWER_RPC_TIMEOUT_MS) + : POWER_RPC_TIMEOUT_MS; + const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( (callback) => 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), callback, ), - POWER_RPC_TIMEOUT_MS, + rpcTimeoutMs, ); } finally { client.close(); diff --git a/apps/api/src/lib/managed-config.ts b/apps/api/src/lib/managed-config.ts new file mode 100644 index 0000000..cc7ef7d --- /dev/null +++ b/apps/api/src/lib/managed-config.ts @@ -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 = { + 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 { + 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 { + 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(); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function restoreDriftedFile( + node: DaemonNodeConnection, + serverUuid: string, + file: ManagedConfigFile, +): Promise { + 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 { + 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; + }, +): 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); + } + } + })(); +} diff --git a/apps/api/src/lib/server-automation.ts b/apps/api/src/lib/server-automation.ts index 0bd8cfa..2851aa7 100644 --- a/apps/api/src/lib/server-automation.ts +++ b/apps/api/src/lib/server-automation.ts @@ -22,7 +22,7 @@ import { CS2_PERSISTED_SERVER_CFG_PATH, CS2_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG, -} from './cs2-server-config.js'; +} from './managed-config.js'; const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024; const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000; diff --git a/apps/api/src/plugins/socket.ts b/apps/api/src/plugins/socket.ts index a69951c..ccbd9b5 100644 --- a/apps/api/src/plugins/socket.ts +++ b/apps/api/src/plugins/socket.ts @@ -253,8 +253,11 @@ export default fp(async (app: FastifyInstance) => { { error, serverId, serverUuid: server.serverUuid, socketId: socket.id }, 'Failed to send console command', ); - socket.emit('server:console:output', { line: '[error] Failed to send command' }); - const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Failed to send command' }; + // The daemon explains *why* (server not running, no RCON password, …) — + // showing that beats a generic failure the user cannot act on. + const reason = daemonErrorReason(error); + socket.emit('server:console:output', { line: `[error] ${reason}` }); + const ack: ConsoleCommandAck = { requestId, ok: false, error: reason }; socket.emit('server:console:command:ack', ack); } }); @@ -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( app: FastifyInstance, user: AccessTokenPayload, diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index eb1b6f7..d89295e 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -244,6 +244,8 @@ export default async function adminRoutes(app: FastifyInstance) { defaultPort: number; startupCommand: string; stopCommand?: string; + stopTimeoutSeconds?: number; + containerDataPath?: string; configFiles?: unknown[]; environmentVars?: unknown[]; automationRules?: unknown[]; diff --git a/apps/api/src/routes/admin/schemas.ts b/apps/api/src/routes/admin/schemas.ts index 320fabb..33290d3 100644 --- a/apps/api/src/routes/admin/schemas.ts +++ b/apps/api/src/routes/admin/schemas.ts @@ -8,6 +8,8 @@ export const CreateGameSchema = { defaultPort: Type.Number({ minimum: 1, maximum: 65535 }), startupCommand: Type.String({ minLength: 1 }), stopCommand: Type.Optional(Type.String()), + stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })), + containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })), configFiles: Type.Optional(Type.Array(Type.Any())), environmentVars: Type.Optional(Type.Array(Type.Any())), automationRules: Type.Optional(Type.Array(Type.Any())), @@ -21,6 +23,8 @@ export const UpdateGameSchema = { defaultPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })), startupCommand: Type.Optional(Type.String({ minLength: 1 })), stopCommand: Type.Optional(Type.String()), + stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })), + containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })), configFiles: Type.Optional(Type.Array(Type.Any())), environmentVars: Type.Optional(Type.Array(Type.Any())), automationRules: Type.Optional(Type.Array(Type.Any())), diff --git a/apps/api/src/routes/servers/config.ts b/apps/api/src/routes/servers/config.ts index 3f366b9..d837270 100644 --- a/apps/api/src/routes/servers/config.ts +++ b/apps/api/src/routes/servers/config.ts @@ -8,10 +8,10 @@ import { requirePermission } from '../../lib/permissions.js'; import { parseConfig, serializeConfig } from '../../lib/config-parsers.js'; import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js'; import { - isManagedCs2ServerConfigPath, - readManagedCs2ServerConfig, - writeManagedCs2ServerConfig, -} from '../../lib/cs2-server-config.js'; + managedConfigFileFor, + readManagedConfig, + writeManagedConfig, +} from '../../lib/managed-config.js'; const ParamSchema = { params: Type.Object({ @@ -70,8 +70,9 @@ export default async function configRoutes(app: FastifyInstance) { let raw = ''; try { - if (isManagedCs2ServerConfigPath(game.slug, configFile.path)) { - raw = await readManagedCs2ServerConfig(node, server.uuid); + const managedFile = managedConfigFileFor(game.slug, configFile.path); + if (managedFile) { + raw = await readManagedConfig(node, server.uuid, managedFile); } else { const file = await daemonReadFile(node, server.uuid, configFile.path); raw = file.data.toString('utf8'); @@ -120,13 +121,13 @@ export default async function configRoutes(app: FastifyInstance) { 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 originalEntries: { key: string; value: string }[] = []; try { - if (isManagedCs2Config) { - originalContent = await readManagedCs2ServerConfig(node, server.uuid); + if (managedFile) { + originalContent = await readManagedConfig(node, server.uuid, managedFile); } else { const current = await daemonReadFile(node, server.uuid, configFile.path); originalContent = current.data.toString('utf8'); @@ -161,8 +162,8 @@ export default async function configRoutes(app: FastifyInstance) { originalContent, ); - if (isManagedCs2Config) { - await writeManagedCs2ServerConfig(node, server.uuid, content); + if (managedFile) { + await writeManagedConfig(node, server.uuid, managedFile, content); } else { await daemonWriteFile(node, server.uuid, configFile.path, content); } diff --git a/apps/api/src/routes/servers/files.ts b/apps/api/src/routes/servers/files.ts index 3506557..c743658 100644 --- a/apps/api/src/routes/servers/files.ts +++ b/apps/api/src/routes/servers/files.ts @@ -12,12 +12,11 @@ import { type DaemonNodeConnection, } from '../../lib/daemon.js'; import { - CS2_PERSISTED_SERVER_CFG_PATH, - CS2_PERSISTED_SERVER_CFG_FILE, - isManagedCs2ServerConfigPath, - readManagedCs2ServerConfig, - writeManagedCs2ServerConfig, -} from '../../lib/cs2-server-config.js'; + isManagedConfigShadowFile, + managedConfigFileFor, + readManagedConfig, + writeManagedConfig, +} from '../../lib/managed-config.js'; const FileParamSchema = { params: Type.Object({ @@ -27,8 +26,8 @@ const FileParamSchema = { }; function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean { + if (isManagedConfigShadowFile(gameSlug, fileName)) return true; if (gameSlug !== 'cs2') return false; - if (fileName.trim() === CS2_PERSISTED_SERVER_CFG_FILE) return true; if (isDirectory) return false; const normalizedName = fileName.trim().toLowerCase(); @@ -108,9 +107,10 @@ export default async function fileRoutes(app: FastifyInstance) { let payload: Buffer; let mimeType = 'text/plain'; - if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) { + const managedFile = managedConfigFileFor(serverContext.gameSlug, path); + if (managedFile) { payload = Buffer.from( - await readManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid), + await readManagedConfig(serverContext.node, serverContext.serverUuid, managedFile), 'utf8', ); } else { @@ -156,8 +156,14 @@ export default async function fileRoutes(app: FastifyInstance) { const payload = encoding === 'base64' ? decodeBase64Payload(data) : data; - if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) { - await writeManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid, payload); + const managedFile = managedConfigFileFor(serverContext.gameSlug, path); + if (managedFile) { + await writeManagedConfig( + serverContext.node, + serverContext.serverUuid, + managedFile, + payload, + ); } else { await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload); } @@ -182,16 +188,19 @@ export default async function fileRoutes(app: FastifyInstance) { await requirePermission(request, orgId, 'files.delete'); const serverContext = await getServerContext(app, orgId, serverId); - const resolvedPaths = paths.flatMap((path) => - isManagedCs2ServerConfigPath(serverContext.gameSlug, path) - ? [ - path, - path.trim().startsWith('/') - ? `/${CS2_PERSISTED_SERVER_CFG_PATH}` - : CS2_PERSISTED_SERVER_CFG_PATH, - ] - : [path], - ); + // Deleting a managed config also drops the panel's sidecar copy, + // otherwise the next start would resurrect the file. + const resolvedPaths = paths.flatMap((path) => { + const managedFile = managedConfigFileFor(serverContext.gameSlug, path); + if (!managedFile) return [path]; + + return [ + path, + path.trim().startsWith('/') + ? `/${managedFile.shadowPath}` + : managedFile.shadowPath, + ]; + }); await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths); return { success: true, paths }; diff --git a/apps/api/src/routes/servers/index.ts b/apps/api/src/routes/servers/index.ts index c4dbea3..0cbe4a2 100644 --- a/apps/api/src/routes/servers/index.ts +++ b/apps/api/src/routes/servers/index.ts @@ -26,7 +26,7 @@ import { type DaemonNodeConnection, type DaemonPortMapping, } from '../../lib/daemon.js'; -import { reapplyManagedCs2ServerConfig } from '../../lib/cs2-server-config.js'; +import { sustainManagedConfigsAfterStart } from '../../lib/managed-config.js'; import { ServerParamSchema, 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 []; } @@ -188,18 +196,38 @@ function applyGameRuntimeEnvironment( additionalPortsRaw: unknown, ): Record { const slug = gameSlug.trim().toLowerCase(); - if (slug !== 'satisfactory') return environment; - const additionalPorts = normalizeAdditionalServerPorts(additionalPortsRaw); - const messagingPort = additionalPorts.find( - (port) => port.key === 'satisfactory-messaging' && port.protocol === 'tcp', - ); + const findPort = (key: string, protocol: PortProtocol) => + additionalPorts.find((port) => port.key === key && port.protocol === protocol); - return { - ...environment, - SERVERGAMEPORT: String(allocationPort), - SERVERMESSAGINGPORT: String(messagingPort?.hostPort ?? 8888), - }; + if (slug === 'satisfactory') { + const messagingPort = findPort('satisfactory-messaging', 'tcp'); + + return { + ...environment, + SERVERGAMEPORT: String(allocationPort), + SERVERMESSAGINGPORT: String(messagingPort?.hostPort ?? 8888), + }; + } + + if (slug === 'ark-se') { + // The ARK image binds exactly the ports it is told about, so the + // allocations have to be mirrored into its environment. + const rconPort = findPort('ark-rcon', 'tcp')?.hostPort ?? 27020; + const adminPassword = environment.ADMIN_PASSWORD ?? ''; + + return { + ...environment, + GAME_CLIENT_PORT: String(allocationPort), + UDP_SOCKET_PORT: String(findPort('ark-raw-udp', 'udp')?.hostPort ?? allocationPort + 1), + SERVER_LIST_PORT: String(findPort('ark-query', 'udp')?.hostPort ?? 27015), + RCON_PORT: String(rconPort), + // The daemon reads these when it opens an RCON console session. + RCON_PASSWORD: adminPassword, + }; + } + + return environment; } function buildDaemonPorts( @@ -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') { return [ { host_port: allocationPort, container_port: containerPort, protocol: 'udp' }, @@ -512,6 +553,11 @@ async function syncServerInstallStatus( '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 (needsManagedProvisioning) { void runManagedInstallProvisioning(app, { @@ -543,30 +589,35 @@ async function syncServerInstallStatus( 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, node: DaemonNodeConnection, serverId: string, serverUuid: string, gameSlug: string, -): Promise { - if (gameSlug.trim().toLowerCase() !== 'cs2') return; +): void { + 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; - 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', - ); - } - } + return row?.status === 'running' || row?.status === 'installing'; + }, + }); } 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), install_plugin_urls: [], + data_path: game.containerDataPath ?? '', + stop_command: game.stopCommand ?? '', + stop_timeout_seconds: game.stopTimeoutSeconds ?? 0, }; let createdServerResponse = server; @@ -1195,6 +1249,9 @@ export default async function serverRoutes(app: FastifyInstance) { gameDefaultPort: games.defaultPort, gameSlug: games.slug, gameStartupCommand: games.startupCommand, + gameStopCommand: games.stopCommand, + gameStopTimeoutSeconds: games.stopTimeoutSeconds, + gameContainerDataPath: games.containerDataPath, gameEnvironmentVars: games.environmentVars, }) .from(servers) @@ -1256,6 +1313,9 @@ export default async function serverRoutes(app: FastifyInstance) { current.gameDefaultPort, current.additionalPorts, ), + data_path: current.gameContainerDataPath ?? '', + stop_command: current.gameStopCommand ?? '', + stop_timeout_seconds: current.gameStopTimeoutSeconds ?? 0, }, ); nextStatus = mapDaemonStatus(response.status); @@ -1412,9 +1472,14 @@ export default async function serverRoutes(app: FastifyInstance) { nodeFqdn: nodes.fqdn, nodeGrpcPort: nodes.grpcPort, nodeDaemonToken: nodes.daemonToken, + gameSlug: games.slug, + gameStopCommand: games.stopCommand, + gameStopTimeoutSeconds: games.stopTimeoutSeconds, + gameAutomationRules: games.automationRules, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) + .innerJoin(games, eq(servers.gameId, games.id)) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); if (!server) throw AppError.notFound('Server not found'); @@ -1422,16 +1487,17 @@ export default async function serverRoutes(app: FastifyInstance) { throw AppError.badRequest('Cannot send power action to a suspended server'); } + const nodeConnection: DaemonNodeConnection = { + fqdn: server.nodeFqdn, + grpcPort: server.nodeGrpcPort, + daemonToken: server.nodeDaemonToken, + }; + try { - await daemonSetPowerState( - { - fqdn: server.nodeFqdn, - grpcPort: server.nodeGrpcPort, - daemonToken: server.nodeDaemonToken, - }, - server.uuid, - action, - ); + await daemonSetPowerState(nodeConnection, server.uuid, action, { + stopCommand: server.gameStopCommand, + stopTimeoutSeconds: server.gameStopTimeoutSeconds, + }); } catch (error) { app.log.error( { error, serverId: server.id, serverUuid: server.uuid, action }, @@ -1457,41 +1523,22 @@ export default async function serverRoutes(app: FastifyInstance) { .where(eq(servers.id, serverId)); if (action === 'start' || action === 'restart') { - const [serverWithGame] = await app.db - .select({ - gameSlug: games.slug, - automationRules: games.automationRules, - }) - .from(servers) - .innerJoin(games, eq(servers.gameId, games.id)) - .where(eq(servers.id, serverId)); + sustainManagedConfigAfterPowerStart( + app, + nodeConnection, + serverId, + server.uuid, + server.gameSlug, + ); - if (serverWithGame) { - void sustainCs2ServerConfigAfterPowerStart( - app, - { - fqdn: server.nodeFqdn, - grpcPort: server.nodeGrpcPort, - daemonToken: server.nodeDaemonToken, - }, - serverId, - server.uuid, - serverWithGame.gameSlug, - ); - - void runServerAutomationEvent(app, { - serverId, - serverUuid: server.uuid, - gameSlug: serverWithGame.gameSlug, - event: 'server.power.started', - node: { - fqdn: server.nodeFqdn, - grpcPort: server.nodeGrpcPort, - daemonToken: server.nodeDaemonToken, - }, - automationRulesRaw: serverWithGame.automationRules, - }); - } + void runServerAutomationEvent(app, { + serverId, + serverUuid: server.uuid, + gameSlug: server.gameSlug, + event: 'server.power.started', + node: nodeConnection, + automationRulesRaw: server.gameAutomationRules, + }); } await createAuditLog(app.db, request, { diff --git a/apps/daemon/Dockerfile b/apps/daemon/Dockerfile index b47e591..189dc8c 100644 --- a/apps/daemon/Dockerfile +++ b/apps/daemon/Dockerfile @@ -3,9 +3,13 @@ FROM rust:1.83-bookworm AS build # Install protoc RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/* -WORKDIR /app -COPY apps/daemon/ . +# build.rs compiles ../../packages/proto/daemon.proto, so the workspace layout +# has to be preserved inside the build context. +WORKDIR /build +COPY packages/proto ./packages/proto +COPY apps/daemon ./apps/daemon +WORKDIR /build/apps/daemon RUN cargo build --release # --- Production --- @@ -18,12 +22,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* 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 RUN mkdir -p /var/lib/gamepanel/servers /var/lib/gamepanel/backups /etc/gamepanel 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"] diff --git a/apps/daemon/src/config.rs b/apps/daemon/src/config.rs index 2e06ee5..d97744a 100644 --- a/apps/daemon/src/config.rs +++ b/apps/daemon/src/config.rs @@ -12,6 +12,12 @@ pub struct DaemonConfig { pub docker: DockerConfig, #[serde(default = "default_data_path")] pub data_path: PathBuf, + /// Where `data_path` lives on the Docker host. Only differs from `data_path` + /// when the daemon itself runs in a container: bind mounts for the game + /// containers are resolved by the host Docker engine, not by the daemon's + /// own mount namespace. Defaults to `data_path`. + #[serde(default)] + pub host_data_path: Option, #[serde(default = "default_backup_path")] pub backup_path: PathBuf, #[serde(default)] @@ -92,7 +98,24 @@ grpc_port: 50051 .to_string() }); - let config: DaemonConfig = serde_yaml::from_str(&content)?; + let mut config: DaemonConfig = serde_yaml::from_str(&content)?; + + // Environment overrides make containerised deployments configurable + // without templating the YAML file. + if let Ok(host_data_path) = std::env::var("DAEMON_HOST_DATA_PATH") { + let trimmed = host_data_path.trim(); + if !trimmed.is_empty() { + config.host_data_path = Some(PathBuf::from(trimmed)); + } + } + Ok(config) } + + /// Path prefix the Docker host uses for server data directories. + pub fn host_data_path(&self) -> PathBuf { + self.host_data_path + .clone() + .unwrap_or_else(|| self.data_path.clone()) + } } diff --git a/apps/daemon/src/docker/container.rs b/apps/daemon/src/docker/container.rs index 08ae32d..a75f2b5 100644 --- a/apps/daemon/src/docker/container.rs +++ b/apps/daemon/src/docker/container.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::io::Cursor; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use anyhow::Result; use bollard::container::{ @@ -14,13 +14,24 @@ use tokio::time::{sleep, Duration}; use tracing::{debug, info}; use crate::docker::DockerManager; -use crate::server::ServerSpec; +use crate::server::{ServerRuntime, ServerSpec}; use crate::server::state::ServerState; /// Container name prefix for all managed game servers. const CONTAINER_PREFIX: &str = "gp_"; const SATISFACTORY_RUN_SH: &str = include_str!("../game/satisfactory_run.sh"); +/// Labels used to persist panel-supplied runtime options on the container, so +/// they survive a daemon restart (in-memory specs are rebuilt from Docker). +const LABEL_DATA_PATH: &str = "gamepanel.data_mount_path"; +const LABEL_STOP_COMMAND: &str = "gamepanel.stop_command"; +const LABEL_STOP_TIMEOUT: &str = "gamepanel.stop_timeout_seconds"; + +/// Docker's SIGTERM grace period once the in-game stop command has had its turn. +const SIGTERM_GRACE_SECS: i64 = 15; +/// Fallback shutdown budget when the game defines no explicit timeout. +pub const DEFAULT_STOP_TIMEOUT_SECS: i64 = 30; + pub fn container_name(server_uuid: &str) -> String { format!("{}{}", CONTAINER_PREFIX, server_uuid) } @@ -47,9 +58,63 @@ fn container_data_path_for_image(image: &str) -> &'static str { if normalized.contains("wolveix/satisfactory-server") { return "/config"; } + if normalized.contains("ark-server") || normalized.contains("ark-survival-evolved") { + return "/app"; + } "/data" } +/// Mount point of the server data directory inside the container. The panel can +/// override the image-derived default per game. +fn container_data_path(spec: &ServerSpec) -> String { + spec.runtime + .data_mount_path + .as_deref() + .map(str::trim) + .filter(|path| path.starts_with('/')) + .map(str::to_string) + .unwrap_or_else(|| container_data_path_for_image(&spec.docker_image).to_string()) +} + +/// Games whose process does not read stdin, so console commands have to go over +/// RCON instead of the container's attached stdin. +fn prefers_rcon_console(image: &str) -> bool { + let normalized = image.to_ascii_lowercase(); + normalized.contains("cs2") + || normalized.contains("csgo") + || normalized.contains("ark-server") + || normalized.contains("ark-survival-evolved") +} + +fn runtime_labels(spec: &ServerSpec) -> HashMap { + 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>) -> 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::().ok()) + .filter(|value| *value > 0), + } +} + fn is_wolveix_satisfactory_image(image: &str) -> bool { image .to_ascii_lowercase() @@ -69,6 +134,7 @@ impl DockerManager { async fn attach_command_stream( &self, container_name: &str, + container_id: String, ) -> Result> { let bollard::container::AttachContainerResults { mut output, input } = self .client() @@ -93,28 +159,80 @@ impl DockerManager { 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 { + let name = container_name(server_uuid); + let info = match self.client().inspect_container(&name, None).await { + Ok(info) => info, + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => { + return Err(anyhow::anyhow!( + "server container does not exist (server has not been installed yet)" + )) + } + Err(error) => return Err(error.into()), + }; + + let running = info + .state + .as_ref() + .and_then(|state| state.running) + .unwrap_or(false); + + if !running { + return Err(anyhow::anyhow!( + "server is not running, start it before sending console commands" + )); + } + + info.id + .ok_or_else(|| anyhow::anyhow!("Docker did not report a container id")) } async fn get_or_attach_command_stream( &self, server_uuid: &str, + container_id: &str, ) -> Result> { let name = container_name(server_uuid); if let Some(existing) = self.command_streams().read().await.get(&name).cloned() { - return Ok(existing); + if existing.container_id() == container_id { + return Ok(existing); + } } - let created = self.attach_command_stream(&name).await?; + // Container was recreated or restarted since we last attached — the old + // hijacked socket is dead, drop it before opening a fresh one. + self.clear_command_stream(server_uuid).await; + + let created = self + .attach_command_stream(&name, container_id.to_string()) + .await?; let mut streams = self.command_streams().write().await; if let Some(existing) = streams.get(&name).cloned() { - created.abort(); - return Ok(existing); + if existing.container_id() == container_id { + created.abort(); + return Ok(existing); + } } - streams.insert(name, created.clone()); + // Another task may have raced in with a stream for a different + // container; its drain task has to be stopped or it leaks. + if let Some(displaced) = streams.insert(name, created.clone()) { + displaced.abort(); + } Ok(created) } @@ -202,6 +320,123 @@ impl DockerManager { .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 { + 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 { + 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::().ok()) + .unwrap_or(default_port); + + let host = match lookup(&["RCON_HOST"]) { + Some(host) => host, + None => self + .container_ip(server_uuid) + .await + .unwrap_or_else(|| "127.0.0.1".to_string()), + }; + + Ok((format!("{host}:{port}"), password)) + } + + async fn send_command_via_rcon(&self, server_uuid: &str, command: &str) -> Result<()> { + let (address, password) = self.rcon_endpoint(server_uuid).await?; + let mut client = crate::game::rcon::RconClient::connect(&address, &password).await?; + client.command(command).await?; + debug!(server_uuid = %server_uuid, address = %address, "Console command delivered over RCON"); + Ok(()) + } + + async fn send_command_via_stdin( + &self, + server_uuid: &str, + container_id: &str, + command: &str, + ) -> Result<()> { + let payload = format!("{command}\n"); + + for attempt in 0..2 { + let stream = self + .get_or_attach_command_stream(server_uuid, container_id) + .await?; + + match stream.write_all(payload.as_bytes()).await { + Ok(_) => return Ok(()), + Err(error) => { + debug!( + server_uuid = %server_uuid, + attempt, + error = %error, + "Failed to write to container stdin, resetting attach stream", + ); + self.clear_command_stream(server_uuid).await; + } + } + } + + Err(anyhow::anyhow!("failed to write command to container stdin")) + } + /// Pull a Docker image if not already present. pub async fn pull_image(&self, image: &str) -> Result<()> { info!(image = %image, "Pulling Docker image"); @@ -230,7 +465,9 @@ impl DockerManager { /// Create and configure a container for a game server. pub async fn create_container(&self, spec: &ServerSpec) -> Result { 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 let mut port_bindings: HashMap>> = HashMap::new(); @@ -267,8 +504,7 @@ impl DockerManager { network_mode: Some(self.network_name().to_string()), binds: Some(vec![format!( "{}:{}", - spec.data_path.display() - , + bind_source.display(), data_mount_path )]), ..Default::default() @@ -280,6 +516,7 @@ impl DockerManager { env: Some(env), exposed_ports: Some(exposed_ports), host_config: Some(host_config), + labels: Some(runtime_labels(spec)), // Preserve image default working directory when no custom startup command is set. // Some game images rely on their built-in WORKDIR and entrypoint scripts. working_dir: if spec.startup_command.is_empty() { @@ -342,6 +579,100 @@ impl DockerManager { Ok(()) } + /// Shut a server down the way its game expects. + /// + /// Sending the in-game stop command first is what makes shutdown fast: most + /// dedicated servers ignore SIGTERM entirely and only exit once Docker's + /// timeout expires and SIGKILL lands, which is why "stop" used to sit there + /// for the full grace period every single time. + pub async fn stop_container_graceful( + &self, + server_uuid: &str, + stop_command: Option<&str>, + stop_timeout_secs: i64, + ) -> Result<()> { + let budget = if stop_timeout_secs > 0 { + stop_timeout_secs + } else { + DEFAULT_STOP_TIMEOUT_SECS + } + .max(5); + + let stop_command = stop_command + .map(str::trim) + .filter(|command| !command.is_empty()); + + if let Some(command) = stop_command { + match self.send_command(server_uuid, command).await { + Ok(_) => { + let graceful_budget = (budget - SIGTERM_GRACE_SECS).max(5); + if self.wait_until_exited(server_uuid, graceful_budget).await? { + self.clear_command_stream(server_uuid).await; + info!( + uuid = %server_uuid, + command = %command, + "Server exited after in-game stop command", + ); + return Ok(()); + } + + tracing::warn!( + uuid = %server_uuid, + command = %command, + graceful_budget, + "Server ignored the in-game stop command, falling back to SIGTERM", + ); + return self.stop_container(server_uuid, SIGTERM_GRACE_SECS).await; + } + Err(error) => { + tracing::warn!( + uuid = %server_uuid, + command = %command, + error = %error, + "Could not deliver the in-game stop command, falling back to SIGTERM", + ); + } + } + } + + // No usable stop command: SIGTERM gets the whole budget. Docker returns + // as soon as the container exits, so a well-behaved image (ARK, itzg) + // still stops quickly. + self.stop_container(server_uuid, budget).await + } + + /// Poll until the container is no longer running. Returns `false` on timeout. + async fn wait_until_exited(&self, server_uuid: &str, timeout_secs: i64) -> Result { + let deadline = + tokio::time::Instant::now() + Duration::from_secs(timeout_secs.max(1) as u64); + let name = container_name(server_uuid); + + loop { + match self.client().inspect_container(&name, None).await { + Ok(info) => { + let running = info + .state + .as_ref() + .and_then(|state| state.running) + .unwrap_or(false); + if !running { + return Ok(true); + } + } + Err(bollard::errors::Error::DockerResponseServerError { + status_code: 404, .. + }) => return Ok(true), + Err(error) => return Err(error.into()), + } + + if tokio::time::Instant::now() >= deadline { + return Ok(false); + } + + sleep(Duration::from_millis(500)).await; + } + } + /// Kill a container immediately. pub async fn kill_container(&self, server_uuid: &str) -> Result<()> { let name = container_name(server_uuid); @@ -476,6 +807,12 @@ impl DockerManager { .and_then(|cfg| cfg.image.clone()) .unwrap_or_default(); + let runtime = runtime_from_labels( + info.config.as_ref().and_then(|cfg| cfg.labels.as_ref()), + ); + + // `mount.source` is a host path; map it back into the daemon's own + // mount namespace before we try to read or write it. let data_mount_path = info .mounts .as_ref() @@ -484,7 +821,10 @@ impl DockerManager { if mount.typ != Some(MountPointTypeEnum::BIND) { 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)); @@ -594,6 +934,7 @@ impl DockerManager { data_path: data_mount_path, state, 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<()> { 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 stream = self.get_or_attach_command_stream(server_uuid).await?; - match stream.write_all(payload.as_bytes()).await { + let container_id = self.running_container_id(server_uuid).await?; + let image = self + .container_runtime_metadata(server_uuid) + .await + .map(|(image, _)| image) + .unwrap_or_default(); + + if prefers_rcon_console(&image) { + match self.send_command_via_rcon(server_uuid, trimmed).await { Ok(_) => return Ok(()), - Err(error) => { - debug!(server_uuid = %server_uuid, error = %error, "Failed to write to container stdin, resetting attach stream"); - self.clear_command_stream(server_uuid).await; + Err(rcon_error) => { + debug!( + server_uuid = %server_uuid, + error = %rcon_error, + "RCON console delivery failed, trying container stdin", + ); + return self + .send_command_via_stdin(server_uuid, &container_id, trimmed) + .await + .map_err(|stdin_error| { + anyhow::anyhow!( + "RCON failed ({rcon_error}) and stdin failed ({stdin_error})" + ) + }); } } } - 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})") + }), + } } } diff --git a/apps/daemon/src/docker/manager.rs b/apps/daemon/src/docker/manager.rs index 08c0b8d..ba65d54 100644 --- a/apps/daemon/src/docker/manager.rs +++ b/apps/daemon/src/docker/manager.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; @@ -10,23 +11,37 @@ use tokio::sync::{Mutex, RwLock}; use tokio::task::JoinHandle; use tracing::info; -use crate::config::DockerConfig; +use crate::config::DaemonConfig; type AttachedInput = Pin>; 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, drain_task: JoinHandle<()>, } 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 { + container_id, input: Mutex::new(input), drain_task, } } + pub(crate) fn container_id(&self) -> &str { + &self.container_id + } + pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> { let mut input = self.input.lock().await; input.write_all(bytes).await?; @@ -44,13 +59,15 @@ impl CommandStreamHandle { pub struct DockerManager { client: Docker, network_name: String, + data_root: PathBuf, + host_data_root: PathBuf, command_streams: Arc>>>, } impl DockerManager { - pub async fn new(config: &DockerConfig) -> Result { + pub async fn new(config: &DaemonConfig) -> Result { let client = Docker::connect_with_socket( - &config.socket, + &config.docker.socket, 120, // timeout bollard::API_DEFAULT_VERSION, )?; @@ -62,13 +79,25 @@ impl DockerManager { "Connected to Docker" ); + let data_root = config.data_path.clone(); + let host_data_root = config.host_data_path(); + if data_root != host_data_root { + info!( + data_root = %data_root.display(), + host_data_root = %host_data_root.display(), + "Server data directories are bind-mounted from a different host path", + ); + } + let manager = Self { client, - network_name: config.network.clone(), + network_name: config.docker.network.clone(), + data_root, + host_data_root, command_streams: Arc::new(RwLock::new(HashMap::new())), }; - manager.ensure_network(&config.network_subnet).await?; + manager.ensure_network(&config.docker.network_subnet).await?; Ok(manager) } @@ -81,6 +110,32 @@ impl DockerManager { &self.network_name } + /// Translate a daemon-local server data directory into the path the Docker + /// host must bind-mount. These differ when the daemon runs in a container. + pub fn host_bind_source(&self, data_path: &Path) -> PathBuf { + if self.data_root == self.host_data_root { + return data_path.to_path_buf(); + } + + match data_path.strip_prefix(&self.data_root) { + Ok(relative) => self.host_data_root.join(relative), + Err(_) => data_path.to_path_buf(), + } + } + + /// Inverse of [`Self::host_bind_source`]: turn a bind-mount source reported + /// by Docker back into a path the daemon can read and write itself. + pub fn daemon_data_path(&self, host_path: &Path) -> PathBuf { + if self.data_root == self.host_data_root { + return host_path.to_path_buf(); + } + + match host_path.strip_prefix(&self.host_data_root) { + Ok(relative) => self.data_root.join(relative), + Err(_) => host_path.to_path_buf(), + } + } + pub(crate) fn command_streams(&self) -> &Arc>>> { &self.command_streams } diff --git a/apps/daemon/src/game/ark.rs b/apps/daemon/src/game/ark.rs new file mode 100644 index 0000000..4f567df --- /dev/null +++ b/apps/daemon/src/game/ark.rs @@ -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> { + 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 { + 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 ". " 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()); + } +} diff --git a/apps/daemon/src/game/mod.rs b/apps/daemon/src/game/mod.rs index 018f2e1..aa28101 100644 --- a/apps/daemon/src/game/mod.rs +++ b/apps/daemon/src/game/mod.rs @@ -1,3 +1,4 @@ pub mod rcon; pub mod minecraft; pub mod cs2; +pub mod ark; diff --git a/apps/daemon/src/grpc/service.rs b/apps/daemon/src/grpc/service.rs index 5252f29..680c20d 100644 --- a/apps/daemon/src/grpc/service.rs +++ b/apps/daemon/src/grpc/service.rs @@ -14,7 +14,7 @@ use tonic::{Request, Response, Status}; use tracing::{info, error, warn}; use crate::command::CommandDispatcher; -use crate::server::{ServerManager, PortMap}; +use crate::server::{ServerManager, ServerRuntime, PortMap}; use crate::filesystem::FileSystem; use crate::backup::BackupManager; use crate::managed_mysql::ManagedMysqlManager; @@ -110,6 +110,21 @@ impl DaemonServiceImpl { Self::env_value(env, keys).and_then(|v| v.parse::().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 { + 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 { Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"]) .unwrap_or_else(|| "changeme".to_string()) @@ -191,6 +206,12 @@ impl DaemonService for DaemonServiceImpl { self.check_auth(&request)?; let req = request.into_inner(); + let runtime = ServerRuntime::from_request( + req.data_path, + req.stop_command, + req.stop_timeout_seconds, + ); + self.server_manager .create_server( req.uuid.clone(), @@ -201,6 +222,7 @@ impl DaemonService for DaemonServiceImpl { req.startup_command, req.environment, Self::map_ports(&req.ports), + runtime, ) .await .map_err(|e| Status::from(e))?; @@ -218,6 +240,12 @@ impl DaemonService for DaemonServiceImpl { self.check_auth(&request)?; let req = request.into_inner(); + let runtime = ServerRuntime::from_request( + req.data_path, + req.stop_command, + req.stop_timeout_seconds, + ); + let state = self.server_manager .update_server( req.uuid.clone(), @@ -228,6 +256,7 @@ impl DaemonService for DaemonServiceImpl { req.startup_command, req.environment, Self::map_ports(&req.ports), + runtime, ) .await .map_err(Status::from)?; @@ -378,15 +407,29 @@ impl DaemonService for DaemonServiceImpl { self.check_auth(&request)?; let req = request.into_inner(); - match req.action() { + let action = req.action(); + let stop_command = if req.stop_command.trim().is_empty() { + None + } else { + Some(req.stop_command.as_str()) + }; + let stop_timeout = i64::from(req.stop_timeout_seconds); + + match action { PowerAction::Start => { self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?; } PowerAction::Stop => { - self.server_manager.stop_server(&req.uuid).await.map_err(Status::from)?; + self.server_manager + .stop_server(&req.uuid, stop_command, stop_timeout) + .await + .map_err(Status::from)?; } PowerAction::Restart => { - let _ = self.server_manager.stop_server(&req.uuid).await; + let _ = self + .server_manager + .stop_server(&req.uuid, stop_command, stop_timeout) + .await; self.server_manager.start_server(&req.uuid).await.map_err(Status::from)?; } PowerAction::Kill => { @@ -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") { max_from_runtime_env = Self::env_i32(&env, &["CS2_MAXPLAYERS", "SRCDS_MAXPLAYERS"]) .unwrap_or(0); - let host = Self::env_value(&env, &["RCON_HOST"]) - .unwrap_or_else(|| "127.0.0.1".to_string()); + let host = self.rcon_host(&uuid, &env).await; let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015); let password = Self::cs2_rcon_password(&env); let address = format!("{}:{}", host, port); diff --git a/apps/daemon/src/main.rs b/apps/daemon/src/main.rs index 7ce5ea4..86a891d 100644 --- a/apps/daemon/src/main.rs +++ b/apps/daemon/src/main.rs @@ -28,6 +28,20 @@ const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024; #[tokio::main] async fn main() -> Result<()> { + // `--health-check` is what the container HEALTHCHECK runs: succeed only if + // the gRPC listener is actually accepting connections. + if std::env::args().any(|arg| arg == "--health-check") { + let config = config::DaemonConfig::load()?; + let address = format!("127.0.0.1:{}", config.grpc_port); + return match tokio::net::TcpStream::connect(&address).await { + Ok(_) => Ok(()), + Err(error) => { + eprintln!("daemon health check failed for {address}: {error}"); + std::process::exit(1); + } + }; + } + // Initialize logging tracing_subscriber::fmt() .with_env_filter( @@ -42,7 +56,7 @@ async fn main() -> Result<()> { info!(grpc_port = config.grpc_port, "Configuration loaded"); // Initialize Docker - let docker = Arc::new(DockerManager::new(&config.docker).await?); + let docker = Arc::new(DockerManager::new(&config).await?); info!("Docker manager initialized"); // Initialize server manager diff --git a/apps/daemon/src/scheduler/mod.rs b/apps/daemon/src/scheduler/mod.rs index e89da0e..c58818a 100644 --- a/apps/daemon/src/scheduler/mod.rs +++ b/apps/daemon/src/scheduler/mod.rs @@ -128,9 +128,9 @@ impl Scheduler { "power" => { match task.payload.as_str() { "start" => self.server_manager.start_server(&task.server_uuid).await?, - "stop" => self.server_manager.stop_server(&task.server_uuid).await?, + "stop" => self.server_manager.stop_server(&task.server_uuid, None, 0).await?, "restart" => { - let _ = self.server_manager.stop_server(&task.server_uuid).await; + let _ = self.server_manager.stop_server(&task.server_uuid, None, 0).await; tokio::time::sleep(Duration::from_secs(3)).await; self.server_manager.start_server(&task.server_uuid).await?; } diff --git a/apps/daemon/src/server/manager.rs b/apps/daemon/src/server/manager.rs index cfbc52d..3cb2cec 100644 --- a/apps/daemon/src/server/manager.rs +++ b/apps/daemon/src/server/manager.rs @@ -10,7 +10,7 @@ use std::os::unix::fs::PermissionsExt; use crate::config::DaemonConfig; use crate::docker::DockerManager; use crate::error::DaemonError; -use super::state::{ServerState, ServerSpec, PortMap}; +use super::state::{ServerState, ServerSpec, ServerRuntime, PortMap}; /// Manages all game server instances on this node. pub struct ServerManager { @@ -101,6 +101,7 @@ impl ServerManager { startup_command: String, environment: HashMap, ports: Vec, + runtime: ServerRuntime, ) -> Result<(), DaemonError> { let mut servers = self.servers.write().await; if servers.contains_key(&uuid) { @@ -122,6 +123,7 @@ impl ServerManager { data_path, state: ServerState::Installing, container_id: None, + runtime, }; servers.insert(uuid.clone(), spec); @@ -154,6 +156,7 @@ impl ServerManager { startup_command: String, environment: HashMap, ports: Vec, + runtime: ServerRuntime, ) -> Result { let existing = { let servers = self.servers.read().await; @@ -205,6 +208,7 @@ impl ServerManager { data_path, state: ServerState::Stopped, container_id: None, + runtime: runtime.clone(), }; if runtime_state @@ -212,7 +216,20 @@ impl ServerManager { .map(Self::is_running_state) .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"); self.docker.kill_container(&uuid).await.map_err(|e| { DaemonError::Internal(format!("Failed to stop running container during update: {}", e)) @@ -335,9 +352,18 @@ impl ServerManager { } /// Stop a server. - pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> { + /// + /// `stop_command` / `stop_timeout_seconds` override whatever was captured + /// when the container was created; pass `None` / `0` to use those defaults. + pub async fn stop_server( + &self, + uuid: &str, + stop_command: Option<&str>, + stop_timeout_seconds: i64, + ) -> Result<(), DaemonError> { let mut managed = false; let mut previous_state: Option = None; + let mut spec_runtime = ServerRuntime::default(); { let mut servers = self.servers.write().await; if let Some(spec) = servers.get_mut(uuid) { @@ -353,12 +379,29 @@ impl ServerManager { }); } previous_state = Some(spec.state.clone()); + spec_runtime = spec.runtime.clone(); spec.state = ServerState::Stopping; 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 { let mut servers = self.servers.write().await; if let Some(spec) = servers.get_mut(uuid) { diff --git a/apps/daemon/src/server/mod.rs b/apps/daemon/src/server/mod.rs index 240198b..29508d4 100644 --- a/apps/daemon/src/server/mod.rs +++ b/apps/daemon/src/server/mod.rs @@ -1,5 +1,5 @@ pub mod state; pub mod manager; -pub use state::{ServerSpec, PortMap}; +pub use state::{ServerSpec, ServerRuntime, PortMap}; pub use manager::ServerManager; diff --git a/apps/daemon/src/server/state.rs b/apps/daemon/src/server/state.rs index a086b47..2a50e8c 100644 --- a/apps/daemon/src/server/state.rs +++ b/apps/daemon/src/server/state.rs @@ -33,6 +33,46 @@ pub struct PortMap { pub protocol: String, // "tcp" or "udp" } +/// Per-game runtime knobs supplied by the panel. Mirrored into Docker labels so +/// they survive a daemon restart (see `docker::container`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ServerRuntime { + /// Mount point of the data directory inside the container. `None` means + /// "derive it from the image". + pub data_mount_path: Option, + /// In-game command that shuts the server down cleanly (e.g. `stop`, `quit`). + pub stop_command: Option, + /// Total budget for a graceful shutdown before the container gets killed. + pub stop_timeout_seconds: Option, +} + +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 { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerSpec { pub uuid: String, @@ -46,6 +86,8 @@ pub struct ServerSpec { pub data_path: PathBuf, pub state: ServerState, pub container_id: Option, + #[serde(default)] + pub runtime: ServerRuntime, } impl ServerSpec { diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf index 386ce43..96ee69b 100644 --- a/apps/web/nginx.conf +++ b/apps/web/nginx.conf @@ -28,9 +28,18 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # A power action blocks until the game server has actually shut down. + # ARK saves its world for minutes, so the default 60s would 504 on a + # stop that is still progressing normally. + proxy_read_timeout 400s; + proxy_send_timeout 400s; + + # File manager uploads. + client_max_body_size 128m; } - # Socket.IO proxy + # Socket.IO proxy (live console) location /socket.io/ { proxy_pass http://api:3000; proxy_http_version 1.1; @@ -39,6 +48,10 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # An idle console must not be torn down every 60s. + proxy_read_timeout 1h; + proxy_send_timeout 1h; } # Static assets caching diff --git a/apps/web/src/pages/servers/create.tsx b/apps/web/src/pages/servers/create.tsx index bf8ed18..2198430 100644 --- a/apps/web/src/pages/servers/create.tsx +++ b/apps/web/src/pages/servers/create.tsx @@ -64,17 +64,47 @@ interface AdditionalPortRequirement { } function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] { - if (gameSlug.trim().toLowerCase() !== 'satisfactory') return []; + const slug = gameSlug.trim().toLowerCase(); - return [ - { - key: 'satisfactory-messaging', - label: 'Messaging Port', - defaultPort: 8888, - protocols: ['tcp'], - description: 'Required by the Satisfactory server messaging API.', - }, - ]; + if (slug === 'satisfactory') { + return [ + { + key: 'satisfactory-messaging', + label: 'Messaging Port', + defaultPort: 8888, + protocols: ['tcp'], + description: 'Required by the Satisfactory server messaging API.', + }, + ]; + } + + if (slug === 'ark-se') { + return [ + { + key: 'ark-raw-udp', + label: 'Raw UDP Socket Port', + defaultPort: 7778, + protocols: ['udp'], + description: 'ARK opens a second UDP socket, normally the game port + 1.', + }, + { + key: 'ark-query', + label: 'Steam Query Port', + defaultPort: 27015, + protocols: ['udp'], + description: 'Used by the Steam server browser to list the server.', + }, + { + key: 'ark-rcon', + label: 'RCON Port', + defaultPort: 27020, + protocols: ['tcp'], + description: 'Console commands and the player list are sent over RCON.', + }, + ]; + } + + return []; } export function CreateServerPage() { diff --git a/daemon-config.yml b/daemon-config.yml index 8c8e54c..f9ecaab 100644 --- a/daemon-config.yml +++ b/daemon-config.yml @@ -4,9 +4,18 @@ api_url: "http://api:3000" node_token: "CHANGE_ME_GENERATE_A_SECURE_TOKEN" grpc_port: 50051 + +# Path inside the daemon container. data_path: "/var/lib/gamepanel/servers" 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: socket: "/var/run/docker.sock" network: "gamepanel_nw" diff --git a/docker-compose.yml b/docker-compose.yml index 940dbf6..17bc543 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: # --- PostgreSQL --- postgres: @@ -10,8 +19,9 @@ services: POSTGRES_DB: ${DB_NAME:-gamepanel} volumes: - postgres_data:/var/lib/postgresql/data - ports: - - "${DB_PORT:-5432}:5432" + # Not published by default — only the API needs it. Set DB_PORT to expose it. + expose: + - "5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"] interval: 10s @@ -26,14 +36,28 @@ services: command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-gamepanel} volumes: - redis_data:/data - ports: - - "${REDIS_PORT:-6379}:6379" + expose: + - "6379" healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-gamepanel}", "ping"] interval: 10s timeout: 5s 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: build: @@ -46,21 +70,23 @@ services: condition: service_healthy redis: condition: service_healthy + migrate: + condition: service_completed_successfully environment: NODE_ENV: production DATABASE_URL: postgresql://${DB_USER:-gamepanel}:${DB_PASSWORD:-gamepanel}@postgres:5432/${DB_NAME:-gamepanel} REDIS_URL: redis://:${REDIS_PASSWORD:-gamepanel}@redis:6379 PORT: 3000 HOST: 0.0.0.0 - JWT_SECRET: ${JWT_SECRET} - JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET} + JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env — run ./scripts/install.sh} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?set JWT_REFRESH_SECRET in .env — run ./scripts/install.sh} CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost} RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-100} RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-60000} - ports: - - "${API_PORT:-3000}:3000" + expose: + - "3000" - # --- Web (nginx + SPA) --- + # --- Web (nginx + SPA, also reverse-proxies /api and /socket.io) --- web: build: context: . @@ -83,13 +109,17 @@ services: restart: unless-stopped depends_on: - api - privileged: true environment: DAEMON_CONFIG: /etc/gamepanel/config.yml + # Game containers are created through the host's Docker socket, so their + # bind mounts are resolved by the *host*, not by this container. 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: - /var/run/docker.sock:/var/run/docker.sock - - daemon_data:/var/lib/gamepanel/servers - - daemon_backups:/var/lib/gamepanel/backups + - ${DAEMON_DATA_PATH:-/var/lib/gamepanel/servers}:/var/lib/gamepanel/servers + - ${DAEMON_BACKUP_PATH:-/var/lib/gamepanel/backups}:/var/lib/gamepanel/backups - ./daemon-config.yml:/etc/gamepanel/config.yml:ro ports: - "${DAEMON_GRPC_PORT:-50051}:50051" @@ -97,5 +127,3 @@ services: volumes: postgres_data: redis_data: - daemon_data: - daemon_backups: diff --git a/packages/database/drizzle/0008_stop_controls_and_ark.sql b/packages/database/drizzle/0008_stop_controls_and_ark.sql new file mode 100644 index 0000000..6724061 --- /dev/null +++ b/packages/database/drizzle/0008_stop_controls_and_ark.sql @@ -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; diff --git a/packages/database/package.json b/packages/database/package.json index 96d41e4..07a3c85 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -9,7 +9,9 @@ "build": "tsc", "lint": "eslint src/", "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:studio": "dotenv -e ../../.env -- drizzle-kit studio" }, diff --git a/packages/database/src/migrate.ts b/packages/database/src/migrate.ts new file mode 100644 index 0000000..7848030 --- /dev/null +++ b/packages/database/src/migrate.ts @@ -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); +}); diff --git a/packages/database/src/schema/games.ts b/packages/database/src/schema/games.ts index 741d25b..6ccecfe 100644 --- a/packages/database/src/schema/games.ts +++ b/packages/database/src/schema/games.ts @@ -10,6 +10,11 @@ export const games = pgTable('games', { automationRules: jsonb('automation_rules').default([]).notNull(), startupCommand: text('startup_command').notNull(), 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(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index 06f0741..6e78c53 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -100,6 +100,8 @@ async function seed() { defaultPort: 25565, startupCommand: '/start', stopCommand: 'stop', + stopTimeoutSeconds: 60, + containerDataPath: '/data', configFiles: [ { path: 'server.properties', @@ -147,6 +149,8 @@ async function seed() { defaultPort: 27015, startupCommand: '', stopCommand: 'quit', + stopTimeoutSeconds: 30, + containerDataPath: '/home/steam/cs2-dedicated', configFiles: [ { path: 'game/csgo/cfg/server.cfg', @@ -332,6 +336,8 @@ async function seed() { defaultPort: 19132, startupCommand: '', stopCommand: 'stop', + stopTimeoutSeconds: 60, + containerDataPath: '/data', configFiles: [ { path: 'server.properties', @@ -366,6 +372,8 @@ async function seed() { defaultPort: 7777, startupCommand: '', stopCommand: 'exit', + stopTimeoutSeconds: 45, + containerDataPath: '/data', configFiles: [ { path: 'serverconfig.txt', @@ -391,6 +399,8 @@ async function seed() { defaultPort: 28015, startupCommand: '', stopCommand: 'quit', + stopTimeoutSeconds: 60, + containerDataPath: '/data', configFiles: [], environmentVars: [ { @@ -421,6 +431,8 @@ async function seed() { defaultPort: 7777, startupCommand: '', stopCommand: 'quit', + stopTimeoutSeconds: 120, + containerDataPath: '/config', configFiles: [], automationRules: [], environmentVars: [ @@ -461,6 +473,8 @@ async function seed() { defaultPort: 30120, startupCommand: '', stopCommand: 'quit', + stopTimeoutSeconds: 30, + containerDataPath: '/config', configFiles: [ { 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(); diff --git a/packages/proto/daemon.proto b/packages/proto/daemon.proto index 0505c9c..d7d7649 100644 --- a/packages/proto/daemon.proto +++ b/packages/proto/daemon.proto @@ -44,6 +44,13 @@ message CreateServerRequest { map environment = 7; repeated PortMapping ports = 8; 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 { @@ -55,6 +62,9 @@ message UpdateServerRequest { string startup_command = 6; map environment = 7; repeated PortMapping ports = 8; + string data_path = 9; + string stop_command = 10; + int32 stop_timeout_seconds = 11; } message ServerResponse { @@ -106,6 +116,10 @@ enum PowerAction { message PowerRequest { string uuid = 1; 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 === diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..1ba7b4a --- /dev/null +++ b/scripts/install.sh @@ -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" </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" <