From 5215560ede489b92a1b30e010425abf5f742f70c Mon Sep 17 00:00:00 2001 From: hibna Date: Tue, 21 Jul 2026 22:06:10 +0000 Subject: [PATCH] her sey --- .codex | 0 .gitignore | 3 +- README.md | 4 +- apps/api/src/lib/config-parsers.ts | 4 +- apps/api/src/lib/daemon.ts | 87 +- apps/api/src/lib/fivem-qbcore.ts | 740 +++++++++++ apps/api/src/routes/servers/index.ts | 1167 +++++++++++++---- apps/api/src/routes/servers/schemas.ts | 1 + apps/daemon/src/docker/container.rs | 226 +++- apps/daemon/src/game/satisfactory_run.sh | 157 +++ apps/daemon/src/grpc/service.rs | 22 + apps/daemon/src/main.rs | 3 + apps/daemon/src/managed_mysql.rs | 94 ++ apps/daemon/src/server/manager.rs | 30 + apps/web/src/pages/servers/create.tsx | 247 +++- .../conduit-crash-20260613-113458.txt | 82 ++ .../linux-accumulated-rerun-20260615.md | 374 ++++++ .../linux-bringup-report-20260613.md | 334 +++++ .../linux-console-rerun-20260614.md | 298 +++++ .../linux-entity-rerun-20260614.md | 287 ++++ .../linux-gameevents-rerun-20260614.md | 307 +++++ .../drizzle/0007_satisfactory_game.sql | 66 + packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/seed.ts | 151 ++- packages/proto/daemon.proto | 6 + 25 files changed, 4364 insertions(+), 333 deletions(-) create mode 100644 .codex create mode 100644 apps/api/src/lib/fivem-qbcore.ts create mode 100644 apps/daemon/src/game/satisfactory_run.sh create mode 100644 conduit-bringup-artifacts/conduit-crash-20260613-113458.txt create mode 100644 conduit-bringup-artifacts/linux-accumulated-rerun-20260615.md create mode 100644 conduit-bringup-artifacts/linux-bringup-report-20260613.md create mode 100644 conduit-bringup-artifacts/linux-console-rerun-20260614.md create mode 100644 conduit-bringup-artifacts/linux-entity-rerun-20260614.md create mode 100644 conduit-bringup-artifacts/linux-gameevents-rerun-20260614.md create mode 100644 packages/database/drizzle/0007_satisfactory_game.sql diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index daf7c35..903c0c3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,8 @@ Thumbs.db apps/daemon/target/ # Database -packages/database/drizzle/ +packages/database/drizzle/* +!packages/database/drizzle/0007_satisfactory_game.sql # Common JS/TS coverage/ diff --git a/README.md b/README.md index 9136ec6..bfa3cb2 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,10 @@ source-gamepanel/ | Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — | | Terraria | `ryshe/terraria` | 7777 | keyvalue | — | | Rust | `didstopia/rust-server` | 28015 | — | — | +| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — | +| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — | -Adding new games requires only a database seed entry — no code changes needed. +Many games can be added with a database seed entry alone. Some images still need small daemon-side tweaks for mount paths, port protocols, or config parsing. --- diff --git a/apps/api/src/lib/config-parsers.ts b/apps/api/src/lib/config-parsers.ts index 30b57df..b3243a7 100644 --- a/apps/api/src/lib/config-parsers.ts +++ b/apps/api/src/lib/config-parsers.ts @@ -187,7 +187,7 @@ function parseKeyValue(content: string): ConfigEntry[] { const entries: ConfigEntry[] = []; for (const line of content.split('\n')) { const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('//')) continue; + if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue; // Match: key "value" or key value const match = trimmed.match(/^(\S+)\s+"([^"]*)"/) || trimmed.match(/^(\S+)\s+(.*)/); @@ -210,7 +210,7 @@ function serializeKeyValue(entries: ConfigEntry[], originalContent?: string): st for (const line of lines) { const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('//')) { + if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) { result.push(line); continue; } diff --git a/apps/api/src/lib/daemon.ts b/apps/api/src/lib/daemon.ts index c3c2f4b..9c89769 100644 --- a/apps/api/src/lib/daemon.ts +++ b/apps/api/src/lib/daemon.ts @@ -200,6 +200,11 @@ interface DaemonServiceClient extends grpc.Client { metadata: grpc.Metadata, callback: UnaryCallback, ): void; + importDatabaseSql( + request: { database_name: string; sql: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; updateDatabasePassword( request: { username: string; password: string }, metadata: grpc.Metadata, @@ -319,9 +324,7 @@ function buildGrpcTarget(fqdn: string, grpcPort: number): string { const withoutPath = host.replace(/\/.*$/, ''); if (/^\[.+\](?::\d+)?$/.test(withoutPath)) { - const innerHost = withoutPath - .replace(/^\[/, '') - .replace(/\](?::\d+)?$/, ''); + const innerHost = withoutPath.replace(/^\[/, '').replace(/\](?::\d+)?$/, ''); return `[${innerHost}]:${grpcPort}`; } if (/^[^:]+:\d+$/.test(withoutPath)) { @@ -340,14 +343,10 @@ function getMetadata(daemonToken: string): grpc.Metadata { function createClient(node: DaemonNodeConnection): DaemonServiceClient { const target = buildGrpcTarget(node.fqdn, node.grpcPort); - return new DaemonService( - target, - grpc.credentials.createInsecure(), - { - 'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES, - 'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES, - }, - ) as unknown as DaemonServiceClient; + return new DaemonService(target, grpc.credentials.createInsecure(), { + 'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES, + 'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES, + }) as unknown as DaemonServiceClient; } function waitForReady(client: grpc.Client, timeoutMs: number): Promise { @@ -444,9 +443,7 @@ interface DaemonRequestTimeoutOptions { rpcTimeoutMs?: number; } -export async function daemonGetNodeStatus( - node: DaemonNodeConnection, -): Promise { +export async function daemonGetNodeStatus(node: DaemonNodeConnection): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); @@ -466,9 +463,7 @@ export async function daemonGetNodeStatus( } } -export async function daemonGetNodeStats( - node: DaemonNodeConnection, -): Promise { +export async function daemonGetNodeStats(node: DaemonNodeConnection): Promise { const client = createClient(node); try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); @@ -511,7 +506,8 @@ export async function daemonDeleteServer( try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( - (callback) => client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), + (callback) => + client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); } finally { @@ -593,6 +589,30 @@ export async function daemonUpdateDatabasePassword( } } +export async function daemonImportDatabaseSql( + node: DaemonNodeConnection, + request: { databaseName: string; sql: string }, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + await callUnary( + (callback) => + client.importDatabaseSql( + { + database_name: request.databaseName, + sql: request.sql, + }, + getMetadata(node.daemonToken), + callback, + ), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + export async function daemonDeleteDatabase( node: DaemonNodeConnection, request: { databaseName: string; username: string }, @@ -626,7 +646,12 @@ export async function daemonSetPowerState( try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( - (callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback), + (callback) => + client.setPowerState( + { uuid: serverUuid, action: POWER_ACTIONS[action] }, + getMetadata(node.daemonToken), + callback, + ), POWER_RPC_TIMEOUT_MS, ); } finally { @@ -643,7 +668,8 @@ export async function daemonGetServerStatus( try { await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS); return await callUnary( - (callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), + (callback) => + client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS, ); } finally { @@ -688,7 +714,8 @@ export async function daemonSendCommand( try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( - (callback) => client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback), + (callback) => + client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); } finally { @@ -705,7 +732,8 @@ export async function daemonListFiles( try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const response = await callUnary( - (callback) => client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback), + (callback) => + client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); @@ -731,7 +759,8 @@ export async function daemonReadFile( try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const response = await callUnary( - (callback) => client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback), + (callback) => + client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); @@ -756,7 +785,11 @@ export async function daemonWriteFile( await callUnary( (callback) => client.writeFile( - { uuid: serverUuid, path, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data }, + { + uuid: serverUuid, + path, + data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data, + }, getMetadata(node.daemonToken), callback, ), @@ -776,7 +809,8 @@ export async function daemonDeleteFiles( try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( - (callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback), + (callback) => + client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); } finally { @@ -870,7 +904,8 @@ export async function daemonGetActivePlayers( try { await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); const response = await callUnary( - (callback) => client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), + (callback) => + client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), DEFAULT_RPC_TIMEOUT_MS, ); diff --git a/apps/api/src/lib/fivem-qbcore.ts b/apps/api/src/lib/fivem-qbcore.ts new file mode 100644 index 0000000..8839980 --- /dev/null +++ b/apps/api/src/lib/fivem-qbcore.ts @@ -0,0 +1,740 @@ +import { randomBytes } from 'node:crypto'; +import { gunzipSync } from 'node:zlib'; +import type { FastifyInstance } from 'fastify'; +import { and, asc, eq } from 'drizzle-orm'; +import * as tar from 'tar-stream'; +import type { Headers } from 'tar-stream'; +import * as unzipper from 'unzipper'; +import { serverDatabases, servers } from '@source/database'; +import { + daemonCreateDatabase, + daemonDeleteDatabase, + daemonDeleteFiles, + daemonImportDatabaseSql, + daemonReadFile, + daemonWriteFile, + type DaemonNodeConnection, +} from './daemon.js'; + +const GITHUB_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024; +const URL_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024; +const QBCORE_DATABASE_NAME = 'qbcore'; +const FIVE_M_QBCORE_MARKER_PATH = '/.gamepanel/fivem-qbcore.json'; +const FIVE_M_INTERNAL_PORT = 30120; +const QBCORE_SQL_URL = + 'https://raw.githubusercontent.com/qbcore-framework/txAdminRecipe/main/qbcore.sql'; +const OXMYSQL_ZIP_URL = + 'https://github.com/overextended/oxmysql/releases/download/v2.12.0/oxmysql.zip'; +const MENUV_ZIP_URL = 'https://github.com/ThymonA/menuv/releases/download/v1.4.1/menuv_v1.4.1.zip'; + +interface ExtractedFile { + path: string; + data: Buffer; +} + +interface ManagedServerDatabaseRecord { + id: string; + name: string; + databaseName: string; + username: string; + password: string; + host: string; + port: number; + phpMyAdminUrl: string | null; +} + +interface FivemProvisionContext { + node: DaemonNodeConnection; + serverDescription?: string | null; + serverId: string; + serverName: string; + serverUuid: string; +} + +interface GitHubArchiveResource { + destination: string; + owner: string; + ref: string; + repo: string; + subpath?: string; +} + +interface RemoteArchiveResource { + collapseTopLevelDirectory?: boolean; + destination: string; + url: string; +} + +const FIVEM_GITHUB_RESOURCES: GitHubArchiveResource[] = [ + { + owner: 'citizenfx', + repo: 'cfx-server-data', + ref: 'master', + destination: '/resources/[cfx-default]', + subpath: 'resources', + }, + { + owner: 'qbcore-framework', + repo: 'bob74_ipl', + ref: 'master', + destination: '/resources/[standalone]/bob74_ipl', + }, + { + owner: 'qbcore-framework', + repo: 'safecracker', + ref: 'main', + destination: '/resources/[standalone]/safecracker', + }, + { + owner: 'citizenfx', + repo: 'screenshot-basic', + ref: 'master', + destination: '/resources/[standalone]/screenshot-basic', + }, + { + owner: 'qbcore-framework', + repo: 'progressbar', + ref: 'main', + destination: '/resources/[standalone]/progressbar', + }, + { + owner: 'qbcore-framework', + repo: 'interact-sound', + ref: 'master', + destination: '/resources/[standalone]/interact-sound', + }, + { + owner: 'qbcore-framework', + repo: 'connectqueue', + ref: 'master', + destination: '/resources/[standalone]/connectqueue', + }, + { + owner: 'qbcore-framework', + repo: 'PolyZone', + ref: 'master', + destination: '/resources/[standalone]/PolyZone', + }, + { + owner: 'AvarianKnight', + repo: 'pma-voice', + ref: 'main', + destination: '/resources/[voice]/pma-voice', + }, + { + owner: 'qbcore-framework', + repo: 'qb-radio', + ref: 'main', + destination: '/resources/[voice]/qb-radio', + }, + { + owner: 'qbcore-framework', + repo: 'hospital_map', + ref: 'main', + destination: '/resources/[defaultmaps]/hospital_map', + }, + { + owner: 'qbcore-framework', + repo: 'dealer_map', + ref: 'main', + destination: '/resources/[defaultmaps]/dealer_map', + }, + { + owner: 'qbcore-framework', + repo: 'prison_map', + ref: 'main', + destination: '/resources/[defaultmaps]/prison_map', + }, + ...[ + 'qb-core', + 'qb-scoreboard', + 'qb-adminmenu', + 'qb-multicharacter', + 'qb-target', + 'qb-vehiclesales', + 'qb-vehicleshop', + 'qb-houserobbery', + 'qb-prison', + 'qb-hud', + 'qb-management', + 'qb-weed', + 'qb-lapraces', + 'qb-inventory', + 'qb-houses', + 'qb-garages', + 'qb-ambulancejob', + 'qb-radialmenu', + 'qb-crypto', + 'qb-weathersync', + 'qb-policejob', + 'qb-apartments', + 'qb-vehiclekeys', + 'qb-mechanicjob', + 'qb-phone', + 'qb-vineyard', + 'qb-weapons', + 'qb-scrapyard', + 'qb-towjob', + 'qb-streetraces', + 'qb-storerobbery', + 'qb-spawn', + 'qb-smallresources', + 'qb-recyclejob', + 'qb-crafting', + 'qb-diving', + 'qb-cityhall', + 'qb-truckrobbery', + 'qb-pawnshop', + 'qb-minigames', + 'qb-taxijob', + 'qb-busjob', + 'qb-newsjob', + 'qb-fuel', + 'qb-jewelery', + 'qb-bankrobbery', + 'qb-banking', + 'qb-clothing', + 'qb-hotdogjob', + 'qb-doorlock', + 'qb-garbagejob', + 'qb-drugs', + 'qb-shops', + 'qb-interior', + 'qb-menu', + 'qb-input', + 'qb-loading', + ].map((repo) => ({ + owner: 'qbcore-framework', + repo, + ref: 'main', + destination: `/resources/[qb]/${repo}`, + })), +]; + +const FIVEM_REMOTE_ARCHIVES: RemoteArchiveResource[] = [ + { + url: OXMYSQL_ZIP_URL, + destination: '/resources/[standalone]/oxmysql', + collapseTopLevelDirectory: true, + }, + { + url: MENUV_ZIP_URL, + destination: '/resources/[standalone]/menuv', + collapseTopLevelDirectory: true, + }, +]; + +function normalizePathSegments(path: string): string[] { + return path + .replace(/\\/g, '/') + .split('/') + .filter((segment) => segment && segment !== '.' && segment !== '..'); +} + +function normalizeArchivePath(path: string): string | null { + const segments = normalizePathSegments(path); + if (segments.length === 0) return null; + return segments.join('/'); +} + +function joinServerPath(base: string, relative: string): string { + const baseSegments = normalizePathSegments(base); + const relativeSegments = normalizePathSegments(relative); + return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/'); +} + +function stripSharedTopLevelDirectory(files: ExtractedFile[]): ExtractedFile[] { + if (files.length === 0) return files; + + const firstSegments = new Set(); + for (const file of files) { + const [first] = normalizePathSegments(file.path); + if (!first) return files; + firstSegments.add(first); + if (firstSegments.size > 1) { + return files; + } + } + + return files + .map((file) => { + const segments = normalizePathSegments(file.path).slice(1); + if (segments.length === 0) return null; + return { + path: segments.join('/'), + data: file.data, + }; + }) + .filter((file): file is ExtractedFile => file !== null); +} + +function filterFilesBySubpath(files: ExtractedFile[], subpath: string): ExtractedFile[] { + const prefix = normalizePathSegments(subpath).join('/'); + if (!prefix) return files; + + const normalizedPrefix = `${prefix}/`; + return files + .map((file) => { + if (file.path === prefix) return null; + if (!file.path.startsWith(normalizedPrefix)) return null; + return { + path: file.path.slice(normalizedPrefix.length), + data: file.data, + }; + }) + .filter((file): file is ExtractedFile => file !== null && file.path.length > 0); +} + +async function downloadBinary( + url: string, + maxBytes: number, + headers: Record = {}, +): Promise { + const response = await fetch(url, { + headers: { + 'User-Agent': 'SourceGamePanel/1.0', + ...headers, + }, + redirect: 'follow', + }); + + if (!response.ok) { + throw new Error(`Download failed (${response.status}): ${url}`); + } + + const contentLength = Number(response.headers.get('content-length') ?? '0'); + if (contentLength > maxBytes) { + throw new Error(`Download exceeds size limit (${contentLength} > ${maxBytes})`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length === 0) { + throw new Error(`Downloaded archive is empty: ${url}`); + } + if (buffer.length > maxBytes) { + throw new Error(`Download exceeds size limit (${buffer.length} > ${maxBytes})`); + } + + return buffer; +} + +async function downloadText(url: string, headers: Record = {}): Promise { + const response = await fetch(url, { + headers: { + 'User-Agent': 'SourceGamePanel/1.0', + ...headers, + }, + redirect: 'follow', + }); + + if (!response.ok) { + throw new Error(`Text download failed (${response.status}): ${url}`); + } + + const text = await response.text(); + if (!text.trim()) { + throw new Error(`Downloaded text is empty: ${url}`); + } + + return text; +} + +async function extractZipFiles(buffer: Buffer): Promise { + const archive = await unzipper.Open.buffer(buffer); + const files: ExtractedFile[] = []; + + for (const entry of archive.files) { + if (entry.type !== 'File') continue; + const normalized = normalizeArchivePath(entry.path); + if (!normalized) continue; + + files.push({ + path: normalized, + data: await entry.buffer(), + }); + } + + return files; +} + +function extractTarFiles(buffer: Buffer): Promise { + return new Promise((resolve, reject) => { + const extract = tar.extract(); + const files: ExtractedFile[] = []; + + extract.on('entry', (header: Headers, stream, next) => { + const type = header.type ?? 'file'; + const normalized = normalizeArchivePath(header.name); + const isFileType = type === 'file' || type === 'contiguous-file'; + + if (!isFileType || !normalized) { + stream.resume(); + stream.on('end', next); + stream.on('error', reject); + return; + } + + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('end', () => { + files.push({ path: normalized, data: Buffer.concat(chunks) }); + next(); + }); + stream.on('error', reject); + }); + + extract.on('finish', () => resolve(files)); + extract.on('error', reject); + extract.end(buffer); + }); +} + +async function extractArchive(buffer: Buffer, url: string): Promise { + const normalizedUrl = url.toLowerCase(); + if (normalizedUrl.endsWith('.zip')) { + return extractZipFiles(buffer); + } + if (normalizedUrl.endsWith('.tar.gz') || normalizedUrl.endsWith('.tgz')) { + return extractTarFiles(gunzipSync(buffer)); + } + if (normalizedUrl.endsWith('.tar')) { + return extractTarFiles(buffer); + } + + throw new Error(`Unsupported archive type: ${url}`); +} + +async function writeFilesToServer( + node: DaemonNodeConnection, + serverUuid: string, + destination: string, + files: ExtractedFile[], +): Promise { + for (const file of files) { + await daemonWriteFile(node, serverUuid, joinServerPath(destination, file.path), file.data); + } +} + +function escapeCfgValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function buildMysqlConnectionString(database: ManagedServerDatabaseRecord): string { + return `mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}?charset=utf8mb4`; +} + +function renderFivemServerConfig( + serverName: string, + description: string | null | undefined, + database: ManagedServerDatabaseRecord, +): string { + const safeServerName = escapeCfgValue(serverName.trim() || 'QBCore Server'); + const safeProjectDescription = escapeCfgValue( + description?.trim() || 'QBCore server provisioned by Source GamePanel.', + ); + const rconPassword = randomBytes(16).toString('hex'); + const mysqlConnectionString = escapeCfgValue(buildMysqlConnectionString(database)); + + return `# Generated by Source GamePanel +# QBCore resources and base dependencies are installed automatically. + +endpoint_add_tcp "0.0.0.0:${FIVE_M_INTERNAL_PORT}" +endpoint_add_udp "0.0.0.0:${FIVE_M_INTERNAL_PORT}" + +sv_maxclients "32" +sv_hostname "${safeServerName}" +sets sv_projectName "[QBCore] ${safeServerName}" +sets sv_projectDesc "${safeProjectDescription}" +sets locale "en-US" +sets tags "qbcore, qb-core, roleplay, source-gamepanel" +set steam_webApiKey "none" +set resources_useSystemChat "true" +set mysql_connection_string "${mysqlConnectionString}" + +setr qb_locale "en" +setr UseTarget "false" +setr voice_useNativeAudio "true" +setr voice_useSendingRangeOnly "true" +setr voice_defaultCycle "GRAVE" +setr voice_defaultVolume "0.3" +setr voice_enableRadioAnim "1" +setr voice_syncData "1" + +sv_scriptHookAllowed "0" +sv_endpointprivacy "true" +rcon_password "${rconPassword}" + +ensure mapmanager +ensure chat +ensure spawnmanager +ensure sessionmanager +ensure basic-gamemode +ensure hardcap +ensure baseevents + +ensure qb-core +ensure [qb] +ensure [standalone] +ensure [voice] +ensure [defaultmaps] + +add_ace group.admin command allow +add_ace group.admin command.quit deny +add_ace resource.qb-core command allow +add_ace qbcore.god command allow +add_principal qbcore.god group.admin +add_principal qbcore.god qbcore.admin +add_principal qbcore.admin qbcore.mod +`; +} + +function isMissingFileError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('No such file or directory') || + message.includes('Server responded with NOT_FOUND') || + message.includes('status code 404') + ); +} + +export function isFivemQbCoreGame(gameSlug: string): boolean { + return gameSlug.trim().toLowerCase() === 'fivem'; +} + +export async function ensureFivemQbCoreDatabase( + app: FastifyInstance, + context: Pick, +): Promise { + const existing = await app.db + .select({ + id: serverDatabases.id, + name: serverDatabases.name, + databaseName: serverDatabases.databaseName, + username: serverDatabases.username, + password: serverDatabases.password, + host: serverDatabases.host, + port: serverDatabases.port, + phpMyAdminUrl: serverDatabases.phpMyAdminUrl, + }) + .from(serverDatabases) + .where(eq(serverDatabases.serverId, context.serverId)) + .orderBy(asc(serverDatabases.createdAt)); + + const preferred = + existing.find((database) => database.name.trim().toLowerCase() === QBCORE_DATABASE_NAME) ?? + existing[0]; + if (preferred) { + return preferred; + } + + const managedDatabase = await daemonCreateDatabase(context.node, { + serverUuid: context.serverUuid, + name: QBCORE_DATABASE_NAME, + }); + + try { + const [created] = await app.db + .insert(serverDatabases) + .values({ + serverId: context.serverId, + name: QBCORE_DATABASE_NAME, + databaseName: managedDatabase.databaseName, + username: managedDatabase.username, + password: managedDatabase.password, + host: managedDatabase.host, + port: managedDatabase.port, + phpMyAdminUrl: managedDatabase.phpMyAdminUrl, + }) + .returning({ + id: serverDatabases.id, + name: serverDatabases.name, + databaseName: serverDatabases.databaseName, + username: serverDatabases.username, + password: serverDatabases.password, + host: serverDatabases.host, + port: serverDatabases.port, + phpMyAdminUrl: serverDatabases.phpMyAdminUrl, + }); + + if (!created) { + throw new Error('Failed to persist managed database metadata'); + } + + return created; + } catch (error) { + try { + await daemonDeleteDatabase(context.node, { + databaseName: managedDatabase.databaseName, + username: managedDatabase.username, + }); + } catch (cleanupError) { + app.log.error( + { + cleanupError, + databaseName: managedDatabase.databaseName, + serverId: context.serverId, + serverUuid: context.serverUuid, + }, + 'Failed to roll back managed MySQL database after metadata save failure', + ); + } + + throw error; + } +} + +export async function deleteFivemQbCoreDatabase( + app: FastifyInstance, + context: Pick, +): Promise { + const [database] = await app.db + .select({ + id: serverDatabases.id, + databaseName: serverDatabases.databaseName, + username: serverDatabases.username, + }) + .from(serverDatabases) + .where( + and( + eq(serverDatabases.serverId, context.serverId), + eq(serverDatabases.name, QBCORE_DATABASE_NAME), + ), + ); + + if (!database) return; + + await daemonDeleteDatabase(context.node, { + databaseName: database.databaseName, + username: database.username, + }); + await app.db.delete(serverDatabases).where(eq(serverDatabases.id, database.id)); +} + +async function installGitHubResource( + app: FastifyInstance, + context: FivemProvisionContext, + resource: GitHubArchiveResource, +): Promise { + const archiveUrl = `https://codeload.github.com/${resource.owner}/${resource.repo}/tar.gz/refs/heads/${encodeURIComponent(resource.ref)}`; + const archive = await downloadBinary(archiveUrl, GITHUB_ARCHIVE_MAX_BYTES); + let files = await extractTarFiles(gunzipSync(archive)); + files = stripSharedTopLevelDirectory(files); + if (resource.subpath) { + files = filterFilesBySubpath(files, resource.subpath); + } + if (files.length === 0) { + throw new Error( + `GitHub archive had no files: ${resource.owner}/${resource.repo}@${resource.ref}`, + ); + } + + await writeFilesToServer(context.node, context.serverUuid, resource.destination, files); + + app.log.info( + { + destination: resource.destination, + filesWritten: files.length, + repo: `${resource.owner}/${resource.repo}`, + serverId: context.serverId, + serverUuid: context.serverUuid, + }, + 'Installed FiveM GitHub resource', + ); +} + +async function installRemoteArchive( + app: FastifyInstance, + context: FivemProvisionContext, + resource: RemoteArchiveResource, +): Promise { + const archive = await downloadBinary(resource.url, URL_ARCHIVE_MAX_BYTES); + let files = await extractArchive(archive, resource.url); + if (resource.collapseTopLevelDirectory) { + files = stripSharedTopLevelDirectory(files); + } + if (files.length === 0) { + throw new Error(`Remote archive had no files: ${resource.url}`); + } + + await writeFilesToServer(context.node, context.serverUuid, resource.destination, files); + + app.log.info( + { + destination: resource.destination, + filesWritten: files.length, + serverId: context.serverId, + serverUuid: context.serverUuid, + url: resource.url, + }, + 'Installed FiveM remote archive', + ); +} + +export async function provisionFivemQbCoreServer( + app: FastifyInstance, + context: FivemProvisionContext, +): Promise { + try { + await daemonReadFile(context.node, context.serverUuid, FIVE_M_QBCORE_MARKER_PATH); + return; + } catch (error) { + if (!isMissingFileError(error)) { + throw error; + } + } + + const database = await ensureFivemQbCoreDatabase(app, context); + const qbCoreSql = await downloadText(QBCORE_SQL_URL); + + await daemonImportDatabaseSql(context.node, { + databaseName: database.databaseName, + sql: qbCoreSql, + }); + + for (const resource of FIVEM_GITHUB_RESOURCES) { + await installGitHubResource(app, context, resource); + } + + for (const resource of FIVEM_REMOTE_ARCHIVES) { + await installRemoteArchive(app, context, resource); + } + + try { + await daemonDeleteFiles(context.node, context.serverUuid, [ + '/resources/[cfx-default]/[gameplay]/chat', + ]); + } catch (error) { + if (!isMissingFileError(error)) { + throw error; + } + } + + await daemonWriteFile( + context.node, + context.serverUuid, + '/server.cfg', + renderFivemServerConfig(context.serverName, context.serverDescription, database), + ); + + await daemonWriteFile( + context.node, + context.serverUuid, + FIVE_M_QBCORE_MARKER_PATH, + JSON.stringify( + { + installedAt: new Date().toISOString(), + manifestVersion: 1, + resourceCount: FIVEM_GITHUB_RESOURCES.length + FIVEM_REMOTE_ARCHIVES.length, + }, + null, + 2, + ), + ); + + await app.db + .update(servers) + .set({ updatedAt: new Date() }) + .where(eq(servers.id, context.serverId)); +} diff --git a/apps/api/src/routes/servers/index.ts b/apps/api/src/routes/servers/index.ts index 95b8d40..c4dbea3 100644 --- a/apps/api/src/routes/servers/index.ts +++ b/apps/api/src/routes/servers/index.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { Type } from '@sinclair/typebox'; -import { eq, and, count } from 'drizzle-orm'; +import { eq, and, count, inArray } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { setTimeout as sleep } from 'timers/promises'; import { servers, allocations, nodes, games, serverDatabases } from '@source/database'; @@ -9,9 +9,16 @@ import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; import { createAuditLog } from '../../lib/audit.js'; +import { + deleteFivemQbCoreDatabase, + ensureFivemQbCoreDatabase, + isFivemQbCoreGame, + provisionFivemQbCoreServer, +} from '../../lib/fivem-qbcore.js'; import { runServerAutomationEvent } from '../../lib/server-automation.js'; import { daemonCreateServer, + daemonDeleteDatabase, daemonDeleteServer, daemonGetServerStatus, daemonSetPowerState, @@ -36,6 +43,22 @@ import databaseRoutes from './databases.js'; type MutableServerStatus = 'installing' | 'running' | 'stopped' | 'error'; type RuntimeServerStatus = MutableServerStatus | 'starting' | 'stopping' | 'suspended'; +type PortProtocol = DaemonPortMapping['protocol']; + +interface AdditionalPortRequirement { + key: string; + label: string; + protocols: PortProtocol[]; +} + +interface AdditionalServerPort { + allocationId: string; + hostPort: number; + containerPort: number; + protocol: PortProtocol; + key: string; + label: string; +} function mapDaemonStatus(rawStatus: string): MutableServerStatus | null { switch (rawStatus.toLowerCase()) { @@ -111,9 +134,104 @@ function buildDaemonEnvironment( return environment; } -function buildDaemonPorts(gameSlug: string, allocationPort: number, containerPort: number): DaemonPortMapping[] { +function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] { + const slug = gameSlug.trim().toLowerCase(); + if (slug === 'satisfactory') { + return [ + { + key: 'satisfactory-messaging', + label: 'Messaging Port', + protocols: ['tcp'], + }, + ]; + } + + return []; +} + +function normalizeAdditionalServerPorts(raw: unknown): AdditionalServerPort[] { + if (!Array.isArray(raw)) return []; + + const ports: AdditionalServerPort[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const record = item as Record; + const protocol = record.protocol; + const hostPort = Number(record.hostPort); + const containerPort = Number(record.containerPort); + const allocationId = typeof record.allocationId === 'string' ? record.allocationId : ''; + const key = typeof record.key === 'string' ? record.key : ''; + const label = typeof record.label === 'string' ? record.label : key; + + if (protocol !== 'tcp' && protocol !== 'udp') continue; + if (!allocationId || !key) continue; + if (!Number.isInteger(hostPort) || hostPort < 1 || hostPort > 65535) continue; + if (!Number.isInteger(containerPort) || containerPort < 1 || containerPort > 65535) continue; + + ports.push({ + allocationId, + hostPort, + containerPort, + protocol, + key, + label, + }); + } + + return ports; +} + +function applyGameRuntimeEnvironment( + gameSlug: string, + environment: Record, + allocationPort: number, + additionalPortsRaw: unknown, +): Record { + const slug = gameSlug.trim().toLowerCase(); + if (slug !== 'satisfactory') return environment; + + const additionalPorts = normalizeAdditionalServerPorts(additionalPortsRaw); + const messagingPort = additionalPorts.find( + (port) => port.key === 'satisfactory-messaging' && port.protocol === 'tcp', + ); + + return { + ...environment, + SERVERGAMEPORT: String(allocationPort), + SERVERMESSAGINGPORT: String(messagingPort?.hostPort ?? 8888), + }; +} + +function buildDaemonPorts( + gameSlug: string, + allocationPort: number, + containerPort: number, + additionalPortsRaw: unknown = [], +): DaemonPortMapping[] { const slug = gameSlug.toLowerCase(); - if (slug === 'cs2' || slug === 'csgo') { + const additionalPorts = normalizeAdditionalServerPorts(additionalPortsRaw); + + if (slug === 'satisfactory') { + const messagingPort = additionalPorts.find( + (port) => port.key === 'satisfactory-messaging' && port.protocol === 'tcp', + ); + + return [ + { host_port: allocationPort, container_port: allocationPort, protocol: 'tcp' }, + { host_port: allocationPort, container_port: allocationPort, protocol: 'udp' }, + ...(messagingPort + ? [ + { + host_port: messagingPort.hostPort, + container_port: messagingPort.hostPort, + protocol: 'tcp' as const, + }, + ] + : []), + ]; + } + + if (slug === 'cs2' || slug === 'csgo' || slug === 'fivem') { return [ { host_port: allocationPort, container_port: containerPort, protocol: 'udp' }, { host_port: allocationPort, container_port: containerPort, protocol: 'tcp' }, @@ -122,7 +240,14 @@ function buildDaemonPorts(gameSlug: string, allocationPort: number, containerPor if (slug === 'minecraft-bedrock') { return [{ host_port: allocationPort, container_port: containerPort, protocol: 'udp' }]; } - return [{ host_port: allocationPort, container_port: containerPort, protocol: 'tcp' }]; + return [ + { host_port: allocationPort, container_port: containerPort, protocol: 'tcp' }, + ...additionalPorts.map((port) => ({ + host_port: port.hostPort, + container_port: port.containerPort, + protocol: port.protocol, + })), + ]; } function normalizeEnvironmentOverrides(raw: unknown): Record { @@ -153,6 +278,199 @@ function sameEnvironmentOverrides( return true; } +function requiresManagedInstallProvisioning(gameSlug: string): boolean { + return isFivemQbCoreGame(gameSlug); +} + +function requiredEnvironmentLabels( + gameEnvVarsRaw: unknown, + overrides: Record, +): string[] { + if (!Array.isArray(gameEnvVarsRaw)) return []; + + const missing: string[] = []; + + for (const item of gameEnvVarsRaw) { + if (!item || typeof item !== 'object') continue; + const record = item as Record; + if (!record.required) continue; + + const key = typeof record.key === 'string' ? record.key.trim() : ''; + if (!key) continue; + + const label = + typeof record.label === 'string' && record.label.trim() ? record.label.trim() : key; + + const composeInto = typeof record.composeInto === 'string' ? record.composeInto.trim() : ''; + const flagValue = typeof record.flagValue === 'string' ? record.flagValue.trim() : ''; + const defaultValue = String(record.default ?? ''); + + if (composeInto && flagValue) { + const currentValue = overrides[composeInto] ?? defaultValue; + const tokens = currentValue.trim() ? currentValue.trim().split(/\s+/) : []; + if (!tokens.includes(flagValue)) { + missing.push(label); + } + continue; + } + + const currentValue = overrides[key] ?? defaultValue; + if (!currentValue.trim()) { + missing.push(label); + } + } + + return missing; +} + +function daemonErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + return fallback; +} + +async function rollbackManagedInstallCreate( + app: FastifyInstance, + options: { + allocationId: string; + node: DaemonNodeConnection; + serverId: string; + serverUuid: string; + }, +): Promise { + try { + await deleteFivemQbCoreDatabase(app, { + node: options.node, + serverId: options.serverId, + }); + } catch (error) { + app.log.warn( + { error, serverId: options.serverId, serverUuid: options.serverUuid }, + 'Failed to delete managed QBCore database during rollback', + ); + } + + try { + await daemonDeleteServer(options.node, options.serverUuid); + } catch (error) { + app.log.warn( + { error, serverId: options.serverId, serverUuid: options.serverUuid }, + 'Failed to delete daemon server during rollback', + ); + } + + try { + await app.db + .update(allocations) + .set({ isDefault: false, serverId: null }) + .where(eq(allocations.serverId, options.serverId)); + } catch (error) { + app.log.warn( + { error, serverId: options.serverId }, + 'Failed to release server allocations during rollback', + ); + } + + try { + await app.db + .update(allocations) + .set({ isDefault: false, serverId: null }) + .where(eq(allocations.id, options.allocationId)); + } catch (error) { + app.log.warn( + { error, allocationId: options.allocationId, serverId: options.serverId }, + 'Failed to release allocation during rollback', + ); + } + + try { + await app.db.delete(servers).where(eq(servers.id, options.serverId)); + } catch (error) { + app.log.warn( + { error, serverId: options.serverId, serverUuid: options.serverUuid }, + 'Failed to delete server row during rollback', + ); + } +} + +async function runManagedInstallProvisioning( + app: FastifyInstance, + options: { + automationRules: unknown; + finalDaemonStatus: Extract; + gameSlug: string; + node: DaemonNodeConnection; + rethrow?: boolean; + serverId: string; + serverUuid: string; + }, +): Promise { + try { + if (isFivemQbCoreGame(options.gameSlug)) { + const server = await app.db.query.servers.findFirst({ + columns: { + description: true, + id: true, + name: true, + }, + where: eq(servers.id, options.serverId), + }); + + if (!server) { + throw AppError.notFound('Server not found for managed install provisioning'); + } + + await provisionFivemQbCoreServer(app, { + node: options.node, + serverDescription: server.description, + serverId: server.id, + serverName: server.name, + serverUuid: options.serverUuid, + }); + } + + const now = new Date(); + await app.db + .update(servers) + .set({ + status: options.finalDaemonStatus, + installedAt: now, + updatedAt: now, + }) + .where(eq(servers.id, options.serverId)); + + void runServerAutomationEvent(app, { + serverId: options.serverId, + serverUuid: options.serverUuid, + gameSlug: options.gameSlug, + event: 'server.install.completed', + node: options.node, + automationRulesRaw: options.automationRules, + }); + } catch (error) { + app.log.error( + { + error, + gameSlug: options.gameSlug, + serverId: options.serverId, + serverUuid: options.serverUuid, + }, + 'Managed install provisioning failed', + ); + + await app.db + .update(servers) + .set({ status: 'error', updatedAt: new Date() }) + .where(eq(servers.id, options.serverId)); + + if (options.rethrow) { + throw error; + } + } +} + async function syncServerInstallStatus( app: FastifyInstance, node: DaemonNodeConnection, @@ -174,13 +492,17 @@ async function syncServerInstallStatus( const mapped = mapDaemonStatus(daemonStatus.state); if (!mapped) continue; if (mapped === 'installing') continue; + const needsManagedProvisioning = + requiresManagedInstallProvisioning(gameSlug) && + (mapped === 'running' || mapped === 'stopped'); + const persistedStatus: MutableServerStatus = needsManagedProvisioning ? 'installing' : mapped; const now = new Date(); await app.db .update(servers) .set({ - status: mapped, - installedAt: mapped === 'running' || mapped === 'stopped' ? now : null, + status: persistedStatus, + installedAt: persistedStatus === 'running' || persistedStatus === 'stopped' ? now : null, updatedAt: now, }) .where(eq(servers.id, serverId)); @@ -191,29 +513,34 @@ async function syncServerInstallStatus( ); if (mapped === 'running' || mapped === 'stopped') { - void runServerAutomationEvent(app, { - serverId, - serverUuid, - gameSlug, - event: 'server.install.completed', - node, - automationRulesRaw: automationRules, - }); + if (needsManagedProvisioning) { + void runManagedInstallProvisioning(app, { + automationRules, + finalDaemonStatus: mapped, + gameSlug, + node, + serverId, + serverUuid, + }); + } else { + void runServerAutomationEvent(app, { + serverId, + serverUuid, + gameSlug, + event: 'server.install.completed', + node, + automationRulesRaw: automationRules, + }); + } } return; } catch (error) { - app.log.warn( - { error, serverId, serverUuid, attempt }, - 'Failed to poll daemon server status', - ); + app.log.warn({ error, serverId, serverUuid, attempt }, 'Failed to poll daemon server status'); } } - app.log.warn( - { serverId, serverUuid }, - 'Timed out while waiting for daemon install completion', - ); + app.log.warn({ serverId, serverUuid }, 'Timed out while waiting for daemon install completion'); } async function sustainCs2ServerConfigAfterPowerStart( @@ -309,6 +636,7 @@ export default async function serverRoutes(app: FastifyInstance) { diskLimit: number; cpuLimit?: number; allocationId: string; + additionalAllocationIds?: string[]; environment?: Record; startupOverride?: string; }; @@ -325,16 +653,99 @@ export default async function serverRoutes(app: FastifyInstance) { }); if (!game) throw AppError.notFound('Game not found'); + const normalizedEnvironment = normalizeEnvironmentOverrides(body.environment); + const missingEnvironment = requiredEnvironmentLabels( + game.environmentVars, + normalizedEnvironment, + ); + if (missingEnvironment.length > 0) { + throw AppError.badRequest( + `Missing required environment values: ${missingEnvironment.join(', ')}`, + ); + } + // Verify and claim allocation const allocation = await app.db.query.allocations.findFirst({ - where: and( - eq(allocations.id, body.allocationId), - eq(allocations.nodeId, body.nodeId), - ), + where: and(eq(allocations.id, body.allocationId), eq(allocations.nodeId, body.nodeId)), }); if (!allocation) throw AppError.notFound('Allocation not found on this node'); if (allocation.serverId) throw AppError.conflict('Allocation is already in use'); + const additionalPortRequirements = additionalPortRequirementsForGame(game.slug); + const additionalAllocationIds = Array.from( + new Set((body.additionalAllocationIds ?? []).map((id) => id.trim()).filter(Boolean)), + ); + + if (additionalAllocationIds.length > 0 && additionalPortRequirements.length === 0) { + throw AppError.badRequest('This game does not require additional allocations'); + } + + if (additionalAllocationIds.length !== additionalPortRequirements.length) { + throw AppError.badRequest( + `This game requires ${additionalPortRequirements.length} additional allocation(s)`, + ); + } + + if (additionalAllocationIds.includes(body.allocationId)) { + throw AppError.conflict('Primary allocation cannot also be used as an additional allocation'); + } + + const additionalAllocationRows = + additionalAllocationIds.length > 0 + ? await app.db + .select() + .from(allocations) + .where( + and( + eq(allocations.nodeId, body.nodeId), + inArray(allocations.id, additionalAllocationIds), + ), + ) + : []; + + if (additionalAllocationRows.length !== additionalAllocationIds.length) { + throw AppError.notFound('One or more additional allocations were not found on this node'); + } + + const additionalAllocationsById = new Map( + additionalAllocationRows.map((additionalAllocation) => [ + additionalAllocation.id, + additionalAllocation, + ]), + ); + const additionalServerPorts: AdditionalServerPort[] = []; + + additionalAllocationIds.forEach((allocationId, index) => { + const additionalAllocation = additionalAllocationsById.get(allocationId); + const requirement = additionalPortRequirements[index]; + if (!additionalAllocation || !requirement) return; + if (additionalAllocation.serverId) { + throw AppError.conflict(`Allocation ${additionalAllocation.port} is already in use`); + } + if (additionalAllocation.port === allocation.port) { + throw AppError.conflict( + `Allocation ${additionalAllocation.port} conflicts with the primary allocation`, + ); + } + + for (const protocol of requirement.protocols) { + additionalServerPorts.push({ + allocationId: additionalAllocation.id, + hostPort: additionalAllocation.port, + containerPort: additionalAllocation.port, + protocol, + key: requirement.key, + label: requirement.label, + }); + } + }); + + const nodeConnection: DaemonNodeConnection = { + fqdn: node.fqdn, + grpcPort: node.grpcPort, + daemonToken: node.daemonToken, + }; + const serverUuid = randomUUID().slice(0, 8); const [server] = await app.db @@ -350,7 +761,8 @@ export default async function serverRoutes(app: FastifyInstance) { diskLimit: body.diskLimit, cpuLimit: body.cpuLimit ?? 100, port: allocation.port, - environment: body.environment ?? {}, + additionalPorts: additionalServerPorts, + environment: normalizedEnvironment, startupOverride: body.startupOverride, status: 'installing', }) @@ -365,11 +777,40 @@ export default async function serverRoutes(app: FastifyInstance) { .set({ serverId: server.id, isDefault: true }) .where(eq(allocations.id, body.allocationId)); - const nodeConnection: DaemonNodeConnection = { - fqdn: node.fqdn, - grpcPort: node.grpcPort, - daemonToken: node.daemonToken, - }; + if (additionalAllocationIds.length > 0) { + await app.db + .update(allocations) + .set({ serverId: server.id, isDefault: false }) + .where(inArray(allocations.id, additionalAllocationIds)); + } + + if (requiresManagedInstallProvisioning(game.slug)) { + try { + await ensureFivemQbCoreDatabase(app, { + node: nodeConnection, + serverId: server.id, + serverUuid: server.uuid, + }); + } catch (error) { + app.log.error( + { error, nodeId: node.id, serverId: server.id, serverUuid: server.uuid }, + 'Failed to provision managed QBCore database during server create', + ); + + await rollbackManagedInstallCreate(app, { + allocationId: body.allocationId, + node: nodeConnection, + serverId: server.id, + serverUuid: server.uuid, + }); + + throw new AppError( + 502, + daemonErrorMessage(error, 'Failed to provision managed QBCore database'), + 'FIVEM_QBCORE_DATABASE_FAILED', + ); + } + } const daemonRequest = { uuid: server.uuid, @@ -378,26 +819,40 @@ export default async function serverRoutes(app: FastifyInstance) { disk_limit: server.diskLimit, cpu_limit: server.cpuLimit, startup_command: body.startupOverride ?? game.startupCommand, - environment: buildDaemonEnvironment(game.environmentVars, body.environment, server.memoryLimit), - ports: buildDaemonPorts(game.slug, allocation.port, game.defaultPort), + environment: applyGameRuntimeEnvironment( + game.slug, + buildDaemonEnvironment(game.environmentVars, normalizedEnvironment, server.memoryLimit), + allocation.port, + additionalServerPorts, + ), + ports: buildDaemonPorts(game.slug, allocation.port, game.defaultPort, additionalServerPorts), install_plugin_urls: [], }; + let createdServerResponse = server; try { const daemonResponse = await daemonCreateServer(nodeConnection, daemonRequest); const daemonStatus = mapDaemonStatus(daemonResponse.status) ?? 'installing'; const now = new Date(); - const automationRules = (game as { automationRules?: GameAutomationRule[] }).automationRules ?? []; + const automationRules = + (game as { automationRules?: GameAutomationRule[] }).automationRules ?? []; + const needsManagedProvisioning = + requiresManagedInstallProvisioning(game.slug) && + (daemonStatus === 'running' || daemonStatus === 'stopped'); + const persistedStatus: MutableServerStatus = needsManagedProvisioning + ? 'installing' + : daemonStatus; const [updatedServer] = await app.db .update(servers) .set({ - status: daemonStatus, - installedAt: daemonStatus === 'running' || daemonStatus === 'stopped' ? now : null, + status: persistedStatus, + installedAt: persistedStatus === 'running' || persistedStatus === 'stopped' ? now : null, updatedAt: now, }) .where(eq(servers.id, server.id)) .returning(); + createdServerResponse = updatedServer ?? server; if (daemonStatus === 'installing') { void syncServerInstallStatus( @@ -409,30 +864,43 @@ export default async function serverRoutes(app: FastifyInstance) { automationRules, ); } else if (daemonStatus === 'running' || daemonStatus === 'stopped') { - void runServerAutomationEvent(app, { - serverId: server.id, - serverUuid: server.uuid, - gameSlug: game.slug, - event: 'server.install.completed', - node: nodeConnection, - automationRulesRaw: automationRules, - }); + if (needsManagedProvisioning) { + void runManagedInstallProvisioning(app, { + automationRules, + finalDaemonStatus: daemonStatus, + gameSlug: game.slug, + node: nodeConnection, + serverId: server.id, + serverUuid: server.uuid, + }); + } else { + void runServerAutomationEvent(app, { + serverId: server.id, + serverUuid: server.uuid, + gameSlug: game.slug, + event: 'server.install.completed', + node: nodeConnection, + automationRulesRaw: automationRules, + }); + } } - - await createAuditLog(app.db, request, { - organizationId: orgId, - serverId: server.id, - action: 'server.create', - metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId }, - }); - - return reply.code(201).send(updatedServer ?? server); } catch (error) { app.log.error( { error, serverId: server.id, serverUuid: server.uuid, nodeId: node.id }, 'Failed to provision server on daemon', ); + if (requiresManagedInstallProvisioning(game.slug)) { + await rollbackManagedInstallCreate(app, { + allocationId: body.allocationId, + node: nodeConnection, + serverId: server.id, + serverUuid: server.uuid, + }); + + throw new AppError(502, 'Failed to provision server on daemon', 'DAEMON_CREATE_FAILED'); + } + await app.db .update(servers) .set({ status: 'error', updatedAt: new Date() }) @@ -440,6 +908,15 @@ export default async function serverRoutes(app: FastifyInstance) { throw new AppError(502, 'Failed to provision server on daemon', 'DAEMON_CREATE_FAILED'); } + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId: server.id, + action: 'server.create', + metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId }, + }); + + return reply.code(201).send(createdServerResponse); }); // GET /api/organizations/:orgId/servers/:serverId @@ -485,17 +962,86 @@ export default async function serverRoutes(app: FastifyInstance) { if (!server) throw AppError.notFound('Server not found'); + const nodeConnection: DaemonNodeConnection = { + fqdn: server.nodeFqdn, + grpcPort: server.nodeGrpcPort, + daemonToken: server.nodeDaemonToken, + }; + + if ( + event === 'server.install.completed' && + requiresManagedInstallProvisioning(server.gameSlug) + ) { + let finalDaemonStatus: Extract = 'stopped'; + + try { + const daemonStatus = await daemonGetServerStatus(nodeConnection, server.uuid); + const mappedStatus = mapDaemonStatus(daemonStatus.state); + if (mappedStatus === 'running') { + finalDaemonStatus = 'running'; + } + } catch (error) { + request.log.warn( + { error, serverId: server.id, serverUuid: server.uuid }, + 'Failed to inspect daemon status before managed install rerun', + ); + } + + try { + await runManagedInstallProvisioning(app, { + automationRules: server.automationRules, + finalDaemonStatus, + gameSlug: server.gameSlug, + node: nodeConnection, + rethrow: true, + serverId: server.id, + serverUuid: server.uuid, + }); + } catch (error) { + throw new AppError( + 502, + daemonErrorMessage(error, 'Managed install provisioning failed'), + 'MANAGED_INSTALL_PROVISIONING_FAILED', + ); + } + + const result = { + workflowsMatched: 0, + workflowsExecuted: 0, + workflowsSkipped: 0, + workflowsFailed: 0, + actionFailures: 0, + failures: [], + }; + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'server.automation.run', + metadata: { + event, + force: force ?? false, + managedProvisioning: true, + result, + }, + }); + + return { + success: true, + event, + force: force ?? false, + managedProvisioning: true, + result, + }; + } + const result = await runServerAutomationEvent(app, { serverId: server.id, serverUuid: server.uuid, gameSlug: server.gameSlug, event, force: force ?? false, - node: { - fqdn: server.nodeFqdn, - grpcPort: server.nodeGrpcPort, - daemonToken: server.nodeDaemonToken, - }, + node: nodeConnection, automationRulesRaw: server.automationRules, }); @@ -584,9 +1130,10 @@ export default async function serverRoutes(app: FastifyInstance) { .update(servers) .set({ status: persistedStatus, - installedAt: persistedStatus === 'running' || persistedStatus === 'stopped' - ? (server.installedAt ?? new Date()) - : server.installedAt, + installedAt: + persistedStatus === 'running' || persistedStatus === 'stopped' + ? (server.installedAt ?? new Date()) + : server.installedAt, updatedAt: new Date(), }) .where(eq(servers.id, server.id)); @@ -609,144 +1156,156 @@ export default async function serverRoutes(app: FastifyInstance) { }); // PATCH /api/organizations/:orgId/servers/:serverId - app.patch('/:serverId', { schema: { ...ServerParamSchema, ...UpdateServerSchema } }, async (request) => { - const { orgId, serverId } = request.params as { orgId: string; serverId: string }; - await requirePermission(request, orgId, 'server.update'); + app.patch( + '/:serverId', + { schema: { ...ServerParamSchema, ...UpdateServerSchema } }, + async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'server.update'); - const body = request.body as { - name?: string; - description?: string; - memoryLimit?: number; - diskLimit?: number; - cpuLimit?: number; - environment?: Record; - startupOverride?: string; - }; + const body = request.body as { + name?: string; + description?: string; + memoryLimit?: number; + diskLimit?: number; + cpuLimit?: number; + environment?: Record; + startupOverride?: string; + }; - const [current] = await app.db - .select({ - id: servers.id, - uuid: servers.uuid, - organizationId: servers.organizationId, - name: servers.name, - description: servers.description, - status: servers.status, - memoryLimit: servers.memoryLimit, - diskLimit: servers.diskLimit, - cpuLimit: servers.cpuLimit, - port: servers.port, - environment: servers.environment, - startupOverride: servers.startupOverride, - nodeFqdn: nodes.fqdn, - nodeGrpcPort: nodes.grpcPort, - nodeDaemonToken: nodes.daemonToken, - gameDockerImage: games.dockerImage, - gameDefaultPort: games.defaultPort, - gameSlug: games.slug, - gameStartupCommand: games.startupCommand, - gameEnvironmentVars: games.environmentVars, - }) - .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))); + const [current] = await app.db + .select({ + id: servers.id, + uuid: servers.uuid, + organizationId: servers.organizationId, + name: servers.name, + description: servers.description, + status: servers.status, + memoryLimit: servers.memoryLimit, + diskLimit: servers.diskLimit, + cpuLimit: servers.cpuLimit, + port: servers.port, + additionalPorts: servers.additionalPorts, + environment: servers.environment, + startupOverride: servers.startupOverride, + nodeFqdn: nodes.fqdn, + nodeGrpcPort: nodes.grpcPort, + nodeDaemonToken: nodes.daemonToken, + gameDockerImage: games.dockerImage, + gameDefaultPort: games.defaultPort, + gameSlug: games.slug, + gameStartupCommand: games.startupCommand, + gameEnvironmentVars: games.environmentVars, + }) + .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 (!current) throw AppError.notFound('Server not found'); + if (!current) throw AppError.notFound('Server not found'); - const nextMemoryLimit = body.memoryLimit ?? current.memoryLimit; - const nextDiskLimit = body.diskLimit ?? current.diskLimit; - const nextCpuLimit = body.cpuLimit ?? current.cpuLimit; - const currentEnvironment = normalizeEnvironmentOverrides(current.environment); - const nextEnvironment = body.environment !== undefined - ? normalizeEnvironmentOverrides(body.environment) - : currentEnvironment; - const nextStartupOverride = body.startupOverride !== undefined - ? (body.startupOverride.trim() || null) - : (current.startupOverride ?? null); + const nextMemoryLimit = body.memoryLimit ?? current.memoryLimit; + const nextDiskLimit = body.diskLimit ?? current.diskLimit; + const nextCpuLimit = body.cpuLimit ?? current.cpuLimit; + const currentEnvironment = normalizeEnvironmentOverrides(current.environment); + const nextEnvironment = + body.environment !== undefined + ? normalizeEnvironmentOverrides(body.environment) + : currentEnvironment; + const nextStartupOverride = + body.startupOverride !== undefined + ? body.startupOverride.trim() || null + : (current.startupOverride ?? null); - const needsRuntimeRecreate = - nextMemoryLimit !== current.memoryLimit || - nextDiskLimit !== current.diskLimit || - nextCpuLimit !== current.cpuLimit || - !sameEnvironmentOverrides(nextEnvironment, currentEnvironment) || - nextStartupOverride !== (current.startupOverride ?? null); + const needsRuntimeRecreate = + nextMemoryLimit !== current.memoryLimit || + nextDiskLimit !== current.diskLimit || + nextCpuLimit !== current.cpuLimit || + !sameEnvironmentOverrides(nextEnvironment, currentEnvironment) || + nextStartupOverride !== (current.startupOverride ?? null); - let nextStatus: MutableServerStatus | null = null; - if (needsRuntimeRecreate) { - try { - const response = await daemonUpdateServer( - { - fqdn: current.nodeFqdn, - grpcPort: current.nodeGrpcPort, - daemonToken: current.nodeDaemonToken, - }, - { - uuid: current.uuid, - docker_image: current.gameDockerImage, - memory_limit: nextMemoryLimit, - disk_limit: nextDiskLimit, - cpu_limit: nextCpuLimit, - startup_command: nextStartupOverride ?? current.gameStartupCommand, - environment: buildDaemonEnvironment( - current.gameEnvironmentVars, - nextEnvironment, - nextMemoryLimit, - ), - ports: buildDaemonPorts(current.gameSlug, current.port, current.gameDefaultPort), - }, - ); - nextStatus = mapDaemonStatus(response.status); - } catch (error) { - app.log.error( - { error, serverId: current.id, serverUuid: current.uuid }, - 'Failed to update server on daemon', - ); - throw new AppError(502, 'Failed to apply runtime changes on daemon', 'DAEMON_UPDATE_FAILED'); + let nextStatus: MutableServerStatus | null = null; + if (needsRuntimeRecreate) { + try { + const response = await daemonUpdateServer( + { + fqdn: current.nodeFqdn, + grpcPort: current.nodeGrpcPort, + daemonToken: current.nodeDaemonToken, + }, + { + uuid: current.uuid, + docker_image: current.gameDockerImage, + memory_limit: nextMemoryLimit, + disk_limit: nextDiskLimit, + cpu_limit: nextCpuLimit, + startup_command: nextStartupOverride ?? current.gameStartupCommand, + environment: applyGameRuntimeEnvironment( + current.gameSlug, + buildDaemonEnvironment( + current.gameEnvironmentVars, + nextEnvironment, + nextMemoryLimit, + ), + current.port, + current.additionalPorts, + ), + ports: buildDaemonPorts( + current.gameSlug, + current.port, + current.gameDefaultPort, + current.additionalPorts, + ), + }, + ); + nextStatus = mapDaemonStatus(response.status); + } catch (error) { + app.log.error( + { error, serverId: current.id, serverUuid: current.uuid }, + 'Failed to update server on daemon', + ); + throw new AppError( + 502, + 'Failed to apply runtime changes on daemon', + 'DAEMON_UPDATE_FAILED', + ); + } } - } - const patch: Record = { - updatedAt: new Date(), - }; - if (body.name !== undefined) patch.name = body.name; - if (body.description !== undefined) patch.description = body.description; - if (body.memoryLimit !== undefined) patch.memoryLimit = nextMemoryLimit; - if (body.diskLimit !== undefined) patch.diskLimit = nextDiskLimit; - if (body.cpuLimit !== undefined) patch.cpuLimit = nextCpuLimit; - if (body.environment !== undefined) patch.environment = nextEnvironment; - if (body.startupOverride !== undefined) patch.startupOverride = nextStartupOverride; - if (nextStatus) patch.status = nextStatus; + const patch: Record = { + updatedAt: new Date(), + }; + if (body.name !== undefined) patch.name = body.name; + if (body.description !== undefined) patch.description = body.description; + if (body.memoryLimit !== undefined) patch.memoryLimit = nextMemoryLimit; + if (body.diskLimit !== undefined) patch.diskLimit = nextDiskLimit; + if (body.cpuLimit !== undefined) patch.cpuLimit = nextCpuLimit; + if (body.environment !== undefined) patch.environment = nextEnvironment; + if (body.startupOverride !== undefined) patch.startupOverride = nextStartupOverride; + if (nextStatus) patch.status = nextStatus; - const [updated] = await app.db - .update(servers) - .set(patch) - .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))) - .returning(); + const [updated] = await app.db + .update(servers) + .set(patch) + .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))) + .returning(); - await createAuditLog(app.db, request, { - organizationId: orgId, - serverId, - action: 'server.update', - metadata: patch, - }); + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'server.update', + metadata: patch, + }); - return updated; - }); + return updated; + }, + ); // DELETE /api/organizations/:orgId/servers/:serverId app.delete('/:serverId', { schema: ServerParamSchema }, async (request, reply) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.delete'); - const [databaseUsage] = await app.db - .select({ count: count() }) - .from(serverDatabases) - .where(eq(serverDatabases.serverId, serverId)); - - if ((databaseUsage?.count ?? 0) > 0) { - throw AppError.conflict('Delete server databases before deleting the server'); - } - const [server] = await app.db .select({ id: servers.id, @@ -761,15 +1320,47 @@ export default async function serverRoutes(app: FastifyInstance) { .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); if (!server) throw AppError.notFound('Server not found'); + const managedDatabases = await app.db + .select({ + id: serverDatabases.id, + databaseName: serverDatabases.databaseName, + name: serverDatabases.name, + username: serverDatabases.username, + }) + .from(serverDatabases) + .where(eq(serverDatabases.serverId, serverId)); + + const nodeConnection: DaemonNodeConnection = { + fqdn: server.nodeFqdn, + grpcPort: server.nodeGrpcPort, + daemonToken: server.nodeDaemonToken, + }; + + for (const database of managedDatabases) { + try { + await daemonDeleteDatabase(nodeConnection, { + databaseName: database.databaseName, + username: database.username, + }); + } catch (error) { + app.log.error( + { databaseId: database.id, databaseName: database.databaseName, error, serverId }, + 'Failed to delete managed database during server delete', + ); + throw new AppError( + 502, + `Failed to delete server database: ${database.name}`, + 'MANAGED_MYSQL_DELETE_FAILED', + ); + } + } + + if (managedDatabases.length > 0) { + await app.db.delete(serverDatabases).where(eq(serverDatabases.serverId, serverId)); + } + try { - await daemonDeleteServer( - { - fqdn: server.nodeFqdn, - grpcPort: server.nodeGrpcPort, - daemonToken: server.nodeDaemonToken, - }, - server.uuid, - ); + await daemonDeleteServer(nodeConnection, server.uuid); } catch (error) { app.log.error( { error, serverId: server.id, serverUuid: server.uuid }, @@ -797,115 +1388,119 @@ export default async function serverRoutes(app: FastifyInstance) { }); // POST /api/organizations/:orgId/servers/:serverId/power - app.post('/:serverId/power', { schema: { ...ServerParamSchema, ...PowerActionSchema } }, async (request) => { - const { orgId, serverId } = request.params as { orgId: string; serverId: string }; - const { action } = request.body as { action: PowerAction }; + app.post( + '/:serverId/power', + { schema: { ...ServerParamSchema, ...PowerActionSchema } }, + async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + const { action } = request.body as { action: PowerAction }; - // Check specific power permission - const permMap = { - start: 'power.start', - stop: 'power.stop', - restart: 'power.restart', - kill: 'power.kill', - } as const; - await requirePermission(request, orgId, permMap[action]); + // Check specific power permission + const permMap = { + start: 'power.start', + stop: 'power.stop', + restart: 'power.restart', + kill: 'power.kill', + } as const; + await requirePermission(request, orgId, permMap[action]); - const [server] = await app.db - .select({ - id: servers.id, - uuid: servers.uuid, - status: servers.status, - nodeFqdn: nodes.fqdn, - nodeGrpcPort: nodes.grpcPort, - nodeDaemonToken: nodes.daemonToken, - }) - .from(servers) - .innerJoin(nodes, eq(servers.nodeId, nodes.id)) - .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); - if (!server) throw AppError.notFound('Server not found'); - - if (server.status === 'suspended') { - throw AppError.badRequest('Cannot send power action to a suspended server'); - } - - try { - await daemonSetPowerState( - { - fqdn: server.nodeFqdn, - grpcPort: server.nodeGrpcPort, - daemonToken: server.nodeDaemonToken, - }, - server.uuid, - action, - ); - } catch (error) { - app.log.error( - { error, serverId: server.id, serverUuid: server.uuid, action }, - 'Failed to send power action to daemon', - ); - throw new AppError(502, 'Failed to send power action to daemon', 'DAEMON_POWER_FAILED'); - } - - const statusMap: Record = { - start: 'running', - stop: 'stopped', - restart: 'running', - kill: 'stopped', - }; - - await app.db - .update(servers) - .set({ - status: statusMap[action], - installedAt: statusMap[action] === 'running' ? new Date() : undefined, - updatedAt: new Date(), - }) - .where(eq(servers.id, serverId)); - - if (action === 'start' || action === 'restart') { - const [serverWithGame] = await app.db + const [server] = await app.db .select({ - gameSlug: games.slug, - automationRules: games.automationRules, + id: servers.id, + uuid: servers.uuid, + status: servers.status, + nodeFqdn: nodes.fqdn, + nodeGrpcPort: nodes.grpcPort, + nodeDaemonToken: nodes.daemonToken, }) .from(servers) - .innerJoin(games, eq(servers.gameId, games.id)) - .where(eq(servers.id, serverId)); + .innerJoin(nodes, eq(servers.nodeId, nodes.id)) + .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); + if (!server) throw AppError.notFound('Server not found'); - if (serverWithGame) { - void sustainCs2ServerConfigAfterPowerStart( - app, + if (server.status === 'suspended') { + throw AppError.badRequest('Cannot send power action to a suspended server'); + } + + try { + await daemonSetPowerState( { fqdn: server.nodeFqdn, grpcPort: server.nodeGrpcPort, daemonToken: server.nodeDaemonToken, }, - serverId, server.uuid, - serverWithGame.gameSlug, + action, ); - - 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, - }); + } catch (error) { + app.log.error( + { error, serverId: server.id, serverUuid: server.uuid, action }, + 'Failed to send power action to daemon', + ); + throw new AppError(502, 'Failed to send power action to daemon', 'DAEMON_POWER_FAILED'); } - } - await createAuditLog(app.db, request, { - organizationId: orgId, - serverId, - action: `server.power.${action}`, - }); + const statusMap: Record = { + start: 'running', + stop: 'stopped', + restart: 'running', + kill: 'stopped', + }; - return { success: true, action }; - }); + await app.db + .update(servers) + .set({ + status: statusMap[action], + installedAt: statusMap[action] === 'running' ? new Date() : undefined, + updatedAt: new Date(), + }) + .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)); + + 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, + }); + } + } + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: `server.power.${action}`, + }); + + return { success: true, action }; + }, + ); } diff --git a/apps/api/src/routes/servers/schemas.ts b/apps/api/src/routes/servers/schemas.ts index b5cf0fd..394893f 100644 --- a/apps/api/src/routes/servers/schemas.ts +++ b/apps/api/src/routes/servers/schemas.ts @@ -17,6 +17,7 @@ export const CreateServerSchema = { diskLimit: Type.Number({ minimum: 256 * 1024 * 1024 }), // min 256MB cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000, default: 100 })), allocationId: Type.String({ format: 'uuid' }), + additionalAllocationIds: Type.Optional(Type.Array(Type.String({ format: 'uuid' }))), environment: Type.Optional(Type.Record(Type.String(), Type.String())), startupOverride: Type.Optional(Type.String()), }), diff --git a/apps/daemon/src/docker/container.rs b/apps/daemon/src/docker/container.rs index e5c6539..08ae32d 100644 --- a/apps/daemon/src/docker/container.rs +++ b/apps/daemon/src/docker/container.rs @@ -1,26 +1,38 @@ use std::collections::HashMap; +use std::io::Cursor; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Result; use bollard::container::{ - AttachContainerOptions, Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions, - StopContainerOptions, StatsOptions, Stats, + AttachContainerOptions, Config, CreateContainerOptions, ListContainersOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions, + StopContainerOptions, StatsOptions, Stats, UploadToContainerOptions, }; use bollard::image::CreateImageOptions; -use bollard::models::{HostConfig, PortBinding}; +use bollard::models::{HostConfig, MountPointTypeEnum, PortBinding}; use futures::StreamExt; use tokio::time::{sleep, Duration}; use tracing::{debug, info}; use crate::docker::DockerManager; use crate::server::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"); pub fn container_name(server_uuid: &str) -> String { format!("{}{}", CONTAINER_PREFIX, server_uuid) } +fn uuid_from_container_name(name: &str) -> Option { + let trimmed = name.trim_start_matches('/'); + trimmed + .strip_prefix(CONTAINER_PREFIX) + .filter(|uuid| !uuid.is_empty()) + .map(str::to_string) +} + fn container_data_path_for_image(image: &str) -> &'static str { let normalized = image.to_ascii_lowercase(); if normalized.contains("cm2network/cs2") || normalized.contains("joedwards32/cs2") { @@ -29,9 +41,30 @@ fn container_data_path_for_image(image: &str) -> &'static str { if normalized.contains("cm2network/csgo") { return "/home/steam/csgo-dedicated"; } + if normalized.contains("spritsail/fivem") { + return "/config"; + } + if normalized.contains("wolveix/satisfactory-server") { + return "/config"; + } "/data" } +fn is_wolveix_satisfactory_image(image: &str) -> bool { + image + .to_ascii_lowercase() + .contains("wolveix/satisfactory-server") +} + +fn server_state_from_container_status(status: &str) -> ServerState { + match status { + "running" | "restarting" | "paused" => ServerState::Running, + "created" | "exited" => ServerState::Stopped, + "dead" => ServerState::Error, + _ => ServerState::Error, + } +} + impl DockerManager { async fn attach_command_stream( &self, @@ -136,6 +169,33 @@ impl DockerManager { Err(anyhow::anyhow!("exec command timeout")) } + async fn patch_satisfactory_run_script(&self, container_name: &str) -> Result<()> { + let mut archive = Vec::new(); + { + let mut builder = tar::Builder::new(&mut archive); + let bytes = SATISFACTORY_RUN_SH.as_bytes(); + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append_data(&mut header, "run.sh", Cursor::new(bytes))?; + builder.finish()?; + } + + self.client() + .upload_to_container( + container_name, + Some(UploadToContainerOptions { + path: "/home/steam", + no_overwrite_dir_non_dir: "false", + }), + archive.into(), + ) + .await?; + + Ok(()) + } + pub async fn rcon_command(&self, server_uuid: &str, command: &str) -> Result { let name = container_name(server_uuid); self.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()]) @@ -248,6 +308,10 @@ impl DockerManager { let options = CreateContainerOptions { name: name.as_str(), platform: None }; let response = self.client().create_container(Some(options), config).await?; + if is_wolveix_satisfactory_image(&spec.docker_image) { + self.patch_satisfactory_run_script(&name).await?; + } + info!(container_id = %response.id, uuid = %spec.uuid, "Container created"); Ok(response.id) } @@ -380,6 +444,162 @@ impl DockerManager { Ok((image, env_map)) } + /// Discover existing managed containers and rebuild in-memory server specs. + pub async fn recover_managed_server_specs(&self, data_root: &Path) -> Result> { + let containers = self + .client() + .list_containers(Some(ListContainersOptions:: { + all: true, + ..Default::default() + })) + .await?; + + let mut recovered = Vec::new(); + + for container in containers { + let uuid = container + .names + .as_ref() + .into_iter() + .flatten() + .find_map(|name| uuid_from_container_name(name)); + + let Some(uuid) = uuid else { + continue; + }; + + let info = self.client().inspect_container(&container_name(&uuid), None).await?; + + let image = info + .config + .as_ref() + .and_then(|cfg| cfg.image.clone()) + .unwrap_or_default(); + + let data_mount_path = info + .mounts + .as_ref() + .and_then(|mounts| { + mounts.iter().find_map(|mount| { + if mount.typ != Some(MountPointTypeEnum::BIND) { + return None; + } + mount.source.as_ref().map(PathBuf::from) + }) + }) + .unwrap_or_else(|| data_root.join(&uuid)); + + let data_destination = info + .mounts + .as_ref() + .and_then(|mounts| { + mounts.iter().find_map(|mount| { + if mount.typ != Some(MountPointTypeEnum::BIND) { + return None; + } + mount.destination.clone() + }) + }) + .unwrap_or_else(|| container_data_path_for_image(&image).to_string()); + + let startup_command = info + .config + .as_ref() + .and_then(|cfg| { + let working_dir = cfg.working_dir.as_deref().unwrap_or_default(); + if working_dir != data_destination { + return None; + } + cfg.cmd.as_ref().map(|cmd| cmd.join(" ")) + }) + .unwrap_or_default(); + + let mut environment = HashMap::new(); + if let Some(env_vars) = info + .config + .as_ref() + .and_then(|cfg| cfg.env.clone()) + { + for entry in env_vars { + if let Some((key, value)) = entry.split_once('=') { + environment.insert(key.to_string(), value.to_string()); + } + } + } + + let ports = info + .host_config + .as_ref() + .and_then(|cfg| cfg.port_bindings.as_ref()) + .map(|bindings| { + bindings + .iter() + .flat_map(|(container_port, host_bindings)| { + let (container_port, protocol) = match container_port.split_once('/') { + Some((port, protocol)) => (port, protocol), + None => return Vec::new(), + }; + + let Ok(container_port_num) = container_port.parse::() else { + return Vec::new(); + }; + + host_bindings + .as_ref() + .into_iter() + .flatten() + .filter_map(|binding| { + let host_port = binding.host_port.as_deref()?.parse::().ok()?; + Some(crate::server::PortMap { + host_port, + container_port: container_port_num, + protocol: protocol.to_string(), + }) + }) + .collect::>() + }) + .collect::>() + }) + .unwrap_or_default(); + + let memory_limit = info + .host_config + .as_ref() + .and_then(|cfg| cfg.memory) + .unwrap_or_default(); + + let cpu_limit = info + .host_config + .as_ref() + .and_then(|cfg| cfg.nano_cpus) + .map(|nano_cpus| (nano_cpus / 10_000_000) as i32) + .unwrap_or_default(); + + let state = info + .state + .as_ref() + .and_then(|state| state.status.as_ref()) + .map(|status| server_state_from_container_status(&format!("{status:?}").to_lowercase())) + .unwrap_or(ServerState::Error); + + recovered.push(ServerSpec { + uuid, + docker_image: image, + memory_limit, + disk_limit: 0, + cpu_limit, + startup_command, + environment, + ports, + data_path: data_mount_path, + state, + container_id: info.id, + }); + } + + Ok(recovered) + } + /// Stream container logs (stdout + stderr). Returns an owned stream. pub fn stream_logs( self: &Arc, diff --git a/apps/daemon/src/game/satisfactory_run.sh b/apps/daemon/src/game/satisfactory_run.sh new file mode 100644 index 0000000..3b4a5b6 --- /dev/null +++ b/apps/daemon/src/game/satisfactory_run.sh @@ -0,0 +1,157 @@ +#!/bin/bash + +set -e + +NUMCHECK='^[0-9]+$' +MSGWARNING="\033[0;33mWARNING:\033[0m" + +if ! [[ "$SERVERGAMEPORT" =~ $NUMCHECK ]]; then + printf "Invalid server port given: %s\n" "$SERVERGAMEPORT" + SERVERGAMEPORT="7777" +fi +printf "Setting server port to %s\n" "$SERVERGAMEPORT" + +if ! [[ "$SERVERMESSAGINGPORT" =~ $NUMCHECK ]]; then + printf "Invalid messaging port given: %s\n" "$SERVERMESSAGINGPORT" + SERVERMESSAGINGPORT="8888" +fi +printf "Setting messaging port to %s\n" "$SERVERMESSAGINGPORT" + +if ! [[ "$AUTOSAVENUM" =~ $NUMCHECK ]]; then + printf "Invalid autosave number given: %s\n" "$AUTOSAVENUM" + AUTOSAVENUM="5" +fi +printf "Setting autosave number to %s\n" "$AUTOSAVENUM" + +if ! [[ "$MAXOBJECTS" =~ $NUMCHECK ]]; then + printf "Invalid max objects number given: %s\n" "$MAXOBJECTS" + MAXOBJECTS="2162688" +fi +printf "Setting max objects to %s\n" "$MAXOBJECTS" + +if ! [[ "$MAXTICKRATE" =~ $NUMCHECK ]]; then + printf "Invalid max tick rate number given: %s\n" "$MAXTICKRATE" + MAXTICKRATE="30" +fi +printf "Setting max tick rate to %s\n" "$MAXTICKRATE" + +[[ "${SERVERSTREAMING,,}" == "true" ]] && SERVERSTREAMING="1" || SERVERSTREAMING="0" +printf "Setting server streaming to %s\n" "$SERVERSTREAMING" + +if ! [[ "$TIMEOUT" =~ $NUMCHECK ]]; then + printf "Invalid timeout number given: %s\n" "$TIMEOUT" + TIMEOUT="30" +fi +printf "Setting timeout to %s\n" "$TIMEOUT" + +if ! [[ "$MAXPLAYERS" =~ $NUMCHECK ]]; then + printf "Invalid max players given: %s\n" "$MAXPLAYERS" + MAXPLAYERS="4" +fi +printf "Setting max players to %s\n" "$MAXPLAYERS" + +if [[ "${DISABLESEASONALEVENTS,,}" == "true" ]]; then + printf "Disabling seasonal events\n" + DISABLESEASONALEVENTS="-DisableSeasonalEvents" +else + DISABLESEASONALEVENTS="" +fi + +if [[ "$MULTIHOME" != "" ]]; then + if [[ "$MULTIHOME" == "::" ]]; then + printf "Multihome will accept IPv4 and IPv6 connections\n" + fi + printf "Setting multihome to %s\n" "$MULTIHOME" + MULTIHOME="-multihome=$MULTIHOME" +fi + +ini_args=( + "-ini:Engine:[/Script/FactoryGame.FGSaveSession]:mNumRotatingAutosaves=$AUTOSAVENUM" + "-ini:Engine:[/Script/Engine.GarbageCollectionSettings]:gc.MaxObjectsInEditor=$MAXOBJECTS" + "-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:LanServerMaxTickRate=$MAXTICKRATE" + "-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:NetServerMaxTickRate=$MAXTICKRATE" + "-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:ConnectionTimeout=$TIMEOUT" + "-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:InitialConnectTimeout=$TIMEOUT" + "-ini:Engine:[ConsoleVariables]:wp.Runtime.EnableServerStreaming=$SERVERSTREAMING" + "-ini:Game:[/Script/Engine.GameSession]:ConnectionTimeout=$TIMEOUT" + "-ini:Game:[/Script/Engine.GameSession]:InitialConnectTimeout=$TIMEOUT" + "-ini:Game:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS" + "-ini:GameUserSettings:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS" + "$DISABLESEASONALEVENTS" + "$MULTIHOME" +) + +if [[ "${SKIPUPDATE,,}" != "false" ]] && [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then + printf "%s Skip update is set, but no game files exist. Updating anyway\n" "$MSGWARNING" + SKIPUPDATE="false" +fi + +if [[ "${SKIPUPDATE,,}" != "true" ]]; then + STEAMBETAPASSWORD="" + + if [[ -n "${STEAMBETAID}" ]]; then + printf "STEAMBETAID is set. Using beta ID: %s\n" "$STEAMBETAID" + STEAMBETAFLAG="$STEAMBETAID" + if [[ -n "${STEAMBETAKEY}" ]]; then + STEAMBETAPASSWORD="-betapassword $STEAMBETAKEY" + printf "Beta password provided\n" + fi + elif [[ "${STEAMBETA,,}" == "true" ]]; then + printf "Experimental flag is set. Experimental will be downloaded instead of Early Access.\n" + STEAMBETAFLAG="experimental" + else + STEAMBETAFLAG="" + fi + + STORAGEAVAILABLE=$(stat -f -c "%a*%S" .) + STORAGEAVAILABLE=$((STORAGEAVAILABLE/1024/1024/1024)) + printf "Checking available storage: %sGB detected\n" "$STORAGEAVAILABLE" + + if [[ "$STORAGEAVAILABLE" -lt 8 ]]; then + printf "You have less than 8GB (%sGB detected) of available storage to download the game.\nIf this is a fresh install, it will probably fail.\n" "$STORAGEAVAILABLE" + fi + + printf "\nDownloading the latest version of the game...\n" + if [ -f "/config/gamefiles/steamapps/appmanifest_1690800.acf" ]; then + printf "\nRemoving the app manifest to force Steam to check for an update...\n" + rm "/config/gamefiles/steamapps/appmanifest_1690800.acf" || true + fi + + if [[ -n "$STEAMBETAFLAG" ]]; then + steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" -beta "$STEAMBETAFLAG" $STEAMBETAPASSWORD validate +quit + else + steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" validate +quit + fi + + cp -r /home/steam/.steam/steam/logs/* "/config/logs/steam" || printf "Failed to store Steam logs\n" +else + printf "Skipping update as flag is set\n" +fi + +printf "Launching game server\n\n" + +cp -r "/config/saved/server/." "/config/backups/" 2>/dev/null || true +cp -r "${GAMESAVESDIR}/server/." "/config/backups" 2>/dev/null || true +rm -rf "$GAMESAVESDIR" +ln -sf "/config/saved" "$GAMESAVESDIR" + +if [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then + printf "FactoryServer launch script is missing.\n" + exit 1 +fi + +cd /config/gamefiles || exit 1 + +chmod +x FactoryServer.sh || true +./FactoryServer.sh -Port="$SERVERGAMEPORT" -ReliablePort="$SERVERMESSAGINGPORT" -ExternalReliablePort="$SERVERMESSAGINGPORT" "${ini_args[@]}" "$@" & + +sleep 2 +satisfactory_pid=$(ps --ppid ${!} o pid=) + +shutdown() { + printf "\nReceived SIGINT. Shutting down.\n" + kill -INT $satisfactory_pid 2>/dev/null +} +trap shutdown SIGINT SIGTERM + +wait diff --git a/apps/daemon/src/grpc/service.rs b/apps/daemon/src/grpc/service.rs index 909fc51..5252f29 100644 --- a/apps/daemon/src/grpc/service.rs +++ b/apps/daemon/src/grpc/service.rs @@ -303,6 +303,28 @@ impl DaemonService for DaemonServiceImpl { })) } + async fn import_database_sql( + &self, + request: Request, + ) -> Result, Status> { + self.check_auth(&request)?; + let req = request.into_inner(); + + if req.database_name.trim().is_empty() { + return Err(Status::invalid_argument("Database name is required")); + } + if req.sql.trim().is_empty() { + return Err(Status::invalid_argument("SQL payload is required")); + } + + self.managed_mysql + .import_sql(req.database_name.trim(), &req.sql) + .await + .map_err(Status::from)?; + + Ok(Response::new(Empty {})) + } + async fn update_database_password( &self, request: Request, diff --git a/apps/daemon/src/main.rs b/apps/daemon/src/main.rs index b573658..7ce5ea4 100644 --- a/apps/daemon/src/main.rs +++ b/apps/daemon/src/main.rs @@ -49,6 +49,9 @@ async fn main() -> Result<()> { let server_manager = Arc::new(ServerManager::new(docker, &config)); info!("Server manager initialized"); + let recovered_servers = server_manager.recover_existing_servers().await?; + info!(recovered_servers, "Recovered managed servers from Docker"); + // Initialize shared command dispatcher (single command pipeline for all games/sources) let command_dispatcher = Arc::new(CommandDispatcher::new(server_manager.clone())); info!("Command dispatcher initialized"); diff --git a/apps/daemon/src/managed_mysql.rs b/apps/daemon/src/managed_mysql.rs index 859cee8..5779479 100644 --- a/apps/daemon/src/managed_mysql.rs +++ b/apps/daemon/src/managed_mysql.rs @@ -1,7 +1,9 @@ use std::io::ErrorKind; +use std::process::Stdio; use reqwest::Url; use thiserror::Error; +use tokio::io::AsyncWriteExt; use tokio::process::Command; use tonic::Status; use uuid::Uuid; @@ -164,6 +166,28 @@ impl ManagedMysqlManager { .await } + pub async fn import_sql( + &self, + database_name: &str, + sql: &str, + ) -> Result<(), ManagedMysqlError> { + let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?; + let database_name = database_name.trim(); + if database_name.is_empty() { + return Err(ManagedMysqlError::CommandFailed( + "Database name is required".to_string(), + )); + } + + if sql.trim().is_empty() { + return Err(ManagedMysqlError::CommandFailed( + "SQL payload is required".to_string(), + )); + } + + self.run_sql_script(config, database_name, sql).await + } + pub async fn delete_database( &self, database_name: &str, @@ -242,6 +266,76 @@ impl ManagedMysqlManager { Err(ManagedMysqlError::ClientMissing) } + + async fn run_sql_script( + &self, + config: &ManagedMysqlRuntimeConfig, + database_name: &str, + sql: &str, + ) -> Result<(), ManagedMysqlError> { + let binaries = match config.client_bin.as_deref() { + Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()], + _ => vec!["mariadb".to_string(), "mysql".to_string()], + }; + + let mut missing_binary = false; + + for binary in binaries { + let child = Command::new(&binary) + .args([ + "--protocol=TCP", + "--batch", + "--skip-column-names", + "-h", + &config.admin_host, + "-P", + &config.admin_port.to_string(), + "-u", + &config.admin_username, + database_name, + ]) + .env("MYSQL_PWD", &config.admin_password) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + + match child { + Ok(mut child) => { + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(sql.as_bytes()).await?; + } + + let output = child.wait_with_output().await?; + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let message = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("{} exited with status {}", binary, output.status) + }; + return Err(ManagedMysqlError::CommandFailed(message)); + } + Err(error) if error.kind() == ErrorKind::NotFound => { + missing_binary = true; + continue; + } + Err(error) => return Err(ManagedMysqlError::Io(error)), + } + } + + if missing_binary { + return Err(ManagedMysqlError::ClientMissing); + } + + Err(ManagedMysqlError::ClientMissing) + } } fn resolve_runtime_config( diff --git a/apps/daemon/src/server/manager.rs b/apps/daemon/src/server/manager.rs index 73335f4..cfbc52d 100644 --- a/apps/daemon/src/server/manager.rs +++ b/apps/daemon/src/server/manager.rs @@ -49,6 +49,32 @@ impl ServerManager { } } + /// Rebuild in-memory server specs from existing managed Docker containers. + pub async fn recover_existing_servers(&self) -> Result { + let recovered = self + .docker + .recover_managed_server_specs(&self.data_root) + .await + .map_err(|error| DaemonError::Internal(format!("Failed to recover managed containers: {}", error)))?; + + let recovered_count = recovered.len(); + let mut servers = self.servers.write().await; + servers.clear(); + + for spec in recovered { + self.ensure_server_data_dir(&spec.data_path).await?; + info!( + uuid = %spec.uuid, + state = %spec.state, + image = %spec.docker_image, + "Recovered managed server from Docker runtime" + ); + servers.insert(spec.uuid.clone(), spec); + } + + Ok(recovered_count) + } + /// Get server spec by UUID. pub async fn get_server(&self, uuid: &str) -> Result { let servers = self.servers.read().await; @@ -200,6 +226,10 @@ impl ServerManager { })?; } + self.docker.pull_image(&desired_spec.docker_image).await.map_err(|e| { + DaemonError::Internal(format!("Failed to pull updated image during server update: {}", e)) + })?; + match self.docker.create_container(&desired_spec).await { Ok(container_id) => { desired_spec.container_id = Some(container_id); diff --git a/apps/web/src/pages/servers/create.tsx b/apps/web/src/pages/servers/create.tsx index 23085f8..bf8ed18 100644 --- a/apps/web/src/pages/servers/create.tsx +++ b/apps/web/src/pages/servers/create.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation } from '@tanstack/react-query'; import { api } from '@/lib/api'; @@ -7,15 +7,34 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; interface Game { + environmentVars?: GameEnvironmentVar[]; id: string; name: string; slug: string; dockerImage: string; } +interface GameEnvironmentVar { + key: string; + label?: string; + default?: string; + description?: string; + required?: boolean; + inputType?: 'text' | 'boolean'; + composeInto?: string; + enabledLabel?: string; + disabledLabel?: string; +} + interface Node { id: string; name: string; @@ -36,6 +55,28 @@ interface PaginatedResponse { meta: { total: number }; } +interface AdditionalPortRequirement { + key: string; + label: string; + defaultPort: number; + protocols: Array<'tcp' | 'udp'>; + description: string; +} + +function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] { + if (gameSlug.trim().toLowerCase() !== 'satisfactory') return []; + + return [ + { + key: 'satisfactory-messaging', + label: 'Messaging Port', + defaultPort: 8888, + protocols: ['tcp'], + description: 'Required by the Satisfactory server messaging API.', + }, + ]; +} + export function CreateServerPage() { const { orgId } = useParams(); const navigate = useNavigate(); @@ -46,9 +87,13 @@ export function CreateServerPage() { const [gameId, setGameId] = useState(''); const [nodeId, setNodeId] = useState(''); const [allocationId, setAllocationId] = useState(''); + const [additionalAllocationIds, setAdditionalAllocationIds] = useState>( + {}, + ); const [memoryLimit, setMemoryLimit] = useState(1024); const [diskLimit, setDiskLimit] = useState(5120); const [cpuLimit, setCpuLimit] = useState(100); + const [environment, setEnvironment] = useState>({}); const { data: gamesData } = useQuery({ queryKey: ['games'], @@ -63,9 +108,7 @@ export function CreateServerPage() { const { data: allocationsData } = useQuery({ queryKey: ['allocations', orgId, nodeId], queryFn: () => - api.get>( - `/organizations/${orgId}/nodes/${nodeId}/allocations`, - ), + api.get>(`/organizations/${orgId}/nodes/${nodeId}/allocations`), enabled: !!nodeId, }); @@ -81,8 +124,60 @@ export function CreateServerPage() { const games = gamesData?.data ?? []; const nodes = nodesData?.data ?? []; + const activeGame = games.find((game) => game.id === gameId); + const additionalPortRequirements = activeGame + ? additionalPortRequirementsForGame(activeGame.slug) + : []; + const visibleEnvironmentVars = (activeGame?.environmentVars ?? []).filter((variable) => { + const key = variable.key?.trim(); + return Boolean(key) && !variable.composeInto?.trim(); + }); + const missingRequiredEnvironment = visibleEnvironmentVars.some((variable) => { + const key = variable.key.trim(); + const currentValue = environment[key] ?? String(variable.default ?? ''); + return Boolean(variable.required) && !currentValue.trim(); + }); + const missingRequiredAdditionalPorts = additionalPortRequirements.some( + (requirement) => !additionalAllocationIds[requirement.key], + ); + + useEffect(() => { + if (!activeGame) { + setEnvironment({}); + return; + } + + setEnvironment((current) => { + const next: Record = {}; + for (const variable of activeGame.environmentVars ?? []) { + const key = variable.key?.trim(); + if (!key || variable.composeInto?.trim()) continue; + next[key] = current[key] ?? String(variable.default ?? ''); + } + return next; + }); + setAdditionalAllocationIds({}); + if (activeGame.slug.trim().toLowerCase() === 'satisfactory') { + setMemoryLimit((current) => Math.max(current, 8192)); + setDiskLimit((current) => Math.max(current, 12288)); + } + }, [activeGame?.id]); + + useEffect(() => { + setAdditionalAllocationIds((current) => { + const allowedKeys = new Set(additionalPortRequirements.map((requirement) => requirement.key)); + const next = Object.fromEntries( + Object.entries(current).filter(([key]) => allowedKeys.has(key)), + ); + return next; + }); + }, [activeGame?.slug]); const handleCreate = () => { + const environmentPayload = Object.fromEntries( + Object.entries(environment).filter(([, value]) => value.trim() !== ''), + ); + createMutation.mutate({ name, description: description || undefined, @@ -92,9 +187,30 @@ export function CreateServerPage() { memoryLimit: memoryLimit * 1024 * 1024, diskLimit: diskLimit * 1024 * 1024, cpuLimit, + additionalAllocationIds: + additionalPortRequirements.length > 0 + ? additionalPortRequirements + .map((requirement) => additionalAllocationIds[requirement.key]) + .filter(Boolean) + : undefined, + environment: Object.keys(environmentPayload).length > 0 ? environmentPayload : undefined, }); }; + const allocationOptionsForRequirement = (requirementKey: string) => { + const selectedByOtherRequirements = new Set( + Object.entries(additionalAllocationIds) + .filter(([key]) => key !== requirementKey) + .map(([, id]) => id) + .filter(Boolean), + ); + + return freeAllocations.filter( + (allocation) => + allocation.id !== allocationId && !selectedByOtherRequirements.has(allocation.id), + ); + }; + return (
@@ -149,7 +265,69 @@ export function CreateServerPage() {
- + +
+ ) : ( + + setEnvironment((current) => ({ + ...current, + [key]: e.target.value, + })) + } + placeholder={variable.description || label} + /> + )} + {variable.description && ( +

{variable.description}

+ )} + + ); + })} + @@ -170,6 +348,7 @@ export function CreateServerPage() { onValueChange={(v) => { setNodeId(v); setAllocationId(''); + setAdditionalAllocationIds({}); }} > @@ -187,7 +366,17 @@ export function CreateServerPage() { {nodeId && (
- { + setAllocationId(value); + setAdditionalAllocationIds((current) => + Object.fromEntries( + Object.entries(current).filter(([, selectedId]) => selectedId !== value), + ), + ); + }} + > @@ -204,11 +393,53 @@ export function CreateServerPage() { )}
)} + {nodeId && + additionalPortRequirements.map((requirement) => { + const options = allocationOptionsForRequirement(requirement.key); + const protocols = requirement.protocols.join('/').toUpperCase(); + + return ( +
+ + +

{requirement.description}

+ {options.length === 0 && ( +

+ No free allocation available for this port +

+ )} +
+ ); + })}
-
diff --git a/conduit-bringup-artifacts/conduit-crash-20260613-113458.txt b/conduit-bringup-artifacts/conduit-crash-20260613-113458.txt new file mode 100644 index 0000000..90e785d --- /dev/null +++ b/conduit-bringup-artifacts/conduit-crash-20260613-113458.txt @@ -0,0 +1,82 @@ +=== CONDUIT CRASH REPORT ========================================== +Framework : Conduit 0.1.0-dev +Time : 2026-06-13 11:34:58 (local) +Process : pid=36 +Signal : 11 (Segmentation fault), fault address (nil) + +--- ATTRIBUTION --------------------------------------------------- + 1. owner=crash_test_plugin callsite=conduit_crash_test +>>> Most likely culprit: 'crash_test_plugin' (in 'conduit_crash_test') + +--- STACK TRACE --------------------------------------------------- + #00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbfb64) [0x7fdf9e694b64] + #01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050] + #02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf66c) [0x7fdf9e69466c] + #03 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902] + +--- RECENT EVENTS (oldest first) ---------------------------------- + [-1767.736s] [INFO] perf: heartbeat: 1921 frames, avg 2.22 ms, max 20.85 ms (last 30 s) + [-1737.735s] [INFO] perf: heartbeat: 1920 frames, avg 2.16 ms, max 5.31 ms (last 30 s) + [-1707.720s] [INFO] perf: heartbeat: 1921 frames, avg 2.12 ms, max 4.39 ms (last 30 s) + [-1677.719s] [INFO] perf: heartbeat: 1920 frames, avg 2.20 ms, max 5.59 ms (last 30 s) + [-1647.706s] [INFO] perf: heartbeat: 1921 frames, avg 2.39 ms, max 23.56 ms (last 30 s) + [-1617.705s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 7.46 ms (last 30 s) + [-1587.691s] [INFO] perf: heartbeat: 1921 frames, avg 2.37 ms, max 8.58 ms (last 30 s) + [-1557.680s] [INFO] perf: heartbeat: 1921 frames, avg 2.33 ms, max 4.84 ms (last 30 s) + [-1527.679s] [INFO] perf: heartbeat: 1920 frames, avg 2.25 ms, max 5.09 ms (last 30 s) + [-1497.674s] [INFO] perf: heartbeat: 1920 frames, avg 2.18 ms, max 19.43 ms (last 30 s) + [-1467.673s] [INFO] perf: heartbeat: 1920 frames, avg 2.30 ms, max 6.87 ms (last 30 s) + [-1437.669s] [INFO] perf: heartbeat: 1920 frames, avg 2.23 ms, max 34.46 ms (last 30 s) + [-1407.668s] [INFO] perf: heartbeat: 1920 frames, avg 2.45 ms, max 6.17 ms (last 30 s) + [-1377.653s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.25 ms (last 30 s) + [-1347.653s] [INFO] perf: heartbeat: 1920 frames, avg 2.53 ms, max 5.44 ms (last 30 s) + [-1317.644s] [INFO] perf: heartbeat: 1921 frames, avg 2.51 ms, max 20.77 ms (last 30 s) + [-1287.631s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.45 ms (last 30 s) + [-1257.614s] [INFO] perf: heartbeat: 1921 frames, avg 2.57 ms, max 5.90 ms (last 30 s) + [-1227.600s] [INFO] perf: heartbeat: 1921 frames, avg 2.54 ms, max 5.79 ms (last 30 s) + [-1197.583s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 6.94 ms (last 30 s) + [-1167.578s] [INFO] perf: heartbeat: 1920 frames, avg 2.46 ms, max 20.36 ms (last 30 s) + [-1137.562s] [INFO] perf: heartbeat: 1921 frames, avg 2.47 ms, max 7.86 ms (last 30 s) + [-1107.548s] [INFO] perf: heartbeat: 1921 frames, avg 2.53 ms, max 6.38 ms (last 30 s) + [-1077.547s] [INFO] perf: heartbeat: 1920 frames, avg 2.54 ms, max 4.09 ms (last 30 s) + [-1047.543s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 33.69 ms (last 30 s) + [-1017.532s] [INFO] perf: heartbeat: 1921 frames, avg 2.44 ms, max 5.97 ms (last 30 s) + [- 987.516s] [INFO] perf: heartbeat: 1921 frames, avg 2.59 ms, max 5.71 ms (last 30 s) + [- 957.501s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 7.43 ms (last 30 s) + [- 927.501s] [INFO] perf: heartbeat: 1920 frames, avg 2.60 ms, max 9.17 ms (last 30 s) + [- 897.490s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 24.84 ms (last 30 s) + [- 867.475s] [INFO] perf: heartbeat: 1921 frames, avg 2.58 ms, max 6.20 ms (last 30 s) + [- 837.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 5.57 ms (last 30 s) + [- 807.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 6.15 ms (last 30 s) + [- 777.460s] [INFO] perf: heartbeat: 1921 frames, avg 2.70 ms, max 5.47 ms (last 30 s) + [- 747.449s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 24.22 ms (last 30 s) + [- 717.434s] [INFO] perf: heartbeat: 1921 frames, avg 2.80 ms, max 8.64 ms (last 30 s) + [- 687.419s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.28 ms (last 30 s) + [- 657.418s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 6.34 ms (last 30 s) + [- 627.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 31.89 ms (last 30 s) + [- 597.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 6.72 ms (last 30 s) + [- 567.400s] [INFO] perf: heartbeat: 1921 frames, avg 2.67 ms, max 6.53 ms (last 30 s) + [- 537.384s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.66 ms (last 30 s) + [- 507.369s] [INFO] perf: heartbeat: 1921 frames, avg 2.55 ms, max 5.30 ms (last 30 s) + [- 477.365s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 34.05 ms (last 30 s) + [- 447.364s] [INFO] perf: heartbeat: 1920 frames, avg 2.59 ms, max 5.42 ms (last 30 s) + [- 417.349s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.55 ms (last 30 s) + [- 387.334s] [INFO] perf: heartbeat: 1921 frames, avg 2.34 ms, max 4.63 ms (last 30 s) + [- 357.334s] [INFO] perf: heartbeat: 1920 frames, avg 2.77 ms, max 6.65 ms (last 30 s) + [- 327.332s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 26.14 ms (last 30 s) + [- 312.170s] detour 'detour_self_test' installed at 0x7fdf9e693cd0 + [- 312.170s] [INFO] hooks: detour 'detour_self_test' installed at 0x7fdf9e693cd0 + [- 312.169s] detour 'detour_self_test' removed + [- 312.169s] [INFO] hooks: detour 'detour_self_test' removed + [- 297.318s] [INFO] perf: heartbeat: 1921 frames, avg 2.73 ms, max 6.96 ms (last 30 s) + [- 267.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.58 ms, max 6.68 ms (last 30 s) + [- 237.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 7.08 ms (last 30 s) + [- 207.296s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 25.68 ms (last 30 s) + [- 177.293s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 5.12 ms (last 30 s) + [- 147.280s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.80 ms (last 30 s) + [- 117.280s] [INFO] perf: heartbeat: 1920 frames, avg 2.64 ms, max 6.24 ms (last 30 s) + [- 87.265s] [INFO] perf: heartbeat: 1921 frames, avg 2.78 ms, max 5.95 ms (last 30 s) + [- 57.263s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 31.12 ms (last 30 s) + [- 27.248s] [INFO] perf: heartbeat: 1921 frames, avg 2.72 ms, max 7.34 ms (last 30 s) + [- 0.001s] [WARN] core: conduit_crash_test invoked — crashing deliberately +=================================================================== diff --git a/conduit-bringup-artifacts/linux-accumulated-rerun-20260615.md b/conduit-bringup-artifacts/linux-accumulated-rerun-20260615.md new file mode 100644 index 0000000..7b69964 --- /dev/null +++ b/conduit-bringup-artifacts/linux-accumulated-rerun-20260615.md @@ -0,0 +1,374 @@ +# Conduit Linux Accumulated Re-run - 2026-06-15 + +## Scope + +- Pulled all accumulated commits from `origin/main`. +- Inspected the changed files and current `docs/linux-bringup.md`. +- Rebuilt Conduit on Linux with GCC 12. +- Deployed to CS2 server `Test Sunucusu`. +- Verified the new timer, chat interception, usermessage/chat-send, and console adopt paths. +- Cleaned temporary test subscriptions/timers/commands. +- Satisfactory server was not modified. + +## Git + +Previous local HEAD before this run: + +```text +bb58461 Phase 2: dynamic console commands + convar access +``` + +Pulled commits: + +```text +43855ff PLAN: chat ships plain (white) via UTIL_SayTextFilter; color is a follow-up +8afbeec chat: send via UTIL_SayTextFilter again (plain text works; SayText2 shows nothing) +faded69 docs: refresh README (Phase 2 status + what works) and PLAN chat-send entry +18e7970 chat colors: prefer UTIL_SayText2Filter (renders color codes) +1743147 PLAN: chat send visually verified on Windows (msgType 0 = chat); colors pending +4b3d322 PLAN: correct the chat-send entry (native UTIL_SayTextFilter, SDK drift finding) +4ba6019 chat sending: call native UTIL_SayTextFilter instead of building the usermessage +a4af862 Phase 2: usermessages + chat sending (SayText2) +6985830 Phase 2: chat interception (say / say_team) +a375e05 Phase 2: game-thread timers driven by GameFrame +62e4ca0 Close out Linux verification of the Commands & ConVars slice +15cc25e console: track adopted convars under their owner +``` + +Diff summary: + +```text + PLAN.md | 8 +- + README.md | 17 +++- + core/CMakeLists.txt | 3 + + core/src/conduit/chat.cpp | 165 ++++++++++++++++++++++++++++++++++++++ + core/src/conduit/chat.h | 46 +++++++++++ + core/src/conduit/commands.cpp | 131 ++++++++++++++++++++++++++++++ + core/src/conduit/console.cpp | 23 +++++- + core/src/conduit/timers.cpp | 181 ++++++++++++++++++++++++++++++++++++++++++ + core/src/conduit/timers.h | 47 +++++++++++ + core/src/conduit/usermsg.cpp | 147 ++++++++++++++++++++++++++++++++++ + core/src/conduit/usermsg.h | 23 ++++++ + core/src/plugin.cpp | 16 ++++ + docs/linux-bringup.md | 76 +++++++++++++++--- + gamedata/core.json | 15 +++- +``` + +Working tree after the run: + +```text +## main...origin/main +``` + +## Build + +Command: + +```sh +cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_C_COMPILER=gcc-12 \ + -DCMAKE_CXX_COMPILER=g++-12 +cmake --build /root/codex/Conduit/build/linux-rerun +``` + +Result: build and link succeeded. + +```text +-- Configuring done +-- Generating done +-- Build files have been written to: /root/codex/Conduit/build/linux-rerun +[1/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/timers.cpp.o +[2/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o +[3/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/usermsg.cpp.o +[4/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/console.cpp.o +[5/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/chat.cpp.o +[6/7] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o +[7/7] Linking CXX shared library package/addons/conduit/bin/conduit.so +``` + +Only warning observed: + +```text +/root/codex/Conduit/core/src/conduit/commands.cpp:521:13: warning: compound assignment with volatile-qualified left operand is deprecated [-Wvolatile] +``` + +No failed CMake/Ninja output exists because the build passed. + +## Deploy And Load + +Deployed: + +```text +/root/codex/Conduit/build/linux-rerun/package/. +``` + +To: + +```text +/var/lib/gamepanel/servers/c2b47a90/game/csgo/ +``` + +Load checks: + +```text +--- meta list --- +Listing 1 plugin: + [01] Conduit (0.1.0-dev) by Conduit Contributors + +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 73.6 s + game frames : 3983 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260615.log + crash report: armed (use conduit_crash_test to verify) +``` + +Gamedata and usermessage function resolution: + +```text +--- conduit_gamedata --- +gamedata: 1 files, 2 signatures, 0 offsets + [ok] UTIL_SayText2Filter server 0x7fd2de250b30 (core.json) + [ok] UTIL_SayTextFilter server 0x7fd2de2508d0 (core.json) + +--- conduit_usermsg --- +[Conduit] [INFO] usermsg: chat sending live via UTIL_SayTextFilter (0x7fd2de2508d0) - plain text, no color yet +usermessages: chat sending live (SayText2 resolved, SayText resolved) +``` + +Startup log: + +```text +2026-06-15 10:50:26 [INFO] gamedata: 1 files loaded: 2 signatures, 0 offsets, all valid +2026-06-15 10:50:27 [INFO] entity: armed via CGameEntitySystem vtable 0x7fd2dec4ffd8 +2026-06-15 10:50:27 [INFO] gameevents: armed via CGameEventManager vtable 0x7fd2dec50f50 +2026-06-15 10:50:27 [INFO] chat: ready - hook installs on first subscriber +2026-06-15 10:50:27 [INFO] core: Conduit 0.1.0-dev loaded - crash reporter armed, profiler on +2026-06-15 10:50:27 [INFO] gameevents: game event manager connected (0x7fd2defd0460) +2026-06-15 10:50:37 [INFO] core: first GameFrame observed - hook dispatch confirmed +``` + +Entity system still resolves on Linux at `GameResourceServiceServerV001+0x50`: + +```text +--- conduit_entity --- +[Conduit] [INFO] entity: entity system connected (0x7fd2da446000) via GameResourceServiceServerV001+0x50 - vtable matches CGameEntitySystem +entity system: connected + slot 0 cs_player_controller team=2 pawn=player health=100 + slot 1 cs_player_controller team=3 pawn=player health=100 +``` + +## Timer Tests + +Schedule/list: + +```text +--- conduit_timer_after 2 --- +[Conduit] one-shot timer #1 scheduled in 2.00 s +--- conduit_timer_every 1 --- +[Conduit] repeating timer #2 every 1.00 s (stops after 5) +--- conduit_timers --- +timers: 2 active + #1 owner=timer_test once next in 1938 ms + #2 owner=timer_test repeat next in 969 ms +``` + +Log block: + +```text +2026-06-15 10:51:55 [INFO] timer_test: repeat timer tick 1 +2026-06-15 10:51:56 [INFO] timer_test: one-shot timer fired +2026-06-15 10:51:56 [INFO] timer_test: repeat timer tick 2 +2026-06-15 10:51:57 [INFO] timer_test: repeat timer tick 3 +2026-06-15 10:51:58 [INFO] timer_test: repeat timer tick 4 +2026-06-15 10:51:59 [INFO] timer_test: repeat timer tick 5 +2026-06-15 10:51:59 [INFO] timer_test: repeat timer self-cancelled after 5 ticks +``` + +After firing: + +```text +--- conduit_timers --- +timers: 0 active +--- conduit_prof --- +timer_test timer 6 71.5us 131.1us 131.1us 101.4us 429.0us +``` + +Group cancel: + +```text +--- conduit_timer_every 1 --- +[Conduit] repeating timer #3 every 1.00 s (stops after 5) +--- conduit_timer_after 30 --- +[Conduit] one-shot timer #4 scheduled in 30.00 s +--- conduit_timers --- +timers: 2 active + #3 owner=timer_test repeat next in 937 ms + #4 owner=timer_test once next in 29969 ms +--- conduit_timer_stop --- +[Conduit] cancelled 2 test timer(s) +--- conduit_timers --- +timers: 0 active +``` + +Result: timers passed, including self-cancel from inside callback and owner group cancel. + +## Chat Interception Tests + +RCON output: + +```text +--- conduit_chat --- +chat: 0 subscriber(s), interception hook off +--- conduit_chat_listen --- +[Conduit] [INFO] chat: 'chat_test' subscribed to chat +[Conduit] listening to chat +--- conduit_chat --- +chat: 1 subscriber(s), interception hook on + owner=chat_test hits=0 +--- say "hello from server" --- +[Conduit] [INFO] chat_test: say slot=-1: "hello from server" +[All Chat][Console (0)]: "hello from server" +L 06/15/2026 - 10:53:03: "Console<0>" say ""hello from server"" +--- say "this has badword inside" --- +[Conduit] [INFO] chat_test: say slot=-1: "this has badword inside" [SUPPRESSED] +--- say_team "x" --- +[Conduit] [INFO] chat_test: say_team slot=-1: "x" +--- conduit_prof --- +chat_test chat 3 97.0us 131.1us 131.1us 127.4us 290.9us +``` + +Conduit log: + +```text +2026-06-15 10:53:03 [INFO] chat: 'chat_test' subscribed to chat +2026-06-15 10:53:03 [INFO] chat_test: say slot=-1: "hello from server" +2026-06-15 10:53:03 [INFO] chat_test: say slot=-1: "this has badword inside" [SUPPRESSED] +2026-06-15 10:53:03 [INFO] chat_test: say_team slot=-1: "x" +``` + +The suppressed `badword` message did not echo as `[All Chat]` in the RCON output. + +Cleanup: + +```text +--- conduit_chat_stop --- +[Conduit] removed 1 chat subscriber(s) +--- conduit_chat --- +chat: 0 subscriber(s), interception hook off +--- say "after stop visible" --- +[All Chat][Console (0)]: "after stop visible" +L 06/15/2026 - 10:53:20: "Console<0>" say ""after stop visible"" +--- conduit_chat --- +chat: 0 subscriber(s), interception hook off +``` + +Result: chat interception, suppression, say_team distinction, profiler scope, and hook removal all passed. + +## Chat Sending / UserMessages + +Commands: + +```text +--- conduit_usermsg --- +usermessages: chat sending live (SayText2 resolved, SayText resolved) +--- conduit_say linux broadcast test --- +[Conduit] PrintToAll sent +--- conduit_say_player 0 private_ping --- +[Conduit] PrintToPlayer(0) sent +--- conduit_usermsg --- +usermessages: chat sending live (SayText2 resolved, SayText resolved) +``` + +Result: Linux signatures resolve and native send calls return `sent`. This verifies the server-side path and no crash. I cannot visually confirm client rendering from this environment; per latest plan this path sends plain white text through `UTIL_SayTextFilter`. + +## Console Adopt Fix + +This re-tested the previously observed adopted-convar listing issue. + +```text +--- conduit_console --- +[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo') +[Conduit] [INFO] console: convar 'conduit_demo_value' created (owner 'console_demo') +console: ready, 1 command(s), 1 plugin convar(s) + cmd conduit_demo owner=console_demo + cvar conduit_demo_value owner=console_demo value=7 +--- conduit_cvar conduit_demo_value 42 --- +[Conduit] set 'conduit_demo_value' = '42' +[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1) +--- conduit_concmd_stop --- +[Conduit] removed 2 demo registration(s) +--- conduit_console --- +[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo') +[Conduit] [INFO] console: convar 'conduit_demo_value' already registered - adopting (owner 'console_demo') +console: ready, 1 command(s), 1 plugin convar(s) + cmd conduit_demo owner=console_demo + cvar conduit_demo_value owner=console_demo value=42 +``` + +Final cleanup: + +```text +--- conduit_concmd_stop --- +[Conduit] removed 2 demo registration(s) +--- conduit_demo x --- + +``` + +Result: adopt tracking is fixed. Adopted convar now appears under `1 plugin convar(s)` and owner cleanup removes 2 registrations. + +## Final Runtime State + +```text +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 248.6 s + game frames : 15184 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260615.log + crash report: armed (use conduit_crash_test to verify) +--- conduit_timers --- +timers: 0 active +--- conduit_chat --- +chat: 0 subscriber(s), interception hook off +--- conduit_events --- +game events: manager connected, 0 subscription(s), 0 event(s) registered, FireEvent detour off +--- conduit_usermsg --- +usermessages: chat sending live (SayText2 resolved, SayText resolved) +``` + +Final profiler: + +```text +engine GameFrame 13848 2.77ms 4.19ms 8.39ms 39.34ms 38.34s +timer_test timer 6 71.5us 131.1us 131.1us 101.4us 429.0us +chat_test chat 3 97.0us 131.1us 131.1us 127.4us 290.9us +core SchemaFindField 3 13.2us 8.2us 32.8us 31.7us 39.5us +``` + +## Final Server State + +Docker: + +```text +/gp_c2b47a90 running running=true exit=0 oom=false +/gp_d8f13411 exited running=false exit=130 oom=false +``` + +Panel DB: + +```text + uuid | name | status | slug | port +----------+----------------+---------+--------------+------- + c2b47a90 | Test Sunucusu | running | cs2 | 27015 + d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777 +(2 rows) +``` + +## Notes + +- No Conduit/Metamod/signal-specific load failure or crash was observed. +- Server console contained ordinary CS2/Steam/map warnings and warmup long-frame messages. +- No watchdog stall block appeared during this run. +- CS2 panel status was `stopped` after Docker restart; I updated only the CS2 row back to `running`. Satisfactory remained `stopped`. + diff --git a/conduit-bringup-artifacts/linux-bringup-report-20260613.md b/conduit-bringup-artifacts/linux-bringup-report-20260613.md new file mode 100644 index 0000000..91faeb3 --- /dev/null +++ b/conduit-bringup-artifacts/linux-bringup-report-20260613.md @@ -0,0 +1,334 @@ +# Conduit Linux Bring-up Report - 2026-06-13 + +Target server: +- CS2 container: `gp_c2b47a90` +- CS2 server UUID: `c2b47a90` +- CS2 name: `Test Sunucusu` +- Satisfactory container: `gp_d8f13411` (left untouched) + +Artifacts copied next to this report: +- `conduit-20260613.log` +- `conduit-crash-20260613-113458.txt` + +Final service state after crash test recovery: + +```text +/gp_c2b47a90 running running=true exit=0 oom=false +/gp_d8f13411 exited running=false exit=130 oom=false +``` + +Panel DB state after recovery: + +```text + uuid | name | status | slug | port +----------+----------------+---------+--------------+------- + c2b47a90 | Test Sunucusu | running | cs2 | 27015 + d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777 +(2 rows) +``` + +## 1. Build Result + +Final build succeeded using GCC 12 in `build/linux-gcc12`. + +Final link output: + +```text +[27/27] Linking CXX shared library package/addons/conduit/bin/conduit.so +``` + +Build/deploy issues encountered and fixed: +- Ubuntu `clang++` was 14.0.0 and rejected `-std=c++23`. +- `hl2sdk` headers needed POSIX aliases for `stricmp`/`strcmpi` during this Linux build. +- `crash_handler_linux.cpp` needed `cstdarg` visible for `va_start`/`va_end`; handled via build flag. +- `watchdog.cpp` had a Linux compile error because glibc `SIGRTMIN` is runtime, not constexpr. Local source change: + +```diff +-constexpr int kSampleSignal = SIGRTMIN + 4; ++const int kSampleSignal = SIGRTMIN + 4; +``` + +- First deployed build failed to load in Metamod with protobuf ABI mismatch: + +```text +[META] Failed to load plugin addons/conduit/bin/conduit: /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so: undefined symbol: _ZNK6google8protobuf7Message11GetTypeNameB5cxx11Ev +``` + +Rebuilt with `_GLIBCXX_USE_CXX11_ABI=0`; Conduit then loaded successfully. + +Final configure command used: + +```sh +cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-gcc12 -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_C_COMPILER=gcc-12 \ + -DCMAKE_CXX_COMPILER=g++-12 \ + "-DCMAKE_CXX_FLAGS=-include strings.h -include cstdarg -Dstricmp=strcasecmp -Dstrcmpi=strcasecmp -D_GLIBCXX_USE_CXX11_ABI=0" +``` + +## 2. meta list + +```text +rcon: authenticated +--- meta list --- +Listing 1 plugin: + [01] Conduit (0.1.0-dev) by Conduit Contributors +``` + +## 3. conduit_status + conduit_prof + +```text +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 3384.6 s + game frames : 215969 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260613.log + crash report: armed (use conduit_crash_test to verify) +--- conduit_prof --- +owner callsite calls avg p50 p99 max total +engine GameFrame 215971 2.36ms 4.19ms 4.19ms 34.46ms 510.40s +stall_test_plugin conduit_stall_test 1 1.50s 2.15s 2.15s 1.50s 1.50s +core SchemaFindField 2 98.4us 262.1us 262.1us 164.3us 196.7us +``` + +After the deliberate crash test and restart, Conduit loaded again: + +```text +rcon: authenticated +--- meta list --- +Listing 1 plugin: + [01] Conduit (0.1.0-dev) by Conduit Contributors +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 130.2 s + game frames : 7760 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260613.log + crash report: armed (use conduit_crash_test to verify) +``` + +## 4. conduit_schema + +```text +--- conduit_schema CCSPlayerPawn m_iHealth --- +CCSPlayerPawn::m_iHealth -> offset 0x5B0 (1456), type int32 +--- conduit_schema CBaseEntity m_iTeamNum --- +CBaseEntity::m_iTeamNum -> offset 0x624 (1572), type uint8 +``` + +Comparison with Windows reference from the bring-up doc: +- `CCSPlayerPawn::m_iHealth`: Linux `0x5B0`, Windows expected `0x2D0` -> mismatch. +- `CBaseEntity::m_iTeamNum`: Linux `0x624`, Windows expected `0x344` -> mismatch. + +## 5. conduit_stall_test 1500 Watchdog Block + +RCON command: + +```text +rcon: authenticated +--- conduit_stall_test 1500 --- +[Conduit] stalling the game thread for 1500 ms... +``` + +Conduit log block: + +```text +2026-06-13 10:35:31 [WARN] watchdog: game thread has not advanced a frame for 591 ms (outside GameFrame) +2026-06-13 10:35:31 [WARN] watchdog: context 1: owner=stall_test_plugin callsite=conduit_stall_test +2026-06-13 10:35:31 [WARN] watchdog: >>> most likely culprit: 'stall_test_plugin' (in 'conduit_stall_test') +2026-06-13 10:35:31 [WARN] watchdog: #00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xc95c9) [0x7fdf9e69e5c9] +2026-06-13 10:35:31 [WARN] watchdog: #01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050] +2026-06-13 10:35:31 [WARN] watchdog: #02 /lib/x86_64-linux-gnu/libc.so.6(clock_nanosleep+0x65) [0x7fdfefae4545] +2026-06-13 10:35:31 [WARN] watchdog: #03 /lib/x86_64-linux-gnu/libc.so.6(nanosleep+0x13) [0x7fdfefae8e53] +2026-06-13 10:35:31 [WARN] watchdog: #04 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf703) [0x7fdf9e694703] +2026-06-13 10:35:31 [WARN] watchdog: #05 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902] +2026-06-13 10:35:32 [INFO] watchdog: game thread recovered after a ~1586 ms stall +``` + +Notes: +- Attribution worked. +- Stack sample landed. +- Symbol quality is partial: `libconduit.so(+0x...)` offsets are present, but not function names such as `conduit_stall_test`. + +## 6. conduit_crash_test Report + +RCON command result: + +```text +rcon: authenticated +--- conduit_crash_test --- +(no response: connection closed) +``` + +Generated file: + +```text +/var/lib/gamepanel/servers/c2b47a90/game/csgo/addons/conduit/crashes/conduit-crash-20260613-113458.txt +``` + +Crash report content: + +```text +=== CONDUIT CRASH REPORT ========================================== +Framework : Conduit 0.1.0-dev +Time : 2026-06-13 11:34:58 (local) +Process : pid=36 +Signal : 11 (Segmentation fault), fault address (nil) + +--- ATTRIBUTION --------------------------------------------------- + 1. owner=crash_test_plugin callsite=conduit_crash_test +>>> Most likely culprit: 'crash_test_plugin' (in 'conduit_crash_test') + +--- STACK TRACE --------------------------------------------------- + #00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbfb64) [0x7fdf9e694b64] + #01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050] + #02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf66c) [0x7fdf9e69466c] + #03 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902] + +--- RECENT EVENTS (oldest first) ---------------------------------- + [-1767.736s] [INFO] perf: heartbeat: 1921 frames, avg 2.22 ms, max 20.85 ms (last 30 s) + [-1737.735s] [INFO] perf: heartbeat: 1920 frames, avg 2.16 ms, max 5.31 ms (last 30 s) + [-1707.720s] [INFO] perf: heartbeat: 1921 frames, avg 2.12 ms, max 4.39 ms (last 30 s) + [-1677.719s] [INFO] perf: heartbeat: 1920 frames, avg 2.20 ms, max 5.59 ms (last 30 s) + [-1647.706s] [INFO] perf: heartbeat: 1921 frames, avg 2.39 ms, max 23.56 ms (last 30 s) + [-1617.705s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 7.46 ms (last 30 s) + [-1587.691s] [INFO] perf: heartbeat: 1921 frames, avg 2.37 ms, max 8.58 ms (last 30 s) + [-1557.680s] [INFO] perf: heartbeat: 1921 frames, avg 2.33 ms, max 4.84 ms (last 30 s) + [-1527.679s] [INFO] perf: heartbeat: 1920 frames, avg 2.25 ms, max 5.09 ms (last 30 s) + [-1497.674s] [INFO] perf: heartbeat: 1920 frames, avg 2.18 ms, max 19.43 ms (last 30 s) + [-1467.673s] [INFO] perf: heartbeat: 1920 frames, avg 2.30 ms, max 6.87 ms (last 30 s) + [-1437.669s] [INFO] perf: heartbeat: 1920 frames, avg 2.23 ms, max 34.46 ms (last 30 s) + [-1407.668s] [INFO] perf: heartbeat: 1920 frames, avg 2.45 ms, max 6.17 ms (last 30 s) + [-1377.653s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.25 ms (last 30 s) + [-1347.653s] [INFO] perf: heartbeat: 1920 frames, avg 2.53 ms, max 5.44 ms (last 30 s) + [-1317.644s] [INFO] perf: heartbeat: 1921 frames, avg 2.51 ms, max 20.77 ms (last 30 s) + [-1287.631s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.45 ms (last 30 s) + [-1257.614s] [INFO] perf: heartbeat: 1921 frames, avg 2.57 ms, max 5.90 ms (last 30 s) + [-1227.600s] [INFO] perf: heartbeat: 1921 frames, avg 2.54 ms, max 5.79 ms (last 30 s) + [-1197.583s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 6.94 ms (last 30 s) + [-1167.578s] [INFO] perf: heartbeat: 1920 frames, avg 2.46 ms, max 20.36 ms (last 30 s) + [-1137.562s] [INFO] perf: heartbeat: 1921 frames, avg 2.47 ms, max 7.86 ms (last 30 s) + [-1107.548s] [INFO] perf: heartbeat: 1921 frames, avg 2.53 ms, max 6.38 ms (last 30 s) + [-1077.547s] [INFO] perf: heartbeat: 1920 frames, avg 2.54 ms, max 4.09 ms (last 30 s) + [-1047.543s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 33.69 ms (last 30 s) + [-1017.532s] [INFO] perf: heartbeat: 1921 frames, avg 2.44 ms, max 5.97 ms (last 30 s) + [- 987.516s] [INFO] perf: heartbeat: 1921 frames, avg 2.59 ms, max 5.71 ms (last 30 s) + [- 957.501s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 7.43 ms (last 30 s) + [- 927.501s] [INFO] perf: heartbeat: 1920 frames, avg 2.60 ms, max 9.17 ms (last 30 s) + [- 897.490s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 24.84 ms (last 30 s) + [- 867.475s] [INFO] perf: heartbeat: 1921 frames, avg 2.58 ms, max 6.20 ms (last 30 s) + [- 837.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 5.57 ms (last 30 s) + [- 807.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 6.15 ms (last 30 s) + [- 777.460s] [INFO] perf: heartbeat: 1921 frames, avg 2.70 ms, max 5.47 ms (last 30 s) + [- 747.449s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 24.22 ms (last 30 s) + [- 717.434s] [INFO] perf: heartbeat: 1921 frames, avg 2.80 ms, max 8.64 ms (last 30 s) + [- 687.419s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.28 ms (last 30 s) + [- 657.418s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 6.34 ms (last 30 s) + [- 627.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 31.89 ms (last 30 s) + [- 597.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 6.72 ms (last 30 s) + [- 567.400s] [INFO] perf: heartbeat: 1921 frames, avg 2.67 ms, max 6.53 ms (last 30 s) + [- 537.384s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.66 ms (last 30 s) + [- 507.369s] [INFO] perf: heartbeat: 1921 frames, avg 2.55 ms, max 5.30 ms (last 30 s) + [- 477.365s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 34.05 ms (last 30 s) + [- 447.364s] [INFO] perf: heartbeat: 1920 frames, avg 2.59 ms, max 5.42 ms (last 30 s) + [- 417.349s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.55 ms (last 30 s) + [- 387.334s] [INFO] perf: heartbeat: 1921 frames, avg 2.34 ms, max 4.63 ms (last 30 s) + [- 357.334s] [INFO] perf: heartbeat: 1920 frames, avg 2.77 ms, max 6.65 ms (last 30 s) + [- 327.332s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 26.14 ms (last 30 s) + [- 312.170s] detour 'detour_self_test' installed at 0x7fdf9e693cd0 + [- 312.170s] [INFO] hooks: detour 'detour_self_test' installed at 0x7fdf9e693cd0 + [- 312.169s] detour 'detour_self_test' removed + [- 312.169s] [INFO] hooks: detour 'detour_self_test' removed + [- 297.318s] [INFO] perf: heartbeat: 1921 frames, avg 2.73 ms, max 6.96 ms (last 30 s) + [- 267.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.58 ms, max 6.68 ms (last 30 s) + [- 237.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 7.08 ms (last 30 s) + [- 207.296s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 25.68 ms (last 30 s) + [- 177.293s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 5.12 ms (last 30 s) + [- 147.280s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.80 ms (last 30 s) + [- 117.280s] [INFO] perf: heartbeat: 1920 frames, avg 2.64 ms, max 6.24 ms (last 30 s) + [- 87.265s] [INFO] perf: heartbeat: 1921 frames, avg 2.78 ms, max 5.95 ms (last 30 s) + [- 57.263s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 31.12 ms (last 30 s) + [- 27.248s] [INFO] perf: heartbeat: 1921 frames, avg 2.72 ms, max 7.34 ms (last 30 s) + [- 0.001s] [WARN] core: conduit_crash_test invoked — crashing deliberately +=================================================================== +``` + +## 7. Conduit Log File + +Original log file: + +```text +/var/lib/gamepanel/servers/c2b47a90/game/csgo/addons/conduit/logs/conduit-20260613.log +``` + +Copied artifact: + +```text +/root/codex/source-gamepanel/conduit-bringup-artifacts/conduit-20260613.log +``` + +Relevant load lines: + +```text +2026-06-13 10:33:21 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid +2026-06-13 10:33:21 [INFO] core: Conduit 0.1.0-dev loaded — crash reporter armed, profiler on +2026-06-13 10:33:29 [INFO] core: first GameFrame observed — hook dispatch confirmed +``` + +## 8. Console / Metamod / Signal Warnings + +Important console findings: + +1. Before the ABI rebuild, Metamod failed to load Conduit: + +```text +[META] Failed to load plugin addons/conduit/bin/conduit: /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so: undefined symbol: _ZNK6google8protobuf7Message11GetTypeNameB5cxx11Ev +[META] Loaded 0 plugins. +``` + +This was fixed by rebuilding with `_GLIBCXX_USE_CXX11_ABI=0`. + +2. After the successful build, no current Metamod load failure was observed. `meta list` reports Conduit loaded. + +3. Signal/watchdog behavior: +- No signal clash symptoms observed. +- No `(stack sample timed out)` observed. +- Stack sample appeared, but symbol names are mostly raw offsets for `libconduit.so`. + +4. Crash test console tail showed the expected deliberate crash: + +```text +post-hook: noop +entry.sh: line 192: 36 Segmentation fault (core dumped) ./cs2 -dedicated -ip 0.0.0.0 -port 27015 -console -usercon -maxplayers 16 +game_type 0 +game_mode 1 +mapgroup mg_active +map de_dust2 +rcon_password changeme +sv_lan 0 +tv_port 27020 -insecure +``` + +Other engine/game warnings seen, not clearly Conduit-specific: + +```text +Failed loading resource "maps/prefabs/misc/terrorist_team_intro_variant2/world_visibility.vvis_c" (ERROR_FILEOPEN: File not found) +Failed to write backup_round15.txt! +UNEXPECTED LONG FRAME DETECTED +``` + +## Additional Findings + +`conduit_dumpbytes` found `server!CreateInterface`: + +```text +[Conduit] server!CreateInterface @ 0x7fdfaa682220 + 55 48 89 E5 41 55 49 89 F5 41 54 53 48 83 EC 08 +``` + +But scanning the same pattern failed: + +```text +[Conduit] no match in server (285312 bytes scanned) +``` + +This looks like a Linux memscan bug or module range issue: export lookup can locate `CreateInterface`, but `ScanPattern` does not find the bytes at that address. + +`conduit_detour_test` passed: + +```text +[Conduit] detour test: PASS (before=42 during=43 after=42; expected 42/43/42) +``` + diff --git a/conduit-bringup-artifacts/linux-console-rerun-20260614.md b/conduit-bringup-artifacts/linux-console-rerun-20260614.md new file mode 100644 index 0000000..5955278 --- /dev/null +++ b/conduit-bringup-artifacts/linux-console-rerun-20260614.md @@ -0,0 +1,298 @@ +# Conduit Linux Console/ConVar Re-run - 2026-06-14 + +## Scope + +- Pulled latest Conduit commits from `origin/main`. +- Inspected the dynamic console command / convar diff and `docs/linux-bringup.md` test steps. +- Rebuilt on Linux with GCC 12. +- Deployed to CS2 server `Test Sunucusu`. +- Verified dynamic command registration, dynamic command dispatch under profiler scope, plugin convar read/write, engine convar read/write, owner cleanup, and final server state. +- Satisfactory server was not modified. + +## Git + +Latest commits: + +```text +bb58461 (HEAD -> main, origin/main, origin/HEAD) Phase 2: dynamic console commands + convar access +a3bd08f Close out Linux verification of the Entity + Player slice +de4bda2 Phase 2: schema-backed Entity + Player access, with event bridge +4b79d33 Close out Linux verification of the Phase 2 GameEvents slice +a5c1c13 docs: Linux re-test steps for the Phase 2 GameEvents slice +ffb57f3 Phase 2 start: dispatch core + GameEvents, with an RTTI vtable finder +``` + +Pulled range: + +```text +Updating de4bda2..bb58461 +Fast-forward + PLAN.md | 4 +- + core/CMakeLists.txt | 1 + + core/src/conduit/commands.cpp | 52 ++++++++ + core/src/conduit/console.cpp | 302 ++++++++++++++++++++++++++++++++++++++++++ + core/src/conduit/console.h | 78 +++++++++++ + core/src/plugin.cpp | 5 + + docs/linux-bringup.md | 58 ++++++-- + 7 files changed, 491 insertions(+), 9 deletions(-) + create mode 100644 core/src/conduit/console.cpp + create mode 100644 core/src/conduit/console.h +``` + +Working tree after the run: + +```text +## main...origin/main +``` + +## Build + +Command: + +```sh +cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_C_COMPILER=gcc-12 \ + -DCMAKE_CXX_COMPILER=g++-12 +cmake --build /root/codex/Conduit/build/linux-rerun +``` + +Result: build and link succeeded. + +```text +-- Configuring done +-- Generating done +-- Build files have been written to: /root/codex/Conduit/build/linux-rerun +[1/4] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o +[2/4] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/console.cpp.o +[3/4] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o +[4/4] Linking CXX shared library package/addons/conduit/bin/conduit.so +``` + +Only warning observed: the existing volatile compound-assignment warning in `commands.cpp`. + +No failed CMake/Ninja output exists because the build passed. The Linux link path for `CConVar` / `ConVarRefAbstract` symbols passed. + +## Deploy + +Package source: + +```text +/root/codex/Conduit/build/linux-rerun/package/. +``` + +Destination: + +```text +/var/lib/gamepanel/servers/c2b47a90/game/csgo/ +``` + +CS2 container restarted: + +```text +gp_c2b47a90 +``` + +Satisfactory was not touched: + +```text +/gp_d8f13411 exited running=false exit=130 oom=false +``` + +## Load Check + +RCON: + +```text +rcon: authenticated +--- meta list --- +Listing 1 plugin: + [01] Conduit (0.1.0-dev) by Conduit Contributors +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 47.4 s + game frames : 2251 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log + crash report: armed (use conduit_crash_test to verify) +``` + +Startup log: + +```text +2026-06-14 14:19:24 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid +2026-06-14 14:19:25 [INFO] entity: armed via CGameEntitySystem vtable 0x7f4c8640ffd8 - entity system not created yet, will resolve on first use after map load +2026-06-14 14:19:25 [INFO] gameevents: armed via CGameEventManager vtable 0x7f4c86410f50 - instance captured on next LoadEventsFromFile +2026-06-14 14:19:25 [INFO] core: Conduit 0.1.0-dev loaded - crash reporter armed, profiler on +2026-06-14 14:19:25 [INFO] gameevents: game event manager connected (0x7f4c86790460) - event listening live +2026-06-14 14:19:36 [INFO] core: first GameFrame observed - hook dispatch confirmed +``` + +Result: plugin loaded, frames advanced, no Conduit/Metamod load failure. + +## Dynamic Command and Profiler + +Commands: + +```text +conduit_console +conduit_demo alpha bravo charlie +conduit_prof +``` + +Output: + +```text +rcon: authenticated +--- conduit_console --- +[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo') +[Conduit] [INFO] console: convar 'conduit_demo_value' created (owner 'console_demo') +console: ready, 1 command(s), 1 plugin convar(s) + cmd conduit_demo owner=console_demo + cvar conduit_demo_value owner=console_demo value=7 +--- conduit_demo alpha bravo charlie --- +[Conduit] [INFO] console_demo: conduit_demo invoked: argc=4 args='alpha bravo charlie' issuer=server(-1) +--- conduit_prof --- +owner callsite calls avg p50 p99 max total +engine GameFrame 3409 2.53ms 4.19ms 8.39ms 47.48ms 8.62s +console_demo conduit_demo 1 63.1us 65.5us 65.5us 63.1us 63.1us +``` + +Result: dynamic command dispatch works and is profiled under `console_demo / conduit_demo`. + +## ConVar Tests + +Commands: + +```text +conduit_cvar conduit_demo_value +conduit_cvar conduit_demo_value 42 +conduit_cvar conduit_demo_value +conduit_cvar sv_cheats +conduit_cvar sv_gravity +conduit_cvar sv_gravity 700 +conduit_cvar sv_gravity +``` + +Output: + +```text +rcon: authenticated +--- conduit_cvar conduit_demo_value --- +[Conduit] conduit_demo_value = 7 (int=7 float=7.000 bool=1) +--- conduit_cvar conduit_demo_value 42 --- +[Conduit] set 'conduit_demo_value' = '42' +[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1) +--- conduit_cvar conduit_demo_value --- +[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1) +--- conduit_cvar sv_cheats --- +[Conduit] sv_cheats = false (int=0 float=0.000 bool=0) +--- conduit_cvar sv_gravity --- +[Conduit] sv_gravity = 800.000000 (int=800 float=800.000 bool=1) +--- conduit_cvar sv_gravity 700 --- +L 06/14/2026 - 14:20:43: server_cvar: "sv_gravity" "700.000000" +[Conduit] set 'sv_gravity' = '700' +[Conduit] sv_gravity = 700.000000 (int=700 float=700.000 bool=1) +--- conduit_cvar sv_gravity --- +[Conduit] sv_gravity = 700.000000 (int=700 float=700.000 bool=1) +``` + +Result: plugin-owned convar read/write passed. Engine convar read/write passed. + +`sv_gravity` was restored: + +```text +rcon: authenticated +--- conduit_cvar sv_gravity 800 --- +L 06/14/2026 - 14:20:56: server_cvar: "sv_gravity" "800.000000" +[Conduit] set 'sv_gravity' = '800' +[Conduit] sv_gravity = 800.000000 (int=800 float=800.000 bool=1) +--- conduit_cvar sv_gravity --- +[Conduit] sv_gravity = 800.000000 (int=800 float=800.000 bool=1) +``` + +## Owner Cleanup + +Commands: + +```text +conduit_concmd_stop +conduit_demo x +``` + +Output: + +```text +--- conduit_concmd_stop --- +[Conduit] removed 2 demo registration(s) +--- conduit_demo x --- + +``` + +Result: `conduit_demo x` produced no handler line after cleanup, so the command was removed from the engine. + +I then called `conduit_console` again to probe the persistence/adopt path: + +```text +--- conduit_console --- +[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo') +[Conduit] [INFO] console: convar 'conduit_demo_value' already registered - adopting (owner 'console_demo') +console: ready, 1 command(s), 0 plugin convar(s) + cmd conduit_demo owner=console_demo +``` + +The persisted convar value remained available: + +```text +--- conduit_cvar conduit_demo_value --- +[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1) +``` + +Final cleanup after the re-register: + +```text +rcon: authenticated +--- conduit_cvar conduit_demo_value --- +[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1) +--- conduit_concmd_stop --- +[Conduit] removed 1 demo registration(s) +--- conduit_demo x --- + +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 115.6 s + game frames : 6615 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log + crash report: armed (use conduit_crash_test to verify) +--- conduit_prof --- +owner callsite calls avg p50 p99 max total +engine GameFrame 6617 2.34ms 4.19ms 8.39ms 47.48ms 15.50s +console_demo conduit_demo 1 63.1us 65.5us 65.5us 63.1us 63.1us +``` + +## Finding + +The adopt path works for name-based access: `conduit_demo_value` stayed at `42` and could be read after re-registration. + +Potential mismatch: after adoption, `conduit_console` reported `0 plugin convar(s)` and did not list `conduit_demo_value`, despite logging `already registered - adopting`. That means the existing convar is not represented in `g_convars`, so owner cleanup removed only the command on the second cleanup (`removed 1 demo registration(s)`). If the intended behavior is "adopted convars are tracked and listed as plugin-owned", this needs a small fix. If "adopt" only means "treat existing engine convar as accessible by name", the runtime behavior is consistent. + +## Final State + +Docker: + +```text +/gp_c2b47a90 running running=true exit=0 oom=false +/gp_d8f13411 exited running=false exit=130 oom=false +``` + +Panel DB: + +```text + uuid | name | status | slug | port +----------+----------------+---------+--------------+------- + c2b47a90 | Test Sunucusu | running | cs2 | 27015 + d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777 +(2 rows) +``` + +No Conduit/Metamod/signal-specific load failure or crash was observed. Server console had ordinary CS2/Steam/map warnings only. + diff --git a/conduit-bringup-artifacts/linux-entity-rerun-20260614.md b/conduit-bringup-artifacts/linux-entity-rerun-20260614.md new file mode 100644 index 0000000..72a4e68 --- /dev/null +++ b/conduit-bringup-artifacts/linux-entity-rerun-20260614.md @@ -0,0 +1,287 @@ +# Conduit Linux Entity Re-run - 2026-06-14 + +## Scope + +- Pulled latest Conduit commits from `origin/main`. +- Inspected the Phase 2 entity/event bridge diff and updated `docs/linux-bringup.md` steps. +- Rebuilt on Linux with GCC 12. +- Deployed to CS2 server `Test Sunucusu`. +- Verified normal startup arming, lazy entity-system resolution, Linux RTTI vtable lookup, event-to-entity bridge, schema-backed health read/write, cleanup, and final server state. +- Satisfactory server was not modified. + +## Git + +Latest commits: + +```text +de4bda2 (HEAD -> main, origin/main, origin/HEAD) Phase 2: schema-backed Entity + Player access, with event bridge +4b79d33 Close out Linux verification of the Phase 2 GameEvents slice +a5c1c13 docs: Linux re-test steps for the Phase 2 GameEvents slice +ffb57f3 Phase 2 start: dispatch core + GameEvents, with an RTTI vtable finder +4078ab7 PLAN: close the Linux memscan question — M1 fully done on both platforms +``` + +Pulled range: + +```text +Updating a5c1c13..de4bda2 +Fast-forward + PLAN.md | 4 +- + core/CMakeLists.txt | 1 + + core/src/conduit/commands.cpp | 61 ++++++- + core/src/conduit/entity.cpp | 380 ++++++++++++++++++++++++++++++++++++++++ + core/src/conduit/entity.h | 89 ++++++++++ + core/src/conduit/gameevents.cpp | 9 + + core/src/conduit/gameevents.h | 9 + + core/src/conduit/memscan.cpp | 52 ++++++ + core/src/conduit/memscan.h | 6 + + core/src/plugin.cpp | 5 + + docs/linux-bringup.md | 53 +++++- +``` + +Working tree after run: + +```text +## main...origin/main +``` + +## Build + +Command: + +```sh +cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_C_COMPILER=gcc-12 \ + -DCMAKE_CXX_COMPILER=g++-12 +cmake --build /root/codex/Conduit/build/linux-rerun +``` + +Result: build succeeded. + +```text +-- Configuring done +-- Generating done +-- Build files have been written to: /root/codex/Conduit/build/linux-rerun +[1/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/memscan.cpp.o +[2/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o +/root/codex/Conduit/core/src/conduit/commands.cpp: In function ‘int {anonymous}::DetourTestTarget(int)’: +/root/codex/Conduit/core/src/conduit/commands.cpp:338:13: warning: compound assignment with ‘volatile’-qualified left operand is deprecated [-Wvolatile] + 338 | acc += x; + | ~~~~^~~~ +[3/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/gamedata.cpp.o +[4/7] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o +[5/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/entity.cpp.o +[6/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/gameevents.cpp.o +[7/7] Linking CXX shared library package/addons/conduit/bin/conduit.so +``` + +No failed CMake/Ninja output exists because the build passed. + +## Deploy + +Package source: + +```text +/root/codex/Conduit/build/linux-rerun/package/. +``` + +Destination: + +```text +/var/lib/gamepanel/servers/c2b47a90/game/csgo/ +``` + +CS2 container restarted: + +```text +gp_c2b47a90 +``` + +Satisfactory container state during deploy: + +```text +/gp_d8f13411 exited running=false exit=130 oom=false +``` + +## Load Check + +RCON: + +```text +rcon: authenticated +--- meta list --- +Listing 1 plugin: + [01] Conduit (0.1.0-dev) by Conduit Contributors +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 45.8 s + game frames : 2220 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log + crash report: armed (use conduit_crash_test to verify) +``` + +Startup log: + +```text +2026-06-14 13:48:50 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid +2026-06-14 13:48:50 [INFO] entity: armed via CGameEntitySystem vtable 0x7f2eed759fd8 — entity system not created yet, will resolve on first use after map load +2026-06-14 13:48:51 [INFO] gameevents: armed via CGameEventManager vtable 0x7f2eed75af50 — instance captured on next LoadEventsFromFile +2026-06-14 13:48:51 [INFO] core: Conduit 0.1.0-dev loaded — crash reporter armed, profiler on +2026-06-14 13:48:51 [INFO] gameevents: game event manager connected (0x7f2eedada460) — event listening live +2026-06-14 13:49:01 [INFO] core: first GameFrame observed — hook dispatch confirmed +``` + +Result: normal startup did not crash. Entity acquisition armed at load and deferred resolution until map/entity system was available. + +## Entity RTTI and Lazy Resolve + +RCON: + +```text +rcon: authenticated +--- conduit_vtable server CGameEntitySystem --- +[Conduit] CGameEntitySystem vtable @ 0x7f2eed759fd8 first method -> 0x7f2eec92aec0 +--- conduit_entity --- +[Conduit] [INFO] entity: entity system connected (0x7f2ee9124000) via GameResourceServiceServerV001+0x50 — vtable matches CGameEntitySystem +entity system: connected + slot 0 cs_player_controller team=2 pawn=player health=100 + slot 1 cs_player_controller team=3 pawn=player health=100 +``` + +Result: Linux Itanium RTTI path found `CGameEntitySystem`. Lazy resource-service scan found the entity system at `GameResourceServiceServerV001+0x50`. This differs from Windows `+0x58`, as expected, and was found by vtable matching. + +## Event to Entity Bridge + +Commands: + +```text +conduit_event_stop +conduit_event_listen player_spawn +mp_warmup_end +mp_restartgame 1 +``` + +RCON / console-reported Conduit lines: + +```text +[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre) +[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post) +[Conduit] listening to 'player_spawn' (pre+post) — watch the log +[Conduit] [INFO] event_test: [pre] player_spawn userid=0 pawn=player idx=256 health=100 team=2 +[Conduit] [INFO] event_test: [post] player_spawn userid=0 pawn=player idx=256 health=100 team=2 +[Conduit] [INFO] event_test: [pre] player_spawn userid=1 pawn=player idx=260 health=100 team=3 +[Conduit] [INFO] event_test: [post] player_spawn userid=1 pawn=player idx=260 health=100 team=3 +``` + +Conduit log: + +```text +2026-06-14 13:50:11 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre) +2026-06-14 13:50:11 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post) +2026-06-14 13:50:11 [INFO] event_test: [pre] player_spawn userid=0 pawn=player idx=256 health=100 team=2 +2026-06-14 13:50:11 [INFO] event_test: [post] player_spawn userid=0 pawn=player idx=256 health=100 team=2 +2026-06-14 13:50:11 [INFO] event_test: [pre] player_spawn userid=1 pawn=player idx=260 health=100 team=3 +2026-06-14 13:50:11 [INFO] event_test: [post] player_spawn userid=1 pawn=player idx=260 health=100 team=3 +2026-06-14 13:50:12 [INFO] event_test: [pre] player_spawn userid=0 pawn=player idx=256 health=100 team=2 +2026-06-14 13:50:12 [INFO] event_test: [post] player_spawn userid=0 pawn=player idx=256 health=100 team=2 +2026-06-14 13:50:12 [INFO] event_test: [pre] player_spawn userid=1 pawn=player idx=260 health=100 team=3 +2026-06-14 13:50:12 [INFO] event_test: [post] player_spawn userid=1 pawn=player idx=260 health=100 team=3 +``` + +Result: event carried player reference resolved to live pawn entities in both pre and post phases. Schema-backed reads returned health/team. + +## Health Read / Write + +RCON: + +```text +rcon: authenticated +--- conduit_ent_health 0 --- +[Conduit] slot 0 pawn=player health=100 team=2 +--- conduit_ent_health 0 45 --- +[Conduit] slot 0 health 100 -> 45 (read back 45; networked write) +--- conduit_ent_health 0 --- +[Conduit] slot 0 pawn=player health=45 team=2 +``` + +Cleanup / restore: + +```text +rcon: authenticated +--- conduit_ent_health 0 100 --- +[Conduit] slot 0 health 45 -> 100 (read back 100; networked write) +--- conduit_ent_health 0 --- +[Conduit] slot 0 pawn=player health=100 team=2 +--- conduit_event_stop --- +[Conduit] removed 2 test subscription(s) +--- conduit_events --- +game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off +--- conduit_prof --- +owner callsite calls avg p50 p99 max total +engine GameFrame 6851 2.44ms 4.19ms 8.39ms 33.34ms 16.74s +event_test player_spawn 8 42.6us 65.5us 131.1us 77.7us 341.2us +core SchemaFindField 3 12.7us 8.2us 32.8us 29.3us 38.0us +``` + +Result: schema-backed setter wrote `m_iHealth`, readback returned the written value, then health was restored to 100. + +## Final State + +RCON: + +```text +rcon: authenticated +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 162.5 s + game frames : 9648 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log + crash report: armed (use conduit_crash_test to verify) +--- conduit_events --- +game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off +``` + +Docker: + +```text +/gp_c2b47a90 running running=true exit=0 oom=false +/gp_d8f13411 exited running=false exit=130 oom=false +``` + +Panel DB: + +```text + uuid | name | status | slug | port +----------+----------------+---------+--------------+------- + c2b47a90 | Test Sunucusu | running | cs2 | 27015 + d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777 +(2 rows) +``` + +## Odd Warnings / Notes + +No Conduit/Metamod/signal load failure or crash was observed. + +The `conduit_vtable` / `conduit_entity` window produced one watchdog sample: + +```text +2026-06-14 13:49:57 [WARN] watchdog: game thread has not advanced a frame for 521 ms (outside GameFrame) +2026-06-14 13:49:57 [WARN] watchdog: no Conduit context on the game thread — the stall is in engine/game code or an untracked path +2026-06-14 13:49:57 [WARN] watchdog: #00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcff99) [0x7f2ee4b0ef99] +2026-06-14 13:49:57 [WARN] watchdog: #01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7f2f31cc2050] +2026-06-14 13:49:57 [WARN] watchdog: #02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd020) [0x7f2ee4b0c020] +2026-06-14 13:49:57 [WARN] watchdog: #03 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd160) [0x7f2ee4b0c160] +2026-06-14 13:49:57 [WARN] watchdog: #04 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd523) [0x7f2ee4b0c523] +2026-06-14 13:49:57 [WARN] watchdog: #05 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd60f) [0x7f2ee4b0c60f] +2026-06-14 13:49:57 [WARN] watchdog: #06 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcdecd) [0x7f2ee4b0cecd] +2026-06-14 13:49:57 [WARN] watchdog: #07 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xc10d5) [0x7f2ee4b000d5] +2026-06-14 13:49:57 [WARN] watchdog: #08 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7f2f3173b902] +2026-06-14 13:49:57 [INFO] entity: entity system connected (0x7f2ee9124000) via GameResourceServiceServerV001+0x50 — vtable matches CGameEntitySystem +2026-06-14 13:49:57 [INFO] watchdog: game thread recovered after a ~816 ms stall +``` + +This looks tied to the diagnostic RTTI/entity scan command window. The server recovered and continued through later event and health tests. + +Server console also printed ordinary CS2 map/resource/round warnings such as missing lightmap resources and `Failed to write backup_round00.txt!`; I did not see those tied to Conduit/Metamod/signal failure. + diff --git a/conduit-bringup-artifacts/linux-gameevents-rerun-20260614.md b/conduit-bringup-artifacts/linux-gameevents-rerun-20260614.md new file mode 100644 index 0000000..7c7950c --- /dev/null +++ b/conduit-bringup-artifacts/linux-gameevents-rerun-20260614.md @@ -0,0 +1,307 @@ +# Conduit Linux GameEvents Re-run - 2026-06-14 + +## Scope + +- Pulled latest Conduit commits from `origin/main`. +- Inspected the new Phase 2 dispatch/GameEvents diff. +- Rebuilt clean on Linux with GCC 12. +- Deployed the resulting package to CS2 server `Test Sunucusu`. +- Verified Linux RTTI vtable lookup, GameEvent manager connection, pre/post dispatch, cancellation, profiler accounting, and cleanup. +- Did not touch the Satisfactory server files/container. + +## Git + +Latest commits after pull: + +```text +a5c1c13 (HEAD -> main, origin/main, origin/HEAD) docs: Linux re-test steps for the Phase 2 GameEvents slice +ffb57f3 Phase 2 start: dispatch core + GameEvents, with an RTTI vtable finder +4078ab7 PLAN: close the Linux memscan question - M1 fully done on both platforms +``` + +Diff summary inspected: + +```text + PLAN.md | 4 +- + core/CMakeLists.txt | 1 + + core/src/conduit/commands.cpp | 85 ++++++++++++ + core/src/conduit/gameevents.cpp | 294 ++++++++++++++++++++++++++++++++++++++++ + core/src/conduit/gameevents.h | 73 ++++++++++ + core/src/conduit/memscan.cpp | 237 ++++++++++++++++++++++++++++++-- + core/src/conduit/memscan.h | 7 + + core/src/plugin.cpp | 5 + + docs/linux-bringup.md | 24 ++++ +``` + +Working tree after the run: + +```text +## main...origin/main +``` + +## Build + +Build command used: + +```sh +cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_C_COMPILER=gcc-12 \ + -DCMAKE_CXX_COMPILER=g++-12 +cmake --build /root/codex/Conduit/build/linux-rerun +``` + +Result: build succeeded. + +Final build line: + +```text +[30/30] Linking CXX shared library package/addons/conduit/bin/conduit.so +``` + +Warnings only: + +```text +- protobuf syntax warnings +- paths.cpp snprintf truncation warnings +- commands.cpp volatile compound assignment deprecated +- log.cpp snprintf truncation warning +- C-only warning for C++ flags on Zydis.c +``` + +No full cmake/ninja failure output exists because the build did not fail. + +## Deploy + +Deployed package: + +```text +/root/codex/Conduit/build/linux-rerun/package/. +``` + +Destination: + +```text +/var/lib/gamepanel/servers/c2b47a90/game/csgo/ +``` + +Server restarted: + +```text +gp_c2b47a90 +``` + +Satisfactory server was not modified. + +## Initial Load + +`meta list` and `conduit_status` after deploy: + +```text +rcon: authenticated +--- meta list --- +Listing 1 plugin: + [01] Conduit (0.1.0-dev) by Conduit Contributors +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 88.9 s + game frames : 5066 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log + crash report: armed (use conduit_crash_test to verify) +``` + +Startup log lines: + +```text +2026-06-14 11:34:50 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid +2026-06-14 11:34:50 [INFO] gameevents: armed via CGameEventManager vtable 0x7f47c7ae2f50 — instance captured on next LoadEventsFromFile +2026-06-14 11:34:50 [INFO] core: Conduit 0.1.0-dev loaded — crash reporter armed, profiler on +2026-06-14 11:34:50 [INFO] gameevents: game event manager connected (0x7f47c7e62460) — event listening live +2026-06-14 11:35:00 [INFO] core: first GameFrame observed — hook dispatch confirmed +``` + +## RTTI Vtable Checks + +Commands: + +```text +conduit_vtable server CGameEventManager +conduit_vtable server CCSPlayerController +``` + +Output: + +```text +rcon: authenticated +--- conduit_vtable server CGameEventManager --- +[Conduit] CGameEventManager vtable @ 0x7f47c7ae2f50 first method -> 0x7f47c6d0aa80 +--- conduit_vtable server CCSPlayerController --- +[Conduit] CCSPlayerController vtable @ 0x7f47c7ace5a8 first method -> 0x7f47c6b169a0 +``` + +Result: Linux Itanium RTTI lookup passed for both classes. + +## Listen Test + +Commands: + +```text +conduit_event_stop +conduit_event_listen player_spawn +mp_restartgame 1 +``` + +RCON output: + +```text +rcon: authenticated +--- conduit_event_stop --- +[Conduit] removed 0 test subscription(s) +--- conduit_event_listen player_spawn --- +[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre) +[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post) +[Conduit] listening to 'player_spawn' (pre+post) — watch the log +--- mp_restartgame 1 --- +``` + +Log evidence: + +```text +2026-06-14 11:55:18 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre) +2026-06-14 11:55:18 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post) +2026-06-14 11:55:20 [INFO] event_test: [pre] player_spawn userid=0 attacker=-1 +2026-06-14 11:55:20 [INFO] event_test: [post] player_spawn userid=0 attacker=-1 +2026-06-14 11:55:20 [INFO] event_test: [pre] player_spawn userid=1 attacker=-1 +2026-06-14 11:55:20 [INFO] event_test: [post] player_spawn userid=1 attacker=-1 +``` + +`conduit_events` and `conduit_prof` after listen: + +```text +rcon: authenticated +--- conduit_events --- +game events: manager connected, 2 subscription(s), 1 event(s) registered, FireEvent detour on + event_test player_spawn pre hits=2 + event_test player_spawn post hits=2 +--- conduit_prof --- +owner callsite calls avg p50 p99 max total +engine GameFrame 80323 2.28ms 4.19ms 4.19ms 27.49ms 183.11s +event_test player_spawn 4 38.1us 65.5us 65.5us 54.9us 152.6us +``` + +Result: pre and post dispatch both fired. + +## Cancel Test + +Commands: + +```text +conduit_event_cancel player_spawn +mp_restartgame 1 +``` + +RCON output: + +```text +rcon: authenticated +--- conduit_event_cancel player_spawn --- +[Conduit] [INFO] gameevents: 'event_cancel_test' subscribed to 'player_spawn' (pre) +[Conduit] will cancel every 'player_spawn' until conduit_event_stop +--- mp_restartgame 1 --- +``` + +Log evidence: + +```text +2026-06-14 12:02:21 [INFO] gameevents: 'event_cancel_test' subscribed to 'player_spawn' (pre) +2026-06-14 12:02:22 [INFO] event_test: [pre] player_spawn userid=0 attacker=-1 +2026-06-14 12:02:22 [WARN] event_test: cancelling 'player_spawn' (pre) +2026-06-14 12:02:22 [INFO] event_test: [pre] player_spawn userid=1 attacker=-1 +2026-06-14 12:02:22 [WARN] event_test: cancelling 'player_spawn' (pre) +``` + +There were no new `[post] player_spawn` lines after cancellation. + +`conduit_events` and `conduit_prof` after cancel: + +```text +rcon: authenticated +--- conduit_events --- +game events: manager connected, 3 subscription(s), 1 event(s) registered, FireEvent detour on + event_test player_spawn pre hits=4 + event_test player_spawn post hits=2 + event_cancel_test player_spawn pre hits=2 +--- conduit_prof --- +owner callsite calls avg p50 p99 max total +engine GameFrame 107531 2.25ms 4.19ms 4.19ms 32.27ms 242.28s +event_test player_spawn 6 47.1us 65.5us 131.1us 76.9us 282.5us +event_cancel_test player_spawn 2 14.5us 16.4us 16.4us 14.9us 28.9us +``` + +Result: cancel path passed; post stayed at `hits=2` while pre advanced. + +## Cleanup + +Commands: + +```text +conduit_event_stop +conduit_events +``` + +Output: + +```text +rcon: authenticated +--- conduit_event_stop --- +[Conduit] removed 3 test subscription(s) +--- conduit_events --- +game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off +``` + +Final status: + +```text +rcon: authenticated +--- conduit_status --- +Conduit 0.1.0-dev + uptime : 2228.6 s + game frames : 141868 + log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log + crash report: armed (use conduit_crash_test to verify) +--- conduit_events --- +game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off +``` + +## Container and Panel State + +Docker: + +```text +/gp_c2b47a90 running running=true exit=0 oom=false +/gp_d8f13411 exited running=false exit=130 oom=false +``` + +Panel DB: + +```text + uuid | name | status | slug | port +----------+----------------+---------+--------------+------- + c2b47a90 | Test Sunucusu | running | cs2 | 27015 + d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777 +(2 rows) +``` + +## Odd Warnings + +During the RTTI/vtable/memscan command window, the log briefly reported watchdog stalls outside Conduit context: + +```text +2026-06-14 11:50:54 [WARN] watchdog: game thread has not advanced a frame for 509 ms (outside GameFrame) +2026-06-14 11:50:54 [WARN] watchdog: no Conduit context on the game thread — the stall is in engine/game code or an untracked path +2026-06-14 11:51:04 [WARN] watchdog: game thread has not advanced a frame for 585 ms (outside GameFrame) +2026-06-14 11:51:04 [WARN] watchdog: no Conduit context on the game thread — the stall is in engine/game code or an untracked path +``` + +The server recovered and continued through 141k+ frames. I did not see Conduit/Metamod/signal load failures or crashes in this run. + diff --git a/packages/database/drizzle/0007_satisfactory_game.sql b/packages/database/drizzle/0007_satisfactory_game.sql new file mode 100644 index 0000000..76f30e5 --- /dev/null +++ b/packages/database/drizzle/0007_satisfactory_game.sql @@ -0,0 +1,66 @@ +INSERT INTO "games" ( + "slug", + "name", + "docker_image", + "default_port", + "config_files", + "automation_rules", + "startup_command", + "stop_command", + "environment_vars", + "created_at", + "updated_at" +) +VALUES ( + 'satisfactory', + 'Satisfactory', + 'wolveix/satisfactory-server:latest', + 7777, + '[]'::jsonb, + '[]'::jsonb, + '', + 'quit', + '[ + { + "key": "MAXPLAYERS", + "default": "4", + "description": "Maximum player count", + "required": false + }, + { + "key": "STEAMBETA", + "label": "Branch", + "default": "false", + "description": "Use the experimental branch instead of stable", + "required": false, + "inputType": "boolean", + "enabledLabel": "Experimental", + "disabledLabel": "Stable" + }, + { + "key": "AUTOSAVENUM", + "default": "5", + "description": "Number of rotating autosaves", + "required": false + }, + { + "key": "MAXTICKRATE", + "default": "30", + "description": "Maximum simulation tick rate", + "required": false + } + ]'::jsonb, + now(), + now() +) +ON CONFLICT ("slug") DO UPDATE +SET + "name" = EXCLUDED."name", + "docker_image" = EXCLUDED."docker_image", + "default_port" = EXCLUDED."default_port", + "config_files" = EXCLUDED."config_files", + "automation_rules" = EXCLUDED."automation_rules", + "startup_command" = EXCLUDED."startup_command", + "stop_command" = EXCLUDED."stop_command", + "environment_vars" = EXCLUDED."environment_vars", + "updated_at" = now(); diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 08a086b..671a134 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1772900000000, "tag": "0006_cs2_servername_branding", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1773000000000, + "tag": "0007_satisfactory_game", + "breakpoints": true } ] } diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index 7a6d43b..06f0741 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -1,4 +1,5 @@ import { createDb } from './client'; +import { eq } from 'drizzle-orm'; import { games } from './schema/games'; import { users } from './schema/users'; @@ -292,8 +293,18 @@ async function seed() { description: 'Steam Workshop map id to launch', required: false, }, - { key: 'CS2_GAMETYPE', default: '0', description: 'Game type numeric value', required: false }, - { key: 'CS2_GAMEMODE', default: '1', description: 'Game mode numeric value', required: false }, + { + key: 'CS2_GAMETYPE', + default: '0', + description: 'Game type numeric value', + required: false, + }, + { + key: 'CS2_GAMEMODE', + default: '1', + description: 'Game mode numeric value', + required: false, + }, { key: 'CS2_ADDITIONAL_ARGS', default: '', @@ -340,7 +351,12 @@ async function seed() { ], environmentVars: [ { key: 'EULA', default: 'TRUE', description: 'Accept Minecraft EULA', required: true }, - { key: 'VERSION', default: 'LATEST', description: 'Bedrock server version', required: true }, + { + key: 'VERSION', + default: 'LATEST', + description: 'Bedrock server version', + required: true, + }, ], }, { @@ -377,15 +393,138 @@ async function seed() { stopCommand: 'quit', configFiles: [], environmentVars: [ - { key: 'RUST_SERVER_NAME', default: 'My Rust Server', description: 'Server name', required: true }, - { key: 'RUST_SERVER_MAXPLAYERS', default: '50', description: 'Max players', required: false }, - { key: 'RUST_SERVER_IDENTITY', default: 'default', description: 'Server identity', required: false }, + { + key: 'RUST_SERVER_NAME', + default: 'My Rust Server', + description: 'Server name', + required: true, + }, + { + key: 'RUST_SERVER_MAXPLAYERS', + default: '50', + description: 'Max players', + required: false, + }, + { + key: 'RUST_SERVER_IDENTITY', + default: 'default', + description: 'Server identity', + required: false, + }, { key: 'RUST_RCON_PASSWORD', default: '', description: 'RCON password', required: true }, ], }, + { + slug: 'satisfactory', + name: 'Satisfactory', + dockerImage: 'wolveix/satisfactory-server:latest', + defaultPort: 7777, + startupCommand: '', + stopCommand: 'quit', + configFiles: [], + automationRules: [], + environmentVars: [ + { + key: 'MAXPLAYERS', + default: '4', + description: 'Maximum player count', + required: false, + }, + { + key: 'STEAMBETA', + label: 'Branch', + default: 'false', + description: 'Use the experimental branch instead of stable', + required: false, + inputType: 'boolean', + enabledLabel: 'Experimental', + disabledLabel: 'Stable', + }, + { + key: 'AUTOSAVENUM', + default: '5', + description: 'Number of rotating autosaves', + required: false, + }, + { + key: 'MAXTICKRATE', + default: '30', + description: 'Maximum simulation tick rate', + required: false, + }, + ], + }, + { + slug: 'fivem', + name: 'FiveM', + dockerImage: 'spritsail/fivem:latest', + defaultPort: 30120, + startupCommand: '', + stopCommand: 'quit', + configFiles: [ + { + path: 'server.cfg', + parser: 'keyvalue', + editableKeys: [ + 'endpoint_add_tcp', + 'endpoint_add_udp', + 'sv_hostname', + 'sv_scriptHookAllowed', + 'rcon_password', + 'sv_endpointprivacy', + 'sv_maxclients', + ], + }, + ], + automationRules: [], + environmentVars: [ + { + key: 'LICENSE_KEY', + default: '', + description: 'Cfx.re server license key required to start FXServer', + required: true, + }, + ], + }, ]) .onConflictDoNothing(); + await db + .update(games) + .set({ + name: 'FiveM', + dockerImage: 'spritsail/fivem:latest', + defaultPort: 30120, + startupCommand: '', + stopCommand: 'quit', + configFiles: [ + { + path: 'server.cfg', + parser: 'keyvalue', + editableKeys: [ + 'endpoint_add_tcp', + 'endpoint_add_udp', + 'sv_hostname', + 'sv_scriptHookAllowed', + 'rcon_password', + 'sv_endpointprivacy', + 'sv_maxclients', + ], + }, + ], + automationRules: [], + environmentVars: [ + { + key: 'LICENSE_KEY', + default: '', + description: 'Cfx.re server license key required to start FXServer', + required: true, + }, + ], + updatedAt: new Date(), + }) + .where(eq(games.slug, 'fivem')); + console.log('Seed completed successfully!'); process.exit(0); } diff --git a/packages/proto/daemon.proto b/packages/proto/daemon.proto index c5358c7..0505c9c 100644 --- a/packages/proto/daemon.proto +++ b/packages/proto/daemon.proto @@ -70,6 +70,11 @@ message CreateDatabaseRequest { string password = 3; } +message ImportDatabaseSqlRequest { + string database_name = 1; + string sql = 2; +} + message UpdateDatabasePasswordRequest { string username = 1; string password = 2; @@ -252,6 +257,7 @@ service DaemonService { rpc DeleteServer(ServerIdentifier) returns (Empty); rpc ReinstallServer(ServerIdentifier) returns (Empty); rpc CreateDatabase(CreateDatabaseRequest) returns (ManagedDatabaseCredentials); + rpc ImportDatabaseSql(ImportDatabaseSqlRequest) returns (Empty); rpc UpdateDatabasePassword(UpdateDatabasePasswordRequest) returns (Empty); rpc DeleteDatabase(DeleteDatabaseRequest) returns (Empty);