fix: something

This commit is contained in:
hibna
2026-08-02 20:26:54 +03:00
parent 5215560ede
commit 11924416a9
38 changed files with 2198 additions and 466 deletions
+17
View File
@@ -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
-178
View File
@@ -1,178 +0,0 @@
import {
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
hostname "GamePanel CS2 Server" // Set server hostname
sv_cheats 0 // Enable or disable cheats
sv_hibernate_when_empty 0 // Disable server hibernation
// Passwords
rcon_password "" // Set rcon password
sv_password "" // Set server password
// CSTV
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
tv_chatgroupsize 0 // Set the default chat group size
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
tv_debug 0 // CSTV debug info.
tv_delay 0 // CSTV broadcast delay in seconds
tv_delaymapchange 1 // Delays map change until broadcast is complete
tv_deltacache 2 // Enable delta entity bit stream cache
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
tv_maxclients 10 // Maximum client number on CSTV server.
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
tv_overridemaster 0 // Overrides the CSTV master root address.
tv_port 27020 // Host SourceTV port
tv_password "changeme" // CSTV password for clients
tv_relaypassword "changeme" // CSTV password for relay proxies
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
tv_timeout 60 // CSTV connection timeout in seconds.
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
tv_transmitall 1 // Transmit all entities (not only director view)
// Logs
log on // Turns logging 'on' or 'off', defaults to 'on'
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
`;
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
// CS2 Server Config
// ============================================
// ---- Sunucu Bilgileri ----
hostname "SourceGamePanel CS2 Server"
sv_password ""
rcon_password "changeme"
sv_cheats 0
// ---- Topluluk Sunucu Gorunurlugu ----
sv_region 3
sv_tags "competitive,community"
sv_lan 0
sv_steamgroup ""
sv_steamgroup_exclusive 0
// ---- Performans ----
sv_maxrate 0
sv_minrate 64000
sv_max_queries_sec 5
sv_max_queries_window 30
sv_parallel_sendsnapshot 1
net_maxroutable 1200
// ---- Baglanti ----
sv_maxclients 16
sv_timeout 60
// ---- GOTV (Tamamen Kapali) ----
tv_enable 0
tv_autorecord 0
tv_delay 0
tv_maxclients 0
tv_port 0
// ---- Loglama ----
log on
mp_logmoney 0
mp_logdetail 0
mp_logdetail_items 0
sv_logfile 1
// ---- Genel Oyun Ayarlari ----
mp_autokick 0
sv_allow_votes 0
sv_alltalk 0
sv_deadtalk 1
sv_voiceenable 1
`;
function normalizePath(path: string): string {
const normalized = path
.trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/\/{2,}/g, '/');
return normalized;
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizeComparableContent(content: string): string {
return content.replace(/\r\n/g, '\n').trim();
}
export function isManagedCs2ServerConfigPath(gameSlug: string, path: string): boolean {
return (
gameSlug.trim().toLowerCase() === 'cs2' &&
normalizePath(path) === CS2_SERVER_CFG_PATH
);
}
export async function readManagedCs2ServerConfig(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<string> {
try {
const persisted = await daemonReadFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH);
return persisted.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
try {
const current = await daemonReadFile(node, serverUuid, CS2_SERVER_CFG_PATH);
const content = current.data.toString('utf8');
const nextContent =
normalizeComparableContent(content) === normalizeComparableContent(LEGACY_IMAGE_CS2_SERVER_CFG)
? DEFAULT_CS2_SERVER_CFG
: content;
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, nextContent);
return nextContent;
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG);
return DEFAULT_CS2_SERVER_CFG;
}
export async function writeManagedCs2ServerConfig(
node: DaemonNodeConnection,
serverUuid: string,
content: string | Buffer,
): Promise<void> {
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, content);
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
}
export async function reapplyManagedCs2ServerConfig(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<void> {
const content = await readManagedCs2ServerConfig(node, serverUuid);
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
}
+39 -3
View File
@@ -25,6 +25,9 @@ export interface DaemonCreateServerRequest {
environment: Record<string, string>;
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();
+387
View File
@@ -0,0 +1,387 @@
import type { FastifyInstance } from 'fastify';
import {
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
/**
* Some game images run a SteamCMD `app_update ... validate` on every container
* start, which rewrites config files that ship with the game back to their
* stock contents. The panel therefore keeps its own copy of every managed
* config file in a hidden sidecar next to the real one, and restores the real
* file whenever the game resets it.
*/
export interface ManagedConfigFile {
/** Path of the real file, relative to the server data directory. */
path: string;
/** Sidecar holding the panel's copy of record. */
shadowPath: string;
/** Base name of the sidecar, so the file browser can hide it. */
shadowFileName: string;
/** Written when neither the real file nor the sidecar exists yet. */
defaultContent: string;
/**
* Stock contents shipped by the image. When the sidecar is adopted from an
* existing install, contents matching one of these are replaced by
* `defaultContent` instead of being preserved.
*/
imageDefaults: string[];
}
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
hostname "GamePanel CS2 Server" // Set server hostname
sv_cheats 0 // Enable or disable cheats
sv_hibernate_when_empty 0 // Disable server hibernation
// Passwords
rcon_password "" // Set rcon password
sv_password "" // Set server password
// CSTV
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
tv_chatgroupsize 0 // Set the default chat group size
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
tv_debug 0 // CSTV debug info.
tv_delay 0 // CSTV broadcast delay in seconds
tv_delaymapchange 1 // Delays map change until broadcast is complete
tv_deltacache 2 // Enable delta entity bit stream cache
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
tv_maxclients 10 // Maximum client number on CSTV server.
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
tv_overridemaster 0 // Overrides the CSTV master root address.
tv_port 27020 // Host SourceTV port
tv_password "changeme" // CSTV password for clients
tv_relaypassword "changeme" // CSTV password for relay proxies
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
tv_timeout 60 // CSTV connection timeout in seconds.
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
tv_transmitall 1 // Transmit all entities (not only director view)
// Logs
log on // Turns logging 'on' or 'off', defaults to 'on'
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
`;
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
// CS2 Server Config
// ============================================
// ---- Sunucu Bilgileri ----
hostname "SourceGamePanel CS2 Server"
sv_password ""
rcon_password "changeme"
sv_cheats 0
// ---- Topluluk Sunucu Gorunurlugu ----
sv_region 3
sv_tags "competitive,community"
sv_lan 0
sv_steamgroup ""
sv_steamgroup_exclusive 0
// ---- Performans ----
sv_maxrate 0
sv_minrate 64000
sv_max_queries_sec 5
sv_max_queries_window 30
sv_parallel_sendsnapshot 1
net_maxroutable 1200
// ---- Baglanti ----
sv_maxclients 16
sv_timeout 60
// ---- GOTV (Tamamen Kapali) ----
tv_enable 0
tv_autorecord 0
tv_delay 0
tv_maxclients 0
tv_port 0
// ---- Loglama ----
log on
mp_logmoney 0
mp_logdetail 0
mp_logdetail_items 0
sv_logfile 1
// ---- Genel Oyun Ayarlari ----
mp_autokick 0
sv_allow_votes 0
sv_alltalk 0
sv_deadtalk 1
sv_voiceenable 1
`;
const MANAGED_CONFIG_FILES: Record<string, ManagedConfigFile[]> = {
cs2: [
{
path: CS2_SERVER_CFG_PATH,
shadowPath: CS2_PERSISTED_SERVER_CFG_PATH,
shadowFileName: CS2_PERSISTED_SERVER_CFG_FILE,
defaultContent: DEFAULT_CS2_SERVER_CFG,
imageDefaults: [LEGACY_IMAGE_CS2_SERVER_CFG],
},
],
};
function normalizePath(path: string): string {
return path
.trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/\/{2,}/g, '/');
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizeComparableContent(content: string): string {
return content.replace(/\r\n/g, '\n').trim();
}
export function managedConfigFilesForGame(gameSlug: string): ManagedConfigFile[] {
return MANAGED_CONFIG_FILES[gameSlug.trim().toLowerCase()] ?? [];
}
/** The managed file a request path refers to, or `null` if it is not managed. */
export function managedConfigFileFor(
gameSlug: string,
path: string,
): ManagedConfigFile | null {
const normalized = normalizePath(path);
return (
managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null
);
}
export function isManagedConfigShadowFile(gameSlug: string, fileName: string): boolean {
const normalized = fileName.trim();
return managedConfigFilesForGame(gameSlug).some(
(file) => file.shadowFileName === normalized,
);
}
/**
* Read the panel's copy of a managed config file, adopting whatever is on disk
* the first time around.
*/
export async function readManagedConfig(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
): Promise<string> {
try {
const persisted = await daemonReadFile(node, serverUuid, file.shadowPath);
return persisted.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
try {
const current = await daemonReadFile(node, serverUuid, file.path);
const content = current.data.toString('utf8');
const isStockContent = file.imageDefaults.some(
(stock) => normalizeComparableContent(stock) === normalizeComparableContent(content),
);
const nextContent = isStockContent ? file.defaultContent : content;
await daemonWriteFile(node, serverUuid, file.shadowPath, nextContent);
return nextContent;
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
await daemonWriteFile(node, serverUuid, file.shadowPath, file.defaultContent);
return file.defaultContent;
}
/** Write a managed config file, keeping the panel's copy in sync. */
export async function writeManagedConfig(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
content: string | Buffer,
): Promise<void> {
await daemonWriteFile(node, serverUuid, file.shadowPath, content);
await daemonWriteFile(node, serverUuid, file.path, content);
}
// === Drift watcher ===
/**
* How long to keep watching after a start. This has to outlast the image's own
* update/validate step — for CS2 that is a multi-gigabyte SteamCMD run that can
* easily take 10+ minutes on a cold cache, and it rewrites `server.cfg` when it
* finishes. Watching for only a minute is why edited configs kept coming back.
*/
const SUSTAIN_WINDOW_MS = Number(process.env.MANAGED_CONFIG_SUSTAIN_MS) || 30 * 60_000;
const FAST_INTERVAL_MS = 5_000;
const SLOW_INTERVAL_MS = 20_000;
const FAST_PHASE_MS = 2 * 60_000;
/** Consecutive drift-free polls needed before the watcher stops early. */
const REQUIRED_STABLE_ROUNDS = 6;
/** Never stop early before this much of the window has elapsed. */
const MIN_WATCH_MS = 3 * 60_000;
/** One watcher per server; a newer start supersedes the one already running. */
const activeWatchers = new Map<string, symbol>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function restoreDriftedFile(
node: DaemonNodeConnection,
serverUuid: string,
file: ManagedConfigFile,
): Promise<boolean> {
const expected = await readManagedConfig(node, serverUuid, file);
let live: string | null = null;
try {
const current = await daemonReadFile(node, serverUuid, file.path);
live = current.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
if (live !== null && normalizeComparableContent(live) === normalizeComparableContent(expected)) {
return false;
}
await daemonWriteFile(node, serverUuid, file.path, expected);
return true;
}
/** Restore every managed config file for a game to the panel's copy. */
export async function reapplyManagedConfigs(
node: DaemonNodeConnection,
serverUuid: string,
gameSlug: string,
): Promise<void> {
for (const file of managedConfigFilesForGame(gameSlug)) {
await restoreDriftedFile(node, serverUuid, file);
}
}
/**
* Watch a server's managed config files after a start and put the panel's
* version back whenever the game overwrites it.
*
* `isServerActive` lets the caller abort once the server leaves the running
* state, so a stopped server never gets its files rewritten behind its back.
*/
export function sustainManagedConfigsAfterStart(
app: FastifyInstance,
options: {
node: DaemonNodeConnection;
serverId: string;
serverUuid: string;
gameSlug: string;
isServerActive: () => Promise<boolean>;
},
): void {
const files = managedConfigFilesForGame(options.gameSlug);
if (files.length === 0) return;
const token = Symbol(options.serverId);
activeWatchers.set(options.serverId, token);
void (async () => {
const startedAt = Date.now();
const deadline = startedAt + SUSTAIN_WINDOW_MS;
let stableRounds = 0;
try {
while (Date.now() < deadline) {
const elapsed = Date.now() - startedAt;
await sleep(elapsed < FAST_PHASE_MS ? FAST_INTERVAL_MS : SLOW_INTERVAL_MS);
if (activeWatchers.get(options.serverId) !== token) return;
let active: boolean;
try {
active = await options.isServerActive();
} catch (error) {
app.log.warn(
{ error, serverId: options.serverId },
'Managed config watcher could not read server status',
);
continue;
}
if (!active) {
app.log.debug(
{ serverId: options.serverId },
'Managed config watcher stopping: server is no longer running',
);
return;
}
let drifted = false;
for (const file of files) {
try {
if (await restoreDriftedFile(options.node, options.serverUuid, file)) {
drifted = true;
app.log.info(
{
serverId: options.serverId,
serverUuid: options.serverUuid,
gameSlug: options.gameSlug,
path: file.path,
},
'Restored managed config file after the game reset it',
);
}
} catch (error) {
app.log.warn(
{
error,
serverId: options.serverId,
serverUuid: options.serverUuid,
path: file.path,
},
'Failed to restore managed config file',
);
}
}
stableRounds = drifted ? 0 : stableRounds + 1;
if (
stableRounds >= REQUIRED_STABLE_ROUNDS &&
Date.now() - startedAt >= MIN_WATCH_MS
) {
return;
}
}
} finally {
if (activeWatchers.get(options.serverId) === token) {
activeWatchers.delete(options.serverId);
}
}
})();
}
+1 -1
View File
@@ -22,7 +22,7 @@ import {
CS2_PERSISTED_SERVER_CFG_PATH,
CS2_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;
+14 -2
View File
@@ -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,
+2
View File
@@ -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[];
+4
View File
@@ -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())),
+12 -11
View File
@@ -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);
}
+30 -21
View File
@@ -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 };
+119 -72
View File
@@ -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, {