- Fix toggle/load_order handlers to use page-reload (not JSON) responses - Remove dead jsonResponse helper method from WorkshopModController - Fix robocopy exit code detection using ROBOCOPY_EXIT: sentinel (not text parsing) - Fix rsync dry-run change detection using RSYNC_EXIT: sentinel - Remove agentIdFromRemote() stub; pass agentId directly to triggerSteamCmdDownload() logging - Fix 'enabled' checkbox default in profile_form to use ($profile['enabled'] ?? 1) - Add missing error_toggle_failed / error_order_failed lang strings Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/dbeebd0e-e7a5-469d-8a8c-e63193d1ebb0 Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
501 lines
19 KiB
PHP
501 lines
19 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
/*
|
||
* OGP / GSP – Steam Workshop
|
||
* WorkshopInstaller: handles mod download (via agent SteamCMD) and
|
||
* copy/sync from agent cache to server install path.
|
||
*
|
||
* Template variables supported in all paths/scripts:
|
||
* {home_id} numeric home id
|
||
* {agent_id} numeric remote_server_id
|
||
* {workshop_app_id} Steam app id (e.g. 221100)
|
||
* {mod_id} Workshop mod id (numeric string)
|
||
* {mod_title} mod title (sanitised)
|
||
* {steamcmd_path} path to steamcmd.sh / steamcmd.exe on the agent
|
||
* {server_path} game server home_path
|
||
* {install_path} resolved install path for this mod
|
||
* {cache_path} resolved cache path for this mod
|
||
*/
|
||
|
||
require_once __DIR__ . '/WorkshopRepository.php';
|
||
|
||
class WorkshopInstaller
|
||
{
|
||
private WorkshopRepository $repo;
|
||
private string $logDir;
|
||
|
||
public function __construct(WorkshopRepository $repo)
|
||
{
|
||
$this->repo = $repo;
|
||
$this->logDir = __DIR__ . '/../logs';
|
||
if (!is_dir($this->logDir)) {
|
||
mkdir($this->logDir, 0775, true);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Public API
|
||
// ------------------------------------------------------------------
|
||
|
||
/**
|
||
* Install a workshop mod for a game server.
|
||
*
|
||
* @param array $home Row from getGameHome/getUserGameHome
|
||
* @param array $profile Row from gsp_workshop_game_profiles
|
||
* @param string $workshopId Numeric workshop item id
|
||
* @param string $steamCmdPath Path to steamcmd binary on the agent
|
||
* @return array{success:bool, message:string, restart_required:bool, log:list<string>}
|
||
*/
|
||
public function install(
|
||
array $home,
|
||
array $profile,
|
||
string $workshopId,
|
||
string $steamCmdPath = ''
|
||
): array {
|
||
$log = [];
|
||
|
||
// Validate workshop id
|
||
$workshopId = preg_replace('/[^0-9]/', '', $workshopId) ?? '';
|
||
if ($workshopId === '') {
|
||
return $this->fail('Workshop ID must be numeric.', $log);
|
||
}
|
||
|
||
$homeId = (int)($home['home_id'] ?? 0);
|
||
$agentId = (int)($home['remote_server_id'] ?? 0);
|
||
$appId = (string)($profile['workshop_app_id'] ?? '');
|
||
$osType = $this->detectOsType($home);
|
||
|
||
if ($homeId <= 0 || $agentId <= 0 || $appId === '') {
|
||
return $this->fail('Invalid home, agent, or app ID.', $log);
|
||
}
|
||
|
||
// Build template vars
|
||
$vars = $this->buildTemplateVars($home, $profile, $workshopId, '', $steamCmdPath);
|
||
$cachePath = $this->resolveTemplate((string)($profile['cache_path_template'] ?? ''), $vars);
|
||
$installPath = $this->resolveTemplate((string)($profile['install_path_template'] ?? ''), $vars);
|
||
$vars['{cache_path}'] = $cachePath;
|
||
$vars['{install_path}'] = $installPath;
|
||
|
||
// Build remote library
|
||
$remote = $this->buildRemote($home);
|
||
if ($remote === null) {
|
||
return $this->fail('Unable to connect to agent.', $log);
|
||
}
|
||
|
||
// Check agent connectivity
|
||
if ($remote->status_chk() !== 1) {
|
||
return $this->fail('Agent is offline.', $log);
|
||
}
|
||
|
||
// Check cache
|
||
$cacheEntry = $this->repo->getCacheEntry($agentId, $appId, $workshopId);
|
||
$log[] = "Cache check: agent={$agentId} app={$appId} mod={$workshopId}";
|
||
|
||
if ($cacheEntry === null || ($cacheEntry['status'] ?? '') !== 'cached') {
|
||
$log[] = 'Cache MISS – triggering SteamCMD download on agent.';
|
||
$downloadResult = $this->triggerSteamCmdDownload(
|
||
$remote, $agentId, $appId, $workshopId, $steamCmdPath, $cachePath, $log
|
||
);
|
||
|
||
if (!$downloadResult) {
|
||
// Update cache status to 'missing' so the cron can retry
|
||
$this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'missing');
|
||
return $this->fail(
|
||
'SteamCMD download failed. The mod will be retried on the next scheduled update.',
|
||
$log
|
||
);
|
||
}
|
||
|
||
$log[] = 'SteamCMD download success.';
|
||
$this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'cached');
|
||
} else {
|
||
$log[] = 'Cache HIT – using existing cached copy.';
|
||
}
|
||
|
||
// Copy / sync from cache to server install path
|
||
$syncResult = $this->syncToServer($remote, $profile, $vars, $log);
|
||
if (!$syncResult) {
|
||
return $this->fail('Sync from cache to server failed. Check agent logs.', $log);
|
||
}
|
||
|
||
// Optional install script (admin-defined only)
|
||
$installScript = trim((string)($profile['install_script'] ?? ''));
|
||
if ($installScript !== '') {
|
||
$this->runInstallScript($remote, $installScript, $vars, $log);
|
||
}
|
||
|
||
// Record in database
|
||
$this->repo->insertOrUpdateMod(
|
||
$homeId, $agentId, (int)$profile['id'], $appId, $workshopId,
|
||
$installPath, '', 0
|
||
);
|
||
|
||
$restartRequired = !empty($profile['requires_restart']);
|
||
$log[] = $restartRequired ? 'Restart required after mod install.' : 'Hot-reload capable (no restart required).';
|
||
|
||
return [
|
||
'success' => true,
|
||
'message' => 'Mod installed successfully.',
|
||
'restart_required' => $restartRequired,
|
||
'log' => $log,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Sync a single installed mod's cache into the server path.
|
||
* Called from pre-start and from the user "Sync now" button.
|
||
*
|
||
* @param array $home Game home row
|
||
* @param array $modRow Row from gsp_server_workshop_mods
|
||
* @param array $profile Row from gsp_workshop_game_profiles
|
||
* @return array{success:bool, changed:bool, message:string, log:list<string>}
|
||
*/
|
||
public function syncMod(array $home, array $modRow, array $profile): array
|
||
{
|
||
$log = [];
|
||
$workshopId = (string)($modRow['workshop_id'] ?? '');
|
||
$agentId = (int)($modRow['agent_id'] ?? 0);
|
||
$appId = (string)($modRow['workshop_app_id'] ?? '');
|
||
|
||
$cacheEntry = $this->repo->getCacheEntry($agentId, $appId, $workshopId);
|
||
if ($cacheEntry === null || ($cacheEntry['status'] ?? '') !== 'cached') {
|
||
$log[] = "Cache entry not available for mod {$workshopId} – skipping sync.";
|
||
return ['success' => false, 'changed' => false, 'message' => 'Mod not cached yet.', 'log' => $log];
|
||
}
|
||
|
||
$remote = $this->buildRemote($home);
|
||
if ($remote === null || $remote->status_chk() !== 1) {
|
||
return ['success' => false, 'changed' => false, 'message' => 'Agent offline.', 'log' => $log];
|
||
}
|
||
|
||
$vars = $this->buildTemplateVars($home, $profile, $workshopId, $modRow['title'] ?? '');
|
||
$vars['{cache_path}'] = $this->resolveTemplate((string)($profile['cache_path_template'] ?? ''), $vars);
|
||
$vars['{install_path}'] = (string)($modRow['install_path'] ?? $this->resolveTemplate((string)($profile['install_path_template'] ?? ''), $vars));
|
||
|
||
$changed = $this->checkNeedsSync($remote, $vars['{cache_path}'], $vars['{install_path}'], $profile, $log);
|
||
if (!$changed) {
|
||
$log[] = 'No changes detected – skipping sync.';
|
||
return ['success' => true, 'changed' => false, 'message' => 'Already up to date.', 'log' => $log];
|
||
}
|
||
|
||
$log[] = 'Changes detected – syncing.';
|
||
$ok = $this->syncToServer($remote, $profile, $vars, $log);
|
||
|
||
return [
|
||
'success' => $ok,
|
||
'changed' => true,
|
||
'message' => $ok ? 'Sync complete.' : 'Sync failed.',
|
||
'log' => $log,
|
||
];
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Template resolution
|
||
// ------------------------------------------------------------------
|
||
|
||
/**
|
||
* Replace template placeholders in a string.
|
||
*
|
||
* @param array<string,string> $vars
|
||
*/
|
||
public function resolveTemplate(string $template, array $vars): string
|
||
{
|
||
return str_replace(array_keys($vars), array_values($vars), $template);
|
||
}
|
||
|
||
/**
|
||
* Build the standard template variable map for a home + profile + mod.
|
||
*
|
||
* @return array<string,string>
|
||
*/
|
||
public function buildTemplateVars(
|
||
array $home,
|
||
array $profile,
|
||
string $workshopId,
|
||
string $modTitle = '',
|
||
string $steamCmdPath = ''
|
||
): array {
|
||
$serverPath = rtrim((string)($home['home_path'] ?? ''), '/');
|
||
$safeName = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $modTitle) ?? '';
|
||
|
||
$folderNameTpl = (string)($profile['folder_name_template'] ?? '@{mod_id}');
|
||
$folderNameVars = [
|
||
'{mod_id}' => $workshopId,
|
||
'{mod_title}' => $safeName,
|
||
];
|
||
$folderName = str_replace(array_keys($folderNameVars), array_values($folderNameVars), $folderNameTpl);
|
||
|
||
return [
|
||
'{home_id}' => (string)($home['home_id'] ?? ''),
|
||
'{agent_id}' => (string)($home['remote_server_id'] ?? ''),
|
||
'{workshop_app_id}' => (string)($profile['workshop_app_id'] ?? ''),
|
||
'{mod_id}' => $workshopId,
|
||
'{mod_title}' => $safeName,
|
||
'{mod_folder}' => $folderName,
|
||
'{steamcmd_path}' => $steamCmdPath !== '' ? $steamCmdPath : '/home/gameserver/steamcmd',
|
||
'{server_path}' => $serverPath,
|
||
'{install_path}' => '', // filled by caller after resolution
|
||
'{cache_path}' => '', // filled by caller after resolution
|
||
];
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Private helpers
|
||
// ------------------------------------------------------------------
|
||
|
||
/** Build an OGPRemoteLibrary instance from a home row. */
|
||
private function buildRemote(array $home): ?object
|
||
{
|
||
if (!class_exists('OGPRemoteLibrary')) {
|
||
@require_once __DIR__ . '/../../../includes/lib_remote.php';
|
||
}
|
||
if (!class_exists('OGPRemoteLibrary')) {
|
||
return null;
|
||
}
|
||
|
||
$ip = (string)($home['agent_ip'] ?? '');
|
||
$port = (string)($home['agent_port'] ?? '');
|
||
$key = (string)($home['encryption_key'] ?? '');
|
||
$timeout = isset($home['timeout']) ? (int)$home['timeout'] : 30;
|
||
|
||
if ($ip === '' || $port === '') {
|
||
return null;
|
||
}
|
||
|
||
return new OGPRemoteLibrary($ip, $port, $key, $timeout);
|
||
}
|
||
|
||
/**
|
||
* Trigger a SteamCMD workshop_download_item on the agent via exec().
|
||
* Returns true on success.
|
||
*
|
||
* @param list<string> $log
|
||
*/
|
||
private function triggerSteamCmdDownload(
|
||
object $remote,
|
||
int $agentId,
|
||
string $appId,
|
||
string $workshopId,
|
||
string $steamCmdPath,
|
||
string $cachePath,
|
||
array &$log
|
||
): bool {
|
||
if ($steamCmdPath === '') {
|
||
$steamCmdPath = '/home/gameserver/steamcmd/steamcmd.sh';
|
||
}
|
||
|
||
$cmd = implode(' ', [
|
||
escapeshellarg($steamCmdPath),
|
||
'+login', 'anonymous',
|
||
'+workshop_download_item', escapeshellarg($appId), escapeshellarg($workshopId),
|
||
'validate',
|
||
'+quit',
|
||
]);
|
||
|
||
$log[] = "SteamCMD start: {$cmd}";
|
||
$this->writeLog("STEAMCMD START agent={$agentId} app={$appId} mod={$workshopId}");
|
||
|
||
$output = $remote->exec($cmd);
|
||
|
||
if ($output === null) {
|
||
$log[] = 'SteamCMD: no response from agent (command may still be running).';
|
||
$this->writeLog("STEAMCMD NO_RESPONSE agent={$agentId} app={$appId} mod={$workshopId}");
|
||
// Treat as unknown – check file existence
|
||
} else {
|
||
$log[] = 'SteamCMD output: ' . substr((string)$output, 0, 500);
|
||
}
|
||
|
||
// Verify the download succeeded by checking for the cache path on the agent
|
||
$exists = $remote->rfile_exists($cachePath);
|
||
if ($exists === 1) {
|
||
$this->writeLog("STEAMCMD SUCCESS agent={$agentId} app={$appId} mod={$workshopId} path={$cachePath}");
|
||
return true;
|
||
}
|
||
|
||
$this->writeLog("STEAMCMD FAILURE agent={$agentId} app={$appId} mod={$workshopId} path={$cachePath}");
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Check if cache path differs from install path using a dry-run compare.
|
||
* Returns true if sync is needed.
|
||
*
|
||
* @param list<string> $log
|
||
*/
|
||
private function checkNeedsSync(
|
||
object $remote,
|
||
string $cachePath,
|
||
string $installPath,
|
||
array $profile,
|
||
array &$log
|
||
): bool {
|
||
$copyMethod = (string)($profile['copy_method'] ?? 'rsync');
|
||
$log[] = "Pre-start compare: cache={$cachePath} dest={$installPath} method={$copyMethod}";
|
||
|
||
if ($copyMethod === 'rsync') {
|
||
// Dry-run: any output lines (beyond the exit sentinel) mean changes exist
|
||
$cmd = sprintf(
|
||
'rsync -rcn --delete %s %s 2>/dev/null; echo "RSYNC_EXIT:$?"',
|
||
escapeshellarg(rtrim($cachePath, '/') . '/'),
|
||
escapeshellarg(rtrim($installPath, '/') . '/')
|
||
);
|
||
$out = (string)$remote->exec($cmd);
|
||
// Strip the exit line, then check for any non-whitespace output
|
||
$body = preg_replace('/RSYNC_EXIT:\d+\s*$/', '', $out) ?? '';
|
||
return preg_match('/\S/', $body) === 1;
|
||
}
|
||
|
||
if ($copyMethod === 'robocopy') {
|
||
// List-only mode: robocopy exit code 0 = no differences, 1+ = changes or errors.
|
||
// Embed the exit code in output so we can read it back via exec().
|
||
$cmd = sprintf(
|
||
'robocopy /L /MIR /NJH /NJS %s %s; echo "ROBOCOPY_EXIT:$LASTEXITCODE"',
|
||
escapeshellarg($cachePath),
|
||
escapeshellarg($installPath)
|
||
);
|
||
$out = (string)$remote->exec($cmd);
|
||
if (preg_match('/ROBOCOPY_EXIT:(\d+)/', $out, $m)) {
|
||
// 0 = no change; 1–7 = informational (changes found); 8+ = error
|
||
return (int)$m[1] !== 0;
|
||
}
|
||
// If we cannot determine, assume sync is needed
|
||
return true;
|
||
}
|
||
|
||
// custom_script: always sync
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Perform the actual copy/sync from cache to install path on the agent.
|
||
*
|
||
* @param array<string,string> $vars
|
||
* @param list<string> $log
|
||
*/
|
||
private function syncToServer(
|
||
object $remote,
|
||
array $profile,
|
||
array $vars,
|
||
array &$log
|
||
): bool {
|
||
$copyMethod = (string)($profile['copy_method'] ?? 'rsync');
|
||
$cachePath = $vars['{cache_path}'] ?? '';
|
||
$installPath = $vars['{install_path}'] ?? '';
|
||
|
||
if ($cachePath === '' || $installPath === '') {
|
||
$log[] = 'Sync skipped: empty cache or install path.';
|
||
return false;
|
||
}
|
||
|
||
$log[] = "Sync start: method={$copyMethod} cache={$cachePath} dest={$installPath}";
|
||
$this->writeLog("COPY START method={$copyMethod} cache={$cachePath} dest={$installPath}");
|
||
|
||
if ($copyMethod === 'rsync') {
|
||
$cmd = sprintf(
|
||
'mkdir -p %s && rsync -a --delete %s %s 2>&1; echo "EXIT:$?"',
|
||
escapeshellarg($installPath),
|
||
escapeshellarg(rtrim($cachePath, '/') . '/'),
|
||
escapeshellarg(rtrim($installPath, '/') . '/')
|
||
);
|
||
} elseif ($copyMethod === 'robocopy') {
|
||
$cmd = sprintf(
|
||
'robocopy /MIR /NJH /NJS %s %s; echo "ROBOCOPY_EXIT:$LASTEXITCODE"',
|
||
escapeshellarg($cachePath),
|
||
escapeshellarg($installPath)
|
||
);
|
||
} elseif ($copyMethod === 'custom_script') {
|
||
$script = trim((string)($profile['install_script'] ?? ''));
|
||
if ($script === '') {
|
||
$log[] = 'custom_script requested but install_script is empty – falling back to rsync.';
|
||
$cmd = sprintf(
|
||
'mkdir -p %s && rsync -a --delete %s %s 2>&1; echo "EXIT:$?"',
|
||
escapeshellarg($installPath),
|
||
escapeshellarg(rtrim($cachePath, '/') . '/'),
|
||
escapeshellarg(rtrim($installPath, '/') . '/')
|
||
);
|
||
} else {
|
||
// The admin-defined script is templated; execute it via the agent exec()
|
||
$resolvedScript = $this->resolveTemplate($script, $vars);
|
||
$cmd = $resolvedScript . ' 2>&1; echo "EXIT:$?"';
|
||
}
|
||
} else {
|
||
$log[] = "Unknown copy method '{$copyMethod}'.";
|
||
return false;
|
||
}
|
||
|
||
$out = (string)$remote->exec($cmd);
|
||
$log[] = 'Sync output: ' . substr($out, 0, 500);
|
||
|
||
// Determine success from embedded exit code sentinel
|
||
if ($copyMethod === 'robocopy') {
|
||
if (preg_match('/ROBOCOPY_EXIT:(\d+)/', $out, $m)) {
|
||
// 0–7 = success/informational; 8+ = error
|
||
$ok = (int)$m[1] < 8;
|
||
} else {
|
||
$ok = true; // assume success if no code extracted
|
||
}
|
||
} else {
|
||
if (preg_match('/EXIT:(\d+)/', $out, $m)) {
|
||
$ok = (int)$m[1] === 0;
|
||
} else {
|
||
$ok = true;
|
||
}
|
||
}
|
||
|
||
if ($ok) {
|
||
$log[] = 'Sync success.';
|
||
$this->writeLog("COPY SUCCESS cache={$cachePath} dest={$installPath}");
|
||
} else {
|
||
$log[] = 'Sync failed (non-zero exit).';
|
||
$this->writeLog("COPY FAILURE cache={$cachePath} dest={$installPath}");
|
||
}
|
||
|
||
return $ok;
|
||
}
|
||
|
||
/**
|
||
* Run the admin-defined install script on the agent.
|
||
*
|
||
* @param array<string,string> $vars
|
||
* @param list<string> $log
|
||
*/
|
||
private function runInstallScript(
|
||
object $remote,
|
||
string $script,
|
||
array $vars,
|
||
array &$log
|
||
): void {
|
||
$resolved = $this->resolveTemplate($script, $vars);
|
||
$log[] = 'Running install script.';
|
||
$out = (string)$remote->exec($resolved . ' 2>&1');
|
||
$log[] = 'Script output: ' . substr($out, 0, 500);
|
||
$this->writeLog('SCRIPT OUTPUT: ' . substr($out, 0, 1000));
|
||
}
|
||
|
||
private function detectOsType(array $home): string
|
||
{
|
||
$gameKey = strtolower((string)($home['game_key'] ?? ''));
|
||
if (preg_match('/win/', $gameKey)) {
|
||
return 'windows';
|
||
}
|
||
return 'linux';
|
||
}
|
||
|
||
private function writeLog(string $message): void
|
||
{
|
||
$file = $this->logDir . '/workshop_install.log';
|
||
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
|
||
@file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
|
||
}
|
||
|
||
private function fail(string $message, array $log): array
|
||
{
|
||
$this->writeLog('FAIL: ' . $message);
|
||
return [
|
||
'success' => false,
|
||
'message' => $message,
|
||
'restart_required' => false,
|
||
'log' => $log,
|
||
];
|
||
}
|
||
}
|