import { gunzipSync } from 'node:zlib'; import type { FastifyInstance } from 'fastify'; import * as tar from 'tar-stream'; import type { Headers } from 'tar-stream'; import * as unzipper from 'unzipper'; import type { GameAutomationRule, ServerAutomationEvent, ServerAutomationAction, ServerAutomationGitHubReleaseExtractAction, ServerAutomationHttpDirectoryExtractAction, ServerAutomationInsertBeforeLineAction, ServerAutomationWriteFileAction, } from '@source/shared'; import { daemonReadFile, daemonSendCommand, daemonWriteFile, type DaemonNodeConnection, } from './daemon.js'; import { CS2_PERSISTED_SERVER_CFG_PATH, CS2_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG, } from './managed-config.js'; const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024; const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000; const AUTOMATION_MARKER_ROOT = '/.gamepanel/automation'; const CS2_GAMEINFO_PATH = '/game/csgo/gameinfo.gi'; const CS2_GAMEINFO_METAMOD_LINE = '\t\t\tGame csgo/addons/metamod'; const CS2_GAMEINFO_INSERT_BEFORE_PATTERN = '^\\s*Game\\s+csgo\\s*$'; const CS2_GAMEINFO_EXISTS_PATTERN = '^\\s*Game\\s+csgo/addons/metamod\\s*$'; const CS2_GAMEINFO_INSERT_ACTION_ID = 'ensure-cs2-metamod-gameinfo-entry'; const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction = { id: CS2_GAMEINFO_INSERT_ACTION_ID, type: 'insert_before_line', path: CS2_GAMEINFO_PATH, line: CS2_GAMEINFO_METAMOD_LINE, beforePattern: CS2_GAMEINFO_INSERT_BEFORE_PATTERN, existsPattern: CS2_GAMEINFO_EXISTS_PATTERN, skipIfExists: true, }; const DEFAULT_CS2_SERVER_CONFIG_ACTION: ServerAutomationWriteFileAction = { id: 'write-cs2-default-server-config', type: 'write_file', path: `/${CS2_SERVER_CFG_PATH}`, data: DEFAULT_CS2_SERVER_CFG, }; const DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION: ServerAutomationWriteFileAction = { id: 'write-cs2-persisted-server-config', type: 'write_file', path: `/${CS2_PERSISTED_SERVER_CFG_PATH}`, data: DEFAULT_CS2_SERVER_CFG, }; const DEFAULT_GAME_AUTOMATION_RULES: Record = { cs2: [ { id: 'cs2-write-default-server-config', event: 'server.install.completed', enabled: true, runOncePerServer: true, continueOnError: false, actions: [ { ...DEFAULT_CS2_SERVER_CONFIG_ACTION }, { ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION }, ], }, { id: 'cs2-install-latest-metamod', event: 'server.install.completed', enabled: true, runOncePerServer: true, continueOnError: false, actions: [ { id: 'install-cs2-metamod', type: 'http_directory_extract', indexUrl: 'https://mms.alliedmods.net/mmsdrop/2.0/', assetNamePattern: '^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$', destination: '/game/csgo', stripComponents: 0, maxBytes: DEFAULT_RELEASE_MAX_BYTES, }, { ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION }, ], }, { id: 'cs2-install-latest-counterstrikesharp-runtime', event: 'server.install.completed', enabled: true, runOncePerServer: true, continueOnError: false, actions: [ { id: 'install-cs2-runtime', type: 'github_release_extract', owner: 'roflmuffin', repo: 'CounterStrikeSharp', assetNamePatterns: [ '^counterstrikesharp-with-runtime-.*linux.*\\.zip$', '^counterstrikesharp-with-runtime.*\\.zip$', ], destination: '/game/csgo', stripComponents: 0, maxBytes: DEFAULT_RELEASE_MAX_BYTES, }, ], }, ], }; interface ServerAutomationContext { serverId: string; serverUuid: string; gameSlug: string; event: ServerAutomationEvent; node: DaemonNodeConnection; automationRulesRaw: unknown; force?: boolean; } export interface ServerAutomationRunResult { workflowsMatched: number; workflowsExecuted: number; workflowsSkipped: number; workflowsFailed: number; actionFailures: number; failures: ServerAutomationFailure[]; } interface ExtractedFile { path: string; data: Buffer; } export interface ServerAutomationFailure { level: 'action' | 'workflow'; workflowId: string; actionId?: string; message: string; } interface GitHubReleaseAsset { name: string; browser_download_url: string; size: number; } interface GitHubReleaseResponse { tag_name: string; assets: GitHubReleaseAsset[]; } function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function errorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } function readWorkflowId(value: unknown): string | null { if (!isObject(value)) return null; const id = value.id; if (typeof id !== 'string' || id.trim() === '') return null; return id; } function normalizeWorkflow(gameSlug: string, workflow: GameAutomationRule): GameAutomationRule { if (gameSlug.toLowerCase() !== 'cs2') return workflow; if (workflow.id === 'cs2-write-default-server-config') { return { ...workflow, actions: [ { ...DEFAULT_CS2_SERVER_CONFIG_ACTION }, { ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION }, ], }; } if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') { const normalizedActions = workflow.actions.map((action) => { if (action.type !== 'github_release_extract') return action; if (action.id !== 'install-cs2-runtime') return action; const destination = (action.destination ?? '').trim(); if (destination !== '' && destination !== '/') return action; return { ...action, destination: '/game/csgo', }; }); return { ...workflow, actions: normalizedActions, }; } if (workflow.id === 'cs2-install-latest-metamod') { const hasGameInfoAction = workflow.actions.some( (action) => action.type === 'insert_before_line' && (action.id === CS2_GAMEINFO_INSERT_ACTION_ID || action.path === CS2_GAMEINFO_PATH), ); if (hasGameInfoAction) return workflow; return { ...workflow, actions: [...workflow.actions, { ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION }], }; } return workflow; } function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] { const defaults = DEFAULT_GAME_AUTOMATION_RULES[gameSlug.toLowerCase()] ?? []; if (!Array.isArray(raw)) { return defaults.map((workflow) => normalizeWorkflow(gameSlug, workflow)); } const configured = raw as GameAutomationRule[]; if (defaults.length === 0) { return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow)); } const existingIds = new Set( raw.map(readWorkflowId).filter((workflowId): workflowId is string => workflowId !== null), ); const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id)); if (missingDefaults.length === 0) { return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow)); } return [...configured, ...missingDefaults].map((workflow) => normalizeWorkflow(gameSlug, workflow), ); } function markerPath(event: ServerAutomationEvent, workflowId: string): string { const cleanId = workflowId.trim().replace(/[^a-zA-Z0-9._-]+/g, '-'); return `${AUTOMATION_MARKER_ROOT}/${event}/${cleanId}.json`; } function isMissingFileError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return ( message.includes('No such file or directory') || message.includes('NOT_FOUND') || message.includes('status code 404') ); } function normalizePathSegments(path: string): string[] { return path .replace(/\\/g, '/') .split('/') .filter((segment) => segment && segment !== '.' && segment !== '..'); } function joinServerPath(base: string, relative: string): string { const baseSegments = normalizePathSegments(base); const relativeSegments = normalizePathSegments(relative); return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/'); } function normalizeArchivePath(path: string, stripComponents = 0): string | null { const segments = normalizePathSegments(path); const stripped = segments.slice(Math.max(0, stripComponents)); if (stripped.length === 0) return null; return stripped.join('/'); } async function hasMarker( node: DaemonNodeConnection, serverUuid: string, event: ServerAutomationEvent, workflowId: string, ): Promise { try { await daemonReadFile(node, serverUuid, markerPath(event, workflowId)); return true; } catch (error) { if (isMissingFileError(error)) return false; throw error; } } async function writeMarker( node: DaemonNodeConnection, serverUuid: string, event: ServerAutomationEvent, workflowId: string, payload: Record, ): Promise { await daemonWriteFile( node, serverUuid, markerPath(event, workflowId), JSON.stringify(payload, null, 2), ); } function githubHeaders(): Record { const headers: Record = { Accept: 'application/vnd.github+json', 'User-Agent': 'SourceGamePanel/1.0', }; const token = process.env.GITHUB_TOKEN?.trim(); if (token) { headers.Authorization = `Bearer ${token}`; } return headers; } function compileAssetPatterns(patterns: string[]): RegExp[] { const compiled: RegExp[] = []; const seen = new Set(); const tryCompile = (pattern: string) => { const key = pattern.trim(); if (!key || seen.has(key)) return; try { compiled.push(new RegExp(key, 'i')); seen.add(key); } catch { // Ignore invalid regex patterns in configuration. } }; for (const pattern of patterns) { tryCompile(pattern); // Some JSON-stored patterns may be over-escaped (e.g. "\\\\." instead of "\\."). // Collapse double backslashes once and compile a fallback variant. if (pattern.includes('\\\\')) { tryCompile(pattern.replace(/\\\\/g, '\\')); } } return compiled; } async function fetchLatestRelease( action: ServerAutomationGitHubReleaseExtractAction, ): Promise { const releaseUrl = `https://api.github.com/repos/${action.owner}/${action.repo}/releases/latest`; const response = await fetch(releaseUrl, { headers: githubHeaders(), }); if (!response.ok) { throw new Error( `GitHub latest release request failed (${action.owner}/${action.repo}): HTTP ${response.status}`, ); } const release = (await response.json()) as GitHubReleaseResponse; if (!Array.isArray(release.assets)) { throw new Error(`GitHub release payload has no assets (${action.owner}/${action.repo})`); } return release; } interface DirectoryAssetCandidate { name: string; downloadUrl: string; } function extractNumberParts(value: string): number[] { const matches = value.match(/\d+/g); if (!matches) return []; return matches.map((part) => Number.parseInt(part, 10)).filter((num) => Number.isFinite(num)); } function compareNumberPartsDesc(a: number[], b: number[]): number { const maxLength = Math.max(a.length, b.length); for (let i = 0; i < maxLength; i += 1) { const left = a[i] ?? -1; const right = b[i] ?? -1; if (left !== right) { return right - left; } } return 0; } function pickLatestDirectoryAsset(candidates: DirectoryAssetCandidate[]): DirectoryAssetCandidate { const sorted = [...candidates].sort((left, right) => { const numberDiff = compareNumberPartsDesc( extractNumberParts(left.name), extractNumberParts(right.name), ); if (numberDiff !== 0) return numberDiff; return right.name.localeCompare(left.name); }); return sorted[0] ?? candidates[0]!; } function extractDirectoryCandidates( html: string, indexUrl: string, assetPattern: RegExp, ): DirectoryAssetCandidate[] { const hrefRegex = /href\s*=\s*(['"])(.*?)\1/gi; const candidates: DirectoryAssetCandidate[] = []; let match: RegExpExecArray | null = null; while ((match = hrefRegex.exec(html)) !== null) { const href = (match[2] ?? '').trim(); if (!href || href.endsWith('/')) continue; try { const resolvedUrl = new URL(href, indexUrl); const filename = decodeURIComponent( resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '', ); if (!filename || !assetPattern.test(filename)) continue; candidates.push({ name: filename, downloadUrl: resolvedUrl.toString(), }); } catch { // Ignore malformed links. } } return candidates; } async function resolveLatestDirectoryAsset( action: ServerAutomationHttpDirectoryExtractAction, ): Promise { let assetPattern: RegExp; try { assetPattern = new RegExp(action.assetNamePattern, 'i'); } catch { throw new Error(`Invalid assetNamePattern regex for action ${action.id}`); } const response = await fetch(action.indexUrl, { headers: { 'User-Agent': 'SourceGamePanel/1.0' }, }); if (!response.ok) { throw new Error( `Directory listing request failed (${action.indexUrl}): HTTP ${response.status}`, ); } const html = await response.text(); const candidates = extractDirectoryCandidates(html, action.indexUrl, assetPattern); if (candidates.length === 0) { throw new Error( `No matching directory asset for ${action.indexUrl} with pattern: ${action.assetNamePattern}`, ); } return pickLatestDirectoryAsset(candidates); } async function downloadBinary(url: string, maxBytes: number): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), DEFAULT_DOWNLOAD_TIMEOUT_MS); try { const response = await fetch(url, { headers: { 'User-Agent': 'SourceGamePanel/1.0', }, redirect: 'follow', signal: controller.signal, }); if (!response.ok) { throw new Error(`Download failed with HTTP ${response.status}: ${url}`); } const contentLength = Number(response.headers.get('content-length') ?? '0'); if (contentLength > maxBytes) { throw new Error(`Artifact exceeds max size (${contentLength} > ${maxBytes} bytes)`); } const buffer = Buffer.from(await response.arrayBuffer()); if (buffer.length === 0) { throw new Error('Downloaded artifact is empty'); } if (buffer.length > maxBytes) { throw new Error(`Artifact exceeds max size (${buffer.length} > ${maxBytes} bytes)`); } return buffer; } finally { clearTimeout(timeout); } } async function extractZipFiles(buffer: Buffer, stripComponents = 0): 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, stripComponents); if (!normalized) continue; files.push({ path: normalized, data: await entry.buffer(), }); } return files; } function extractTarFiles(buffer: Buffer, stripComponents = 0): 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, stripComponents); 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 extractArtifactFiles( artifact: Buffer, assetName: string, stripComponents = 0, ): Promise { const name = assetName.toLowerCase(); if (name.endsWith('.zip')) { return extractZipFiles(artifact, stripComponents); } if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) { return extractTarFiles(gunzipSync(artifact), stripComponents); } if (name.endsWith('.tar')) { return extractTarFiles(artifact, stripComponents); } const normalized = normalizeArchivePath(assetName, stripComponents) ?? assetName; return [{ path: normalized, data: artifact }]; } async function executeGitHubReleaseExtract( app: FastifyInstance, context: ServerAutomationContext, action: ServerAutomationGitHubReleaseExtractAction, ): Promise { const release = await fetchLatestRelease(action); const patterns = compileAssetPatterns(action.assetNamePatterns); if (patterns.length === 0) { throw new Error(`No valid asset regex pattern for action ${action.id}`); } const asset = release.assets.find((candidate) => patterns.some((pattern) => pattern.test(candidate.name)), ); if (!asset) { throw new Error( `No matching release asset for ${action.owner}/${action.repo} with patterns: ${action.assetNamePatterns.join(', ')}`, ); } const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES; const artifact = await downloadBinary(asset.browser_download_url, maxBytes); const files = await extractArtifactFiles( artifact, asset.name, Number(action.stripComponents) || 0, ); if (files.length === 0) { throw new Error(`Extracted artifact has no files: ${asset.name}`); } const destination = action.destination ?? '/'; for (const file of files) { const targetPath = joinServerPath(destination, file.path); await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data); } app.log.info( { serverId: context.serverId, serverUuid: context.serverUuid, gameSlug: context.gameSlug, event: context.event, actionId: action.id, release: release.tag_name, asset: asset.name, filesWritten: files.length, }, 'Automation action completed: github_release_extract', ); } async function executeHttpDirectoryExtract( app: FastifyInstance, context: ServerAutomationContext, action: ServerAutomationHttpDirectoryExtractAction, ): Promise { const selectedAsset = await resolveLatestDirectoryAsset(action); const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES; const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes); const files = await extractArtifactFiles( artifact, selectedAsset.name, Number(action.stripComponents) || 0, ); if (files.length === 0) { throw new Error(`Extracted artifact has no files: ${selectedAsset.name}`); } const destination = action.destination ?? '/'; for (const file of files) { const targetPath = joinServerPath(destination, file.path); await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data); } app.log.info( { serverId: context.serverId, serverUuid: context.serverUuid, gameSlug: context.gameSlug, event: context.event, actionId: action.id, source: action.indexUrl, asset: selectedAsset.name, filesWritten: files.length, }, 'Automation action completed: http_directory_extract', ); } async function executeInsertBeforeLine( app: FastifyInstance, context: ServerAutomationContext, action: ServerAutomationInsertBeforeLineAction, ): Promise { const file = await daemonReadFile(context.node, context.serverUuid, action.path); const content = file.data.toString('utf8'); const eol = content.includes('\r\n') ? '\r\n' : '\n'; const hasTrailingEol = content.endsWith('\n'); const lines = content.split(/\r?\n/); if (hasTrailingEol && lines[lines.length - 1] === '') { lines.pop(); } const skipIfExists = action.skipIfExists !== false; if (skipIfExists) { const existsRegex = action.existsPattern ? new RegExp(action.existsPattern, 'i') : null; const alreadyExists = lines.some((line) => existsRegex ? existsRegex.test(line) : line === action.line, ); if (alreadyExists) { app.log.info( { serverId: context.serverId, serverUuid: context.serverUuid, event: context.event, actionId: action.id, path: action.path, }, 'Automation action skipped: line already present', ); return; } } let beforeRegex: RegExp; try { beforeRegex = new RegExp(action.beforePattern); } catch { throw new Error(`Invalid beforePattern regex for action ${action.id}`); } const insertIndex = lines.findIndex((line) => beforeRegex.test(line)); if (insertIndex < 0) { throw new Error( `Could not find insertion point in ${action.path} with pattern: ${action.beforePattern}`, ); } const updated = [...lines.slice(0, insertIndex), action.line, ...lines.slice(insertIndex)]; const output = `${updated.join(eol)}${hasTrailingEol ? eol : ''}`; await daemonWriteFile(context.node, context.serverUuid, action.path, output); app.log.info( { serverId: context.serverId, serverUuid: context.serverUuid, event: context.event, actionId: action.id, path: action.path, }, 'Automation action completed: insert_before_line', ); } async function executeAction( app: FastifyInstance, context: ServerAutomationContext, action: ServerAutomationAction, ): Promise { switch (action.type) { case 'github_release_extract': { await executeGitHubReleaseExtract(app, context, action); return; } case 'http_directory_extract': { await executeHttpDirectoryExtract(app, context, action); return; } case 'insert_before_line': { await executeInsertBeforeLine(app, context, action); return; } case 'write_file': { const payload = action.encoding === 'base64' ? Buffer.from(action.data, 'base64') : action.data; await daemonWriteFile(context.node, context.serverUuid, action.path, payload); app.log.info( { serverId: context.serverId, serverUuid: context.serverUuid, event: context.event, actionId: action.id, path: action.path, }, 'Automation action completed: write_file', ); return; } case 'send_command': { await daemonSendCommand(context.node, context.serverUuid, action.command); app.log.info( { serverId: context.serverId, serverUuid: context.serverUuid, event: context.event, actionId: action.id, command: action.command, }, 'Automation action completed: send_command', ); return; } default: { const unknownAction = action as { type?: unknown }; throw new Error(`Unsupported automation action type: ${String(unknownAction.type)}`); } } } export async function runServerAutomationEvent( app: FastifyInstance, context: ServerAutomationContext, ): Promise { const workflows = asAutomationRules(context.automationRulesRaw, context.gameSlug) .filter((rule) => isObject(rule)) .filter((rule) => rule.event === context.event) .filter((rule) => rule.enabled !== false) .filter((rule) => Array.isArray(rule.actions) && rule.actions.length > 0); const result: ServerAutomationRunResult = { workflowsMatched: workflows.length, workflowsExecuted: 0, workflowsSkipped: 0, workflowsFailed: 0, actionFailures: 0, failures: [], }; if (workflows.length === 0) { return result; } for (const workflow of workflows) { const runOnce = workflow.runOncePerServer !== false; try { if ( runOnce && !context.force && (await hasMarker(context.node, context.serverUuid, context.event, workflow.id)) ) { result.workflowsSkipped += 1; app.log.info( { serverId: context.serverId, serverUuid: context.serverUuid, gameSlug: context.gameSlug, event: context.event, workflowId: workflow.id, }, 'Skipping automation workflow (already completed)', ); continue; } for (const action of workflow.actions) { try { await executeAction(app, context, action); } catch (error) { const message = errorMessage(error); result.actionFailures += 1; result.failures.push({ level: 'action', workflowId: workflow.id, actionId: action.id, message, }); app.log.error( { err: error, errorMessage: message, serverId: context.serverId, serverUuid: context.serverUuid, gameSlug: context.gameSlug, event: context.event, workflowId: workflow.id, actionId: action.id, }, 'Automation action failed', ); if (workflow.continueOnError) { continue; } throw error; } } if (runOnce) { await writeMarker(context.node, context.serverUuid, context.event, workflow.id, { workflowId: workflow.id, event: context.event, completedAt: new Date().toISOString(), }); } app.log.info( { serverId: context.serverId, serverUuid: context.serverUuid, gameSlug: context.gameSlug, event: context.event, workflowId: workflow.id, }, 'Automation workflow completed', ); result.workflowsExecuted += 1; } catch (error) { const message = errorMessage(error); result.workflowsFailed += 1; result.failures.push({ level: 'workflow', workflowId: workflow.id, message, }); app.log.error( { err: error, errorMessage: message, serverId: context.serverId, serverUuid: context.serverUuid, gameSlug: context.gameSlug, event: context.event, workflowId: workflow.id, }, 'Automation workflow failed', ); } } return result; }