fix: something
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import {
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
|
||||
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
|
||||
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
|
||||
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
|
||||
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
|
||||
|
||||
hostname "GamePanel CS2 Server" // Set server hostname
|
||||
sv_cheats 0 // Enable or disable cheats
|
||||
sv_hibernate_when_empty 0 // Disable server hibernation
|
||||
|
||||
// Passwords
|
||||
|
||||
rcon_password "" // Set rcon password
|
||||
sv_password "" // Set server password
|
||||
|
||||
// CSTV
|
||||
|
||||
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
|
||||
|
||||
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
|
||||
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
|
||||
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
|
||||
tv_chatgroupsize 0 // Set the default chat group size
|
||||
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
|
||||
tv_debug 0 // CSTV debug info.
|
||||
tv_delay 0 // CSTV broadcast delay in seconds
|
||||
tv_delaymapchange 1 // Delays map change until broadcast is complete
|
||||
tv_deltacache 2 // Enable delta entity bit stream cache
|
||||
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
|
||||
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
|
||||
tv_maxclients 10 // Maximum client number on CSTV server.
|
||||
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
|
||||
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
|
||||
tv_overridemaster 0 // Overrides the CSTV master root address.
|
||||
tv_port 27020 // Host SourceTV port
|
||||
tv_password "changeme" // CSTV password for clients
|
||||
tv_relaypassword "changeme" // CSTV password for relay proxies
|
||||
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
|
||||
tv_timeout 60 // CSTV connection timeout in seconds.
|
||||
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
|
||||
tv_transmitall 1 // Transmit all entities (not only director view)
|
||||
|
||||
// Logs
|
||||
|
||||
log on // Turns logging 'on' or 'off', defaults to 'on'
|
||||
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
|
||||
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
|
||||
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
|
||||
`;
|
||||
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
|
||||
// CS2 Server Config
|
||||
// ============================================
|
||||
|
||||
// ---- Sunucu Bilgileri ----
|
||||
hostname "SourceGamePanel CS2 Server"
|
||||
sv_password ""
|
||||
rcon_password "changeme"
|
||||
sv_cheats 0
|
||||
|
||||
// ---- Topluluk Sunucu Gorunurlugu ----
|
||||
sv_region 3
|
||||
sv_tags "competitive,community"
|
||||
sv_lan 0
|
||||
sv_steamgroup ""
|
||||
sv_steamgroup_exclusive 0
|
||||
|
||||
// ---- Performans ----
|
||||
sv_maxrate 0
|
||||
sv_minrate 64000
|
||||
sv_max_queries_sec 5
|
||||
sv_max_queries_window 30
|
||||
sv_parallel_sendsnapshot 1
|
||||
net_maxroutable 1200
|
||||
|
||||
// ---- Baglanti ----
|
||||
sv_maxclients 16
|
||||
sv_timeout 60
|
||||
|
||||
// ---- GOTV (Tamamen Kapali) ----
|
||||
tv_enable 0
|
||||
tv_autorecord 0
|
||||
tv_delay 0
|
||||
tv_maxclients 0
|
||||
tv_port 0
|
||||
|
||||
// ---- Loglama ----
|
||||
log on
|
||||
mp_logmoney 0
|
||||
mp_logdetail 0
|
||||
mp_logdetail_items 0
|
||||
sv_logfile 1
|
||||
|
||||
// ---- Genel Oyun Ayarlari ----
|
||||
mp_autokick 0
|
||||
sv_allow_votes 0
|
||||
sv_alltalk 0
|
||||
sv_deadtalk 1
|
||||
sv_voiceenable 1
|
||||
`;
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
const normalized = path
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/{2,}/g, '/');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('Server responded with NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeComparableContent(content: string): string {
|
||||
return content.replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
export function isManagedCs2ServerConfigPath(gameSlug: string, path: string): boolean {
|
||||
return (
|
||||
gameSlug.trim().toLowerCase() === 'cs2' &&
|
||||
normalizePath(path) === CS2_SERVER_CFG_PATH
|
||||
);
|
||||
}
|
||||
|
||||
export async function readManagedCs2ServerConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const persisted = await daemonReadFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH);
|
||||
return persisted.data.toString('utf8');
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const current = await daemonReadFile(node, serverUuid, CS2_SERVER_CFG_PATH);
|
||||
const content = current.data.toString('utf8');
|
||||
const nextContent =
|
||||
normalizeComparableContent(content) === normalizeComparableContent(LEGACY_IMAGE_CS2_SERVER_CFG)
|
||||
? DEFAULT_CS2_SERVER_CFG
|
||||
: content;
|
||||
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, nextContent);
|
||||
return nextContent;
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG);
|
||||
return DEFAULT_CS2_SERVER_CFG;
|
||||
}
|
||||
|
||||
export async function writeManagedCs2ServerConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
content: string | Buffer,
|
||||
): Promise<void> {
|
||||
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, content);
|
||||
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
|
||||
}
|
||||
|
||||
export async function reapplyManagedCs2ServerConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
): Promise<void> {
|
||||
const content = await readManagedCs2ServerConfig(node, serverUuid);
|
||||
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export interface DaemonCreateServerRequest {
|
||||
environment: Record<string, string>;
|
||||
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<string, string>;
|
||||
ports: DaemonPortMapping[];
|
||||
data_path: string;
|
||||
stop_command: string;
|
||||
stop_timeout_seconds: number;
|
||||
}
|
||||
|
||||
export interface DaemonPowerOptions {
|
||||
/** In-game shutdown command; lets the daemon skip the SIGTERM wait entirely. */
|
||||
stopCommand?: string | null;
|
||||
/** Total graceful-shutdown budget in seconds. */
|
||||
stopTimeoutSeconds?: number | null;
|
||||
}
|
||||
|
||||
interface DaemonServerResponse {
|
||||
@@ -216,7 +229,12 @@ interface DaemonServiceClient extends grpc.Client {
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
setPowerState(
|
||||
request: { uuid: string; action: number },
|
||||
request: {
|
||||
uuid: string;
|
||||
action: number;
|
||||
stop_command: string;
|
||||
stop_timeout_seconds: number;
|
||||
},
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): 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<void> {
|
||||
const stopTimeoutSeconds = Number(options.stopTimeoutSeconds) > 0
|
||||
? Math.floor(Number(options.stopTimeoutSeconds))
|
||||
: 0;
|
||||
|
||||
// The daemon waits out the shutdown before replying, so the RPC deadline has
|
||||
// to outlive the game's own budget (ARK saves its world for minutes).
|
||||
const rpcTimeoutMs =
|
||||
action === 'stop' || action === 'restart'
|
||||
? Math.min(Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS), MAX_POWER_RPC_TIMEOUT_MS)
|
||||
: POWER_RPC_TIMEOUT_MS;
|
||||
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.setPowerState(
|
||||
{ uuid: serverUuid, action: POWER_ACTIONS[action] },
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
|
||||
/**
|
||||
* Some game images run a SteamCMD `app_update ... validate` on every container
|
||||
* start, which rewrites config files that ship with the game back to their
|
||||
* stock contents. The panel therefore keeps its own copy of every managed
|
||||
* config file in a hidden sidecar next to the real one, and restores the real
|
||||
* file whenever the game resets it.
|
||||
*/
|
||||
export interface ManagedConfigFile {
|
||||
/** Path of the real file, relative to the server data directory. */
|
||||
path: string;
|
||||
/** Sidecar holding the panel's copy of record. */
|
||||
shadowPath: string;
|
||||
/** Base name of the sidecar, so the file browser can hide it. */
|
||||
shadowFileName: string;
|
||||
/** Written when neither the real file nor the sidecar exists yet. */
|
||||
defaultContent: string;
|
||||
/**
|
||||
* Stock contents shipped by the image. When the sidecar is adopted from an
|
||||
* existing install, contents matching one of these are replaced by
|
||||
* `defaultContent` instead of being preserved.
|
||||
*/
|
||||
imageDefaults: string[];
|
||||
}
|
||||
|
||||
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
|
||||
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
|
||||
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
|
||||
|
||||
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
|
||||
|
||||
hostname "GamePanel CS2 Server" // Set server hostname
|
||||
sv_cheats 0 // Enable or disable cheats
|
||||
sv_hibernate_when_empty 0 // Disable server hibernation
|
||||
|
||||
// Passwords
|
||||
|
||||
rcon_password "" // Set rcon password
|
||||
sv_password "" // Set server password
|
||||
|
||||
// CSTV
|
||||
|
||||
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
|
||||
|
||||
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
|
||||
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
|
||||
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
|
||||
tv_chatgroupsize 0 // Set the default chat group size
|
||||
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
|
||||
tv_debug 0 // CSTV debug info.
|
||||
tv_delay 0 // CSTV broadcast delay in seconds
|
||||
tv_delaymapchange 1 // Delays map change until broadcast is complete
|
||||
tv_deltacache 2 // Enable delta entity bit stream cache
|
||||
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
|
||||
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
|
||||
tv_maxclients 10 // Maximum client number on CSTV server.
|
||||
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
|
||||
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
|
||||
tv_overridemaster 0 // Overrides the CSTV master root address.
|
||||
tv_port 27020 // Host SourceTV port
|
||||
tv_password "changeme" // CSTV password for clients
|
||||
tv_relaypassword "changeme" // CSTV password for relay proxies
|
||||
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
|
||||
tv_timeout 60 // CSTV connection timeout in seconds.
|
||||
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
|
||||
tv_transmitall 1 // Transmit all entities (not only director view)
|
||||
|
||||
// Logs
|
||||
|
||||
log on // Turns logging 'on' or 'off', defaults to 'on'
|
||||
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
|
||||
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
|
||||
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
|
||||
`;
|
||||
|
||||
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
|
||||
// CS2 Server Config
|
||||
// ============================================
|
||||
|
||||
// ---- Sunucu Bilgileri ----
|
||||
hostname "SourceGamePanel CS2 Server"
|
||||
sv_password ""
|
||||
rcon_password "changeme"
|
||||
sv_cheats 0
|
||||
|
||||
// ---- Topluluk Sunucu Gorunurlugu ----
|
||||
sv_region 3
|
||||
sv_tags "competitive,community"
|
||||
sv_lan 0
|
||||
sv_steamgroup ""
|
||||
sv_steamgroup_exclusive 0
|
||||
|
||||
// ---- Performans ----
|
||||
sv_maxrate 0
|
||||
sv_minrate 64000
|
||||
sv_max_queries_sec 5
|
||||
sv_max_queries_window 30
|
||||
sv_parallel_sendsnapshot 1
|
||||
net_maxroutable 1200
|
||||
|
||||
// ---- Baglanti ----
|
||||
sv_maxclients 16
|
||||
sv_timeout 60
|
||||
|
||||
// ---- GOTV (Tamamen Kapali) ----
|
||||
tv_enable 0
|
||||
tv_autorecord 0
|
||||
tv_delay 0
|
||||
tv_maxclients 0
|
||||
tv_port 0
|
||||
|
||||
// ---- Loglama ----
|
||||
log on
|
||||
mp_logmoney 0
|
||||
mp_logdetail 0
|
||||
mp_logdetail_items 0
|
||||
sv_logfile 1
|
||||
|
||||
// ---- Genel Oyun Ayarlari ----
|
||||
mp_autokick 0
|
||||
sv_allow_votes 0
|
||||
sv_alltalk 0
|
||||
sv_deadtalk 1
|
||||
sv_voiceenable 1
|
||||
`;
|
||||
|
||||
const MANAGED_CONFIG_FILES: Record<string, ManagedConfigFile[]> = {
|
||||
cs2: [
|
||||
{
|
||||
path: CS2_SERVER_CFG_PATH,
|
||||
shadowPath: CS2_PERSISTED_SERVER_CFG_PATH,
|
||||
shadowFileName: CS2_PERSISTED_SERVER_CFG_FILE,
|
||||
defaultContent: DEFAULT_CS2_SERVER_CFG,
|
||||
imageDefaults: [LEGACY_IMAGE_CS2_SERVER_CFG],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('No such file or directory') ||
|
||||
message.includes('Server responded with NOT_FOUND') ||
|
||||
message.includes('status code 404')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeComparableContent(content: string): string {
|
||||
return content.replace(/\r\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
export function managedConfigFilesForGame(gameSlug: string): ManagedConfigFile[] {
|
||||
return MANAGED_CONFIG_FILES[gameSlug.trim().toLowerCase()] ?? [];
|
||||
}
|
||||
|
||||
/** The managed file a request path refers to, or `null` if it is not managed. */
|
||||
export function managedConfigFileFor(
|
||||
gameSlug: string,
|
||||
path: string,
|
||||
): ManagedConfigFile | null {
|
||||
const normalized = normalizePath(path);
|
||||
return (
|
||||
managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function isManagedConfigShadowFile(gameSlug: string, fileName: string): boolean {
|
||||
const normalized = fileName.trim();
|
||||
return managedConfigFilesForGame(gameSlug).some(
|
||||
(file) => file.shadowFileName === normalized,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the panel's copy of a managed config file, adopting whatever is on disk
|
||||
* the first time around.
|
||||
*/
|
||||
export async function readManagedConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
file: ManagedConfigFile,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const persisted = await daemonReadFile(node, serverUuid, file.shadowPath);
|
||||
return persisted.data.toString('utf8');
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const current = await daemonReadFile(node, serverUuid, file.path);
|
||||
const content = current.data.toString('utf8');
|
||||
const isStockContent = file.imageDefaults.some(
|
||||
(stock) => normalizeComparableContent(stock) === normalizeComparableContent(content),
|
||||
);
|
||||
const nextContent = isStockContent ? file.defaultContent : content;
|
||||
await daemonWriteFile(node, serverUuid, file.shadowPath, nextContent);
|
||||
return nextContent;
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
await daemonWriteFile(node, serverUuid, file.shadowPath, file.defaultContent);
|
||||
return file.defaultContent;
|
||||
}
|
||||
|
||||
/** Write a managed config file, keeping the panel's copy in sync. */
|
||||
export async function writeManagedConfig(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
file: ManagedConfigFile,
|
||||
content: string | Buffer,
|
||||
): Promise<void> {
|
||||
await daemonWriteFile(node, serverUuid, file.shadowPath, content);
|
||||
await daemonWriteFile(node, serverUuid, file.path, content);
|
||||
}
|
||||
|
||||
// === Drift watcher ===
|
||||
|
||||
/**
|
||||
* How long to keep watching after a start. This has to outlast the image's own
|
||||
* update/validate step — for CS2 that is a multi-gigabyte SteamCMD run that can
|
||||
* easily take 10+ minutes on a cold cache, and it rewrites `server.cfg` when it
|
||||
* finishes. Watching for only a minute is why edited configs kept coming back.
|
||||
*/
|
||||
const SUSTAIN_WINDOW_MS = Number(process.env.MANAGED_CONFIG_SUSTAIN_MS) || 30 * 60_000;
|
||||
const FAST_INTERVAL_MS = 5_000;
|
||||
const SLOW_INTERVAL_MS = 20_000;
|
||||
const FAST_PHASE_MS = 2 * 60_000;
|
||||
/** Consecutive drift-free polls needed before the watcher stops early. */
|
||||
const REQUIRED_STABLE_ROUNDS = 6;
|
||||
/** Never stop early before this much of the window has elapsed. */
|
||||
const MIN_WATCH_MS = 3 * 60_000;
|
||||
|
||||
/** One watcher per server; a newer start supersedes the one already running. */
|
||||
const activeWatchers = new Map<string, symbol>();
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function restoreDriftedFile(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
file: ManagedConfigFile,
|
||||
): Promise<boolean> {
|
||||
const expected = await readManagedConfig(node, serverUuid, file);
|
||||
|
||||
let live: string | null = null;
|
||||
try {
|
||||
const current = await daemonReadFile(node, serverUuid, file.path);
|
||||
live = current.data.toString('utf8');
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) throw error;
|
||||
}
|
||||
|
||||
if (live !== null && normalizeComparableContent(live) === normalizeComparableContent(expected)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await daemonWriteFile(node, serverUuid, file.path, expected);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Restore every managed config file for a game to the panel's copy. */
|
||||
export async function reapplyManagedConfigs(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
gameSlug: string,
|
||||
): Promise<void> {
|
||||
for (const file of managedConfigFilesForGame(gameSlug)) {
|
||||
await restoreDriftedFile(node, serverUuid, file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a server's managed config files after a start and put the panel's
|
||||
* version back whenever the game overwrites it.
|
||||
*
|
||||
* `isServerActive` lets the caller abort once the server leaves the running
|
||||
* state, so a stopped server never gets its files rewritten behind its back.
|
||||
*/
|
||||
export function sustainManagedConfigsAfterStart(
|
||||
app: FastifyInstance,
|
||||
options: {
|
||||
node: DaemonNodeConnection;
|
||||
serverId: string;
|
||||
serverUuid: string;
|
||||
gameSlug: string;
|
||||
isServerActive: () => Promise<boolean>;
|
||||
},
|
||||
): void {
|
||||
const files = managedConfigFilesForGame(options.gameSlug);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const token = Symbol(options.serverId);
|
||||
activeWatchers.set(options.serverId, token);
|
||||
|
||||
void (async () => {
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + SUSTAIN_WINDOW_MS;
|
||||
let stableRounds = 0;
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
await sleep(elapsed < FAST_PHASE_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
|
||||
|
||||
if (activeWatchers.get(options.serverId) !== token) return;
|
||||
|
||||
let active: boolean;
|
||||
try {
|
||||
active = await options.isServerActive();
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{ error, serverId: options.serverId },
|
||||
'Managed config watcher could not read server status',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
app.log.debug(
|
||||
{ serverId: options.serverId },
|
||||
'Managed config watcher stopping: server is no longer running',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let drifted = false;
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (await restoreDriftedFile(options.node, options.serverUuid, file)) {
|
||||
drifted = true;
|
||||
app.log.info(
|
||||
{
|
||||
serverId: options.serverId,
|
||||
serverUuid: options.serverUuid,
|
||||
gameSlug: options.gameSlug,
|
||||
path: file.path,
|
||||
},
|
||||
'Restored managed config file after the game reset it',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{
|
||||
error,
|
||||
serverId: options.serverId,
|
||||
serverUuid: options.serverUuid,
|
||||
path: file.path,
|
||||
},
|
||||
'Failed to restore managed config file',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stableRounds = drifted ? 0 : stableRounds + 1;
|
||||
|
||||
if (
|
||||
stableRounds >= REQUIRED_STABLE_ROUNDS &&
|
||||
Date.now() - startedAt >= MIN_WATCH_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (activeWatchers.get(options.serverId) === token) {
|
||||
activeWatchers.delete(options.serverId);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<void> {
|
||||
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, {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String> {
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert(LABEL_DATA_PATH.to_string(), container_data_path(spec));
|
||||
|
||||
if let Some(stop_command) = spec.runtime.stop_command.as_deref() {
|
||||
labels.insert(LABEL_STOP_COMMAND.to_string(), stop_command.to_string());
|
||||
}
|
||||
if let Some(timeout) = spec.runtime.stop_timeout_seconds {
|
||||
labels.insert(LABEL_STOP_TIMEOUT.to_string(), timeout.to_string());
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
fn runtime_from_labels(labels: Option<&HashMap<String, String>>) -> ServerRuntime {
|
||||
let Some(labels) = labels else {
|
||||
return ServerRuntime::default();
|
||||
};
|
||||
|
||||
ServerRuntime {
|
||||
data_mount_path: labels.get(LABEL_DATA_PATH).cloned(),
|
||||
stop_command: labels.get(LABEL_STOP_COMMAND).cloned(),
|
||||
stop_timeout_seconds: labels
|
||||
.get(LABEL_STOP_TIMEOUT)
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.filter(|value| *value > 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_wolveix_satisfactory_image(image: &str) -> bool {
|
||||
image
|
||||
.to_ascii_lowercase()
|
||||
@@ -69,6 +134,7 @@ impl DockerManager {
|
||||
async fn attach_command_stream(
|
||||
&self,
|
||||
container_name: &str,
|
||||
container_id: String,
|
||||
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
||||
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<String> {
|
||||
let name = container_name(server_uuid);
|
||||
let info = match self.client().inspect_container(&name, None).await {
|
||||
Ok(info) => info,
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"server container does not exist (server has not been installed yet)"
|
||||
))
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
let running = info
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.running)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !running {
|
||||
return Err(anyhow::anyhow!(
|
||||
"server is not running, start it before sending console commands"
|
||||
));
|
||||
}
|
||||
|
||||
info.id
|
||||
.ok_or_else(|| anyhow::anyhow!("Docker did not report a container id"))
|
||||
}
|
||||
|
||||
async fn get_or_attach_command_stream(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
container_id: &str,
|
||||
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
||||
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<String> {
|
||||
let name = container_name(server_uuid);
|
||||
let info = self.client().inspect_container(&name, None).await.ok()?;
|
||||
let networks = info.network_settings.as_ref()?;
|
||||
|
||||
if let Some(named) = networks.networks.as_ref() {
|
||||
if let Some(ip) = named
|
||||
.get(self.network_name())
|
||||
.and_then(|net| net.ip_address.clone())
|
||||
.filter(|ip| !ip.is_empty())
|
||||
{
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = named
|
||||
.values()
|
||||
.filter_map(|net| net.ip_address.clone())
|
||||
.find(|ip| !ip.is_empty())
|
||||
{
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
|
||||
networks
|
||||
.ip_address
|
||||
.clone()
|
||||
.filter(|ip| !ip.is_empty())
|
||||
}
|
||||
|
||||
/// Resolve `(address, password)` for the container's RCON endpoint from its
|
||||
/// image and environment.
|
||||
async fn rcon_endpoint(&self, server_uuid: &str) -> Result<(String, String)> {
|
||||
let (image, env) = self.container_runtime_metadata(server_uuid).await?;
|
||||
let normalized = image.to_ascii_lowercase();
|
||||
|
||||
let lookup = |keys: &[&str]| -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|key| env.get(*key))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
};
|
||||
|
||||
let is_ark = normalized.contains("ark-server")
|
||||
|| normalized.contains("ark-survival-evolved");
|
||||
|
||||
let (password_keys, port_keys, default_port): (&[&str], &[&str], u16) = if is_ark {
|
||||
(
|
||||
&["ARK_RCON_PASSWORD", "RCON_PASSWORD", "ADMIN_PASSWORD"],
|
||||
&["RCON_PORT"],
|
||||
27020,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
&["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"],
|
||||
&["RCON_PORT", "CS2_PORT"],
|
||||
27015,
|
||||
)
|
||||
};
|
||||
|
||||
let password = lookup(password_keys)
|
||||
.ok_or_else(|| anyhow::anyhow!("no RCON password is configured for this server"))?;
|
||||
let port = lookup(port_keys)
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(default_port);
|
||||
|
||||
let host = match lookup(&["RCON_HOST"]) {
|
||||
Some(host) => host,
|
||||
None => self
|
||||
.container_ip(server_uuid)
|
||||
.await
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
};
|
||||
|
||||
Ok((format!("{host}:{port}"), password))
|
||||
}
|
||||
|
||||
async fn send_command_via_rcon(&self, server_uuid: &str, command: &str) -> Result<()> {
|
||||
let (address, password) = self.rcon_endpoint(server_uuid).await?;
|
||||
let mut client = crate::game::rcon::RconClient::connect(&address, &password).await?;
|
||||
client.command(command).await?;
|
||||
debug!(server_uuid = %server_uuid, address = %address, "Console command delivered over RCON");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_command_via_stdin(
|
||||
&self,
|
||||
server_uuid: &str,
|
||||
container_id: &str,
|
||||
command: &str,
|
||||
) -> Result<()> {
|
||||
let payload = format!("{command}\n");
|
||||
|
||||
for attempt in 0..2 {
|
||||
let stream = self
|
||||
.get_or_attach_command_stream(server_uuid, container_id)
|
||||
.await?;
|
||||
|
||||
match stream.write_all(payload.as_bytes()).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => {
|
||||
debug!(
|
||||
server_uuid = %server_uuid,
|
||||
attempt,
|
||||
error = %error,
|
||||
"Failed to write to container stdin, resetting attach stream",
|
||||
);
|
||||
self.clear_command_stream(server_uuid).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("failed to write command to container stdin"))
|
||||
}
|
||||
|
||||
/// Pull a Docker image if not already present.
|
||||
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<String> {
|
||||
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<String, Option<Vec<PortBinding>>> = 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<bool> {
|
||||
let deadline =
|
||||
tokio::time::Instant::now() + Duration::from_secs(timeout_secs.max(1) as u64);
|
||||
let name = container_name(server_uuid);
|
||||
|
||||
loop {
|
||||
match self.client().inspect_container(&name, None).await {
|
||||
Ok(info) => {
|
||||
let running = info
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.running)
|
||||
.unwrap_or(false);
|
||||
if !running {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => return Ok(true),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill a container immediately.
|
||||
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})")
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Box<dyn AsyncWrite + Send>>;
|
||||
|
||||
pub(crate) struct CommandStreamHandle {
|
||||
/// Docker id of the container this stdin stream was opened against. A
|
||||
/// container that gets recreated (or restarted) keeps the same name but
|
||||
/// gets a new id, and writes to the old hijacked socket are silently
|
||||
/// swallowed — so the id is what makes a cached stream reusable.
|
||||
container_id: String,
|
||||
input: Mutex<AttachedInput>,
|
||||
drain_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CommandStreamHandle {
|
||||
pub(crate) fn new(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<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>,
|
||||
}
|
||||
|
||||
impl DockerManager {
|
||||
pub async fn new(config: &DockerConfig) -> Result<Self> {
|
||||
pub async fn new(config: &DaemonConfig) -> Result<Self> {
|
||||
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<RwLock<HashMap<String, Arc<CommandStreamHandle>>>> {
|
||||
&self.command_streams
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use super::rcon::RconClient;
|
||||
|
||||
/// Player information from an ARK RCON `ListPlayers` response.
|
||||
pub struct ArkPlayer {
|
||||
pub name: String,
|
||||
pub steamid: String,
|
||||
}
|
||||
|
||||
/// Query an ARK server for its connected players.
|
||||
pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<Vec<ArkPlayer>> {
|
||||
let mut client = RconClient::connect(rcon_address, rcon_password).await?;
|
||||
let response = client.command("ListPlayers").await?;
|
||||
|
||||
let players = parse_list_players_response(&response);
|
||||
info!(count = players.len(), "ARK player list retrieved");
|
||||
|
||||
Ok(players)
|
||||
}
|
||||
|
||||
/// Parses lines shaped like `0. PlayerName, 76561198000000000`.
|
||||
fn parse_list_players_response(response: &str) -> Vec<ArkPlayer> {
|
||||
let mut players = Vec::new();
|
||||
|
||||
for line in response.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// "No Players Connected"
|
||||
if trimmed.eq_ignore_ascii_case("no players connected") {
|
||||
break;
|
||||
}
|
||||
|
||||
// Strip the "<index>. " prefix.
|
||||
let entry = match trimmed.split_once('.') {
|
||||
Some((index, rest)) if index.trim().chars().all(|c| c.is_ascii_digit()) => rest.trim(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let (name, steamid) = match entry.rsplit_once(',') {
|
||||
Some((name, steamid)) => (name.trim(), steamid.trim()),
|
||||
None => (entry, ""),
|
||||
};
|
||||
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
players.push(ArkPlayer {
|
||||
name: name.to_string(),
|
||||
steamid: steamid.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
players
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_connected_players() {
|
||||
let response = "0. Alper, 76561198000000001\n1. Rezan, 76561198000000002\n";
|
||||
let players = parse_list_players_response(response);
|
||||
|
||||
assert_eq!(players.len(), 2);
|
||||
assert_eq!(players[0].name, "Alper");
|
||||
assert_eq!(players[0].steamid, "76561198000000001");
|
||||
assert_eq!(players[1].name, "Rezan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_server() {
|
||||
assert!(parse_list_players_response("No Players Connected\n").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod rcon;
|
||||
pub mod minecraft;
|
||||
pub mod cs2;
|
||||
pub mod ark;
|
||||
|
||||
@@ -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::<i32>().ok())
|
||||
}
|
||||
|
||||
/// Host to reach a server's RCON port on. The container's own IP works both
|
||||
/// when the daemon runs on the host and when it runs as a sibling container;
|
||||
/// `127.0.0.1` only works in the former case.
|
||||
async fn rcon_host(&self, uuid: &str, env: &HashMap<String, String>) -> String {
|
||||
if let Some(host) = Self::env_value(env, &["RCON_HOST"]) {
|
||||
return host;
|
||||
}
|
||||
|
||||
self.server_manager
|
||||
.docker()
|
||||
.container_ip(uuid)
|
||||
.await
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string())
|
||||
}
|
||||
|
||||
fn cs2_rcon_password(env: &HashMap<String, String>) -> String {
|
||||
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);
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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<String, String>,
|
||||
ports: Vec<PortMap>,
|
||||
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<String, String>,
|
||||
ports: Vec<PortMap>,
|
||||
runtime: ServerRuntime,
|
||||
) -> Result<ServerState, DaemonError> {
|
||||
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<ServerState> = 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// In-game command that shuts the server down cleanly (e.g. `stop`, `quit`).
|
||||
pub stop_command: Option<String>,
|
||||
/// Total budget for a graceful shutdown before the container gets killed.
|
||||
pub stop_timeout_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
impl ServerRuntime {
|
||||
pub fn from_request(
|
||||
data_mount_path: String,
|
||||
stop_command: String,
|
||||
stop_timeout_seconds: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
data_mount_path: non_empty(data_mount_path),
|
||||
stop_command: non_empty(stop_command),
|
||||
stop_timeout_seconds: if stop_timeout_seconds > 0 {
|
||||
Some(stop_timeout_seconds as i64)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty(value: String) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerSpec {
|
||||
pub uuid: String,
|
||||
@@ -46,6 +86,8 @@ pub struct ServerSpec {
|
||||
pub data_path: PathBuf,
|
||||
pub state: ServerState,
|
||||
pub container_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub runtime: ServerRuntime,
|
||||
}
|
||||
|
||||
impl ServerSpec {
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user