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} */ 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} */ 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 $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 */ 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 $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 $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 $vars * @param list $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 $vars * @param list $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, ]; } }