From e4b83794c36a5c605906731ea0bd3ebf858564ae Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Sat, 4 Apr 2026 17:22:38 +0300 Subject: [PATCH] feat: enhance Docker command execution with improved error handling and path management --- inc/InstallProtocolManager.php | 115 ++++++++++++++++++++++++++++++--- inc/VpnServer.php | 15 +++-- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index b0ebc3d..d2f2d34 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -375,8 +375,27 @@ class InstallProtocolManager $containerFilter = escapeshellarg('^' . $containerName . '$'); $containerArg = escapeshellarg($containerName); - $containerList = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); - if ($containerList === '') { + $containerListRaw = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); + if ($containerListRaw === '') { + return [ + 'status' => 'absent', + 'message' => 'Контейнер AmneziaWG не найден на сервере' + ]; + } + + if (preg_match('/docker: command not found|command not found|cannot connect to the docker daemon|permission denied/i', $containerListRaw)) { + return [ + 'status' => 'absent', + 'message' => 'Docker CLI недоступен на сервере', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerListRaw, + ] + ]; + } + + $containerNames = array_values(array_filter(array_map('trim', preg_split('/\R+/', $containerListRaw)))); + if (!in_array($containerName, $containerNames, true)) { return [ 'status' => 'absent', 'message' => 'Контейнер AmneziaWG не найден на сервере' @@ -592,6 +611,29 @@ class InstallProtocolManager $script = self::renderTemplate($scripts, $context); $script = preg_replace('/\n\+\s*/', "\n", $script); $exportLines = self::buildExports($context); + + if ($phase === 'install') { + Logger::appendInstall($server->getId(), 'INSTALL phase: docker preflight start'); + $bootstrapCmd = "bash -lc 'set -e; " + . "if command -v docker >/dev/null 2>&1; then command -v docker; docker --version || true; exit 0; fi; " + . "if command -v curl >/dev/null 2>&1; then curl -fsSL https://get.docker.com | sh; " + . "elif command -v wget >/dev/null 2>&1; then wget -qO- https://get.docker.com | sh; " + . "else echo \"curl/wget not found\"; exit 127; fi; " + . "(systemctl enable --now docker || service docker start || true); " + . "command -v docker >/dev/null 2>&1 || { echo \"docker bootstrap failed\"; exit 127; }; " + . "command -v docker; docker --version || true'"; + $bootstrapOut = trim((string) $server->executeCommand($bootstrapCmd, true)); + if ($bootstrapOut !== '') { + $bootstrapHead = substr(str_replace(["\r", "\n"], ' ', $bootstrapOut), 0, 280); + Logger::appendInstall($server->getId(), 'INSTALL phase: docker preflight output ' . $bootstrapHead); + } + + $dockerCheckAfter = trim((string) $server->executeCommand('command -v docker || true', true)); + if ($dockerCheckAfter === '') { + throw new Exception('Docker не установлен на сервере и авто-установка не удалась'); + } + } + $wrapper = "bash <<'EOS'\nset -euo pipefail\n" . $exportLines . $script . "\nEOS"; Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: executing remote script'); $output = $server->executeCommand($wrapper, true); @@ -601,6 +643,17 @@ class InstallProtocolManager Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: output head ' . $head); } $trimmed = trim($output); + $installProbeSummary = ''; + + if ($phase === 'install' && $trimmed === '') { + $probeCmd = "echo whoami:\$(whoami) 2>/dev/null || true; echo shell:\$SHELL; command -v docker || echo docker:not-found; docker --version 2>&1 || true; id 2>&1 || true"; + $probeOut = trim((string) $server->executeCommand($probeCmd, true)); + if ($probeOut !== '') { + $normalizedProbe = substr(str_replace(["\r", "\n"], ' | ', $probeOut), 0, 320); + Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: probe ' . $normalizedProbe); + $installProbeSummary = '; probe: ' . $normalizedProbe; + } + } // Try JSON first $decoded = json_decode($trimmed, true); @@ -609,6 +662,25 @@ class InstallProtocolManager return $decoded; } + if ($phase === 'install') { + $lower = strtolower($trimmed); + $hardErrors = [ + 'connection refused', + 'permission denied', + 'command not found', + 'no route to host', + 'could not resolve hostname', + 'host key verification failed', + 'timed out', + 'operation timed out', + ]; + foreach ($hardErrors as $needle) { + if ($needle !== '' && strpos($lower, $needle) !== false) { + throw new Exception('Ошибка установки (script): ' . $trimmed); + } + } + } + // Try key-value format (e.g., "Port: 123" or "Server Public Key: abc") $result = self::parseKeyValueOutput($trimmed); if (!empty($result)) { @@ -620,7 +692,7 @@ class InstallProtocolManager if ($phase === 'install') { $lower = strtolower($trimmed); if ($lower === '' || strpos($lower, 'command not found') !== false || strpos($lower, 'error') !== false) { - throw new Exception('Ошибка установки (script): ' . ($trimmed !== '' ? $trimmed : 'empty output')); + throw new Exception('Ошибка установки (script): ' . ($trimmed !== '' ? $trimmed : 'empty output') . $installProbeSummary); } } @@ -1353,9 +1425,17 @@ class InstallProtocolManager } return $res; } catch (Throwable $e) { - self::markServerError($serverId, $e->getMessage()); - Logger::appendInstall($serverId, 'Activate failed: ' . $e->getMessage()); - throw $e; + $message = (string) $e->getMessage(); + if ( + stripos($message, 'server_protocols_ibfk_1') !== false + || (stripos($message, 'foreign key constraint fails') !== false && stripos($message, 'server_protocols') !== false) + ) { + $message = 'Сервер был удален или пересоздан во время установки. Обновите страницу и запустите установку заново.'; + } + + self::markServerError($serverId, $message); + Logger::appendInstall($serverId, 'Activate failed: ' . $message); + throw new Exception($message, 0, $e); } } @@ -1777,8 +1857,27 @@ class InstallProtocolManager $containerFilter = escapeshellarg('^' . $containerName . '$'); $containerArg = escapeshellarg($containerName); - $containerList = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); - if ($containerList === '') { + $containerListRaw = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true)); + if ($containerListRaw === '') { + return [ + 'status' => 'absent', + 'message' => 'Контейнер X-Ray не найден на сервере' + ]; + } + + if (preg_match('/docker: command not found|command not found|cannot connect to the docker daemon|permission denied/i', $containerListRaw)) { + return [ + 'status' => 'absent', + 'message' => 'Docker CLI недоступен на сервере', + 'details' => [ + 'container_name' => $containerName, + 'container_status' => $containerListRaw, + ] + ]; + } + + $containerNames = array_values(array_filter(array_map('trim', preg_split('/\R+/', $containerListRaw)))); + if (!in_array($containerName, $containerNames, true)) { return [ 'status' => 'absent', 'message' => 'Контейнер X-Ray не найден на сервере' diff --git a/inc/VpnServer.php b/inc/VpnServer.php index aee6752..587f91b 100644 --- a/inc/VpnServer.php +++ b/inc/VpnServer.php @@ -428,10 +428,12 @@ class VpnServer public function executeCommand(string $command, bool $sudo = false): string { $baseCommand = $command; - $escapedCommand = escapeshellarg($command); + $pathPrefix = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH; '; + $escapedCommand = ''; + $needsSudo = false; // Determine auth method - $sshOptions = '-q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no'; + $sshOptions = '-o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no'; $keyFile = ''; if (!empty($this->data['ssh_key'])) { @@ -440,6 +442,9 @@ class VpnServer chmod($keyFile, 0600); $sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey"; + $preparedCommand = $pathPrefix . $command; + $escapedCommand = escapeshellarg($preparedCommand); + $sshCommand = sprintf( "ssh -p %d %s %s@%s %s 2>&1", $this->data['port'], @@ -453,9 +458,11 @@ class VpnServer if ($needsSudo) { // Suppress sudo prompt text to keep command output machine-parseable. $command = "echo '{$this->data['password']}' | sudo -S -p '' " . $command; - $escapedCommand = escapeshellarg($command); } + $preparedCommand = $pathPrefix . $command; + $escapedCommand = escapeshellarg($preparedCommand); + $sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no"; $sshCommand = sprintf( "sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1", @@ -477,7 +484,7 @@ class VpnServer && preg_match('/(^|\\n)docker(\\s|$)/', ltrim($baseCommand)) && preg_match('/incorrect password attempts|sorry, try again|a password is required/i', $output) ) { - $escapedBaseCommand = escapeshellarg($baseCommand); + $escapedBaseCommand = escapeshellarg($pathPrefix . $baseCommand); $sshCommandNoSudo = sprintf( "sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1", $this->data['password'],