scopeConfig->getValue(self::XML_MAP_PATH)); return $path !== '' ? $path : null; } /** * @throws LocalizedException */ public function readMapFile(): string { $path = $this->getMapFilePath(); if ($path === null) { throw new LocalizedException(__('NGINX global map path is not configured.')); } if (!\is_readable($path)) { throw new LocalizedException(__('NGINX global map file is not readable: %1', $path)); } $content = file_get_contents($path); if ($content === false) { throw new LocalizedException(__('Failed to read NGINX global map file: %1', $path)); } return $content; } /** * @throws LocalizedException */ public function writeMapFile(string $content): void { $path = $this->getMapFilePath(); if ($path === null) { throw new LocalizedException(__('NGINX global map path is not configured.')); } $dir = \dirname($path); if (!\is_dir($dir)) { throw new LocalizedException(__('NGINX global map directory does not exist: %1', $dir)); } $fileExists = \is_file($path); if (!$fileExists) { if (!\is_writable($dir)) { throw new LocalizedException( __( 'NGINX global map directory is not writable: %1. Create the file first (e.g. touch) and chown/chgrp ' . 'so the PHP-FPM user can write it, or grant ACL/group write on the directory.', $dir ) ); } } elseif (!\is_writable($path)) { throw new LocalizedException( __( 'NGINX global map file is not writable: %1. Ensure the PHP-FPM user can write this file ' . '(e.g. group jetrails with mode 664 and usermod -aG jetrails , or setfacl -m u::rw).', $path ) ); } if (file_put_contents($path, $content, LOCK_EX) === false) { throw new LocalizedException(__('Failed to write NGINX global map file: %1', $path)); } } /** * Add host → store code in $MAGE_RUN_CODE map and host → store in $MAGE_RUN_TYPE map. * * @throws LocalizedException */ public function addTunnelMapping(string $hostname, string $storeCode): void { $content = $this->readMapFile(); $content = $this->removeTunnelMappingFromContent($content, $hostname); $runTypeValue = trim((string) $this->scopeConfig->getValue(self::XML_MAP_RUN_TYPE_VALUE)) ?: 'store'; $lineCode = $this->buildMapLine($hostname, $storeCode); $lineType = $this->buildMapLine($hostname, $runTypeValue); $content = $this->insertIntoNamedMap($content, $this->getMapRunCodeVar(), $lineCode); $content = $this->insertIntoNamedMap($content, $this->getMapRunTypeVar(), $lineType); $this->writeMapFile($content); } /** * @throws LocalizedException */ public function removeTunnelMapping(string $hostname): void { $content = $this->readMapFile(); $updated = $this->removeTunnelMappingFromContent($content, $hostname); if ($updated !== $content) { $this->writeMapFile($updated); } } /** * @return array{valid: bool, output?: string, error?: string} */ public function validateNginxConfig(): array { $mapPath = $this->getMapFilePath(); if ($mapPath === null) { return [ 'valid' => false, 'error' => 'Global map path is not configured.', 'output' => '', ]; } if (!\is_readable($mapPath)) { return [ 'valid' => false, 'error' => 'Global map file is not readable: ' . $mapPath, 'output' => '', ]; } $configuredPid = trim((string) $this->scopeConfig->getValue(self::XML_TEST_PID_PATH)); $cleanupPidFile = $configuredPid === ''; $pidPath = $cleanupPidFile ? rtrim(sys_get_temp_dir(), \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR . 'magesail-nginx-test-' . getmypid() . '-' . bin2hex(random_bytes(4)) . '.pid' : $configuredPid; $tempConf = rtrim(sys_get_temp_dir(), \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR . 'magesail-nginx-minimal-test-' . getmypid() . '.conf'; $body = $this->buildMinimalNginxTestConfig($pidPath, $mapPath); if (file_put_contents($tempConf, $body, LOCK_EX) === false) { return [ 'valid' => false, 'error' => 'Could not write temporary nginx test config: ' . $tempConf, 'output' => '', ]; } try { $result = $this->runNginxProcess(['-t', '-c', $tempConf]); } finally { if (\is_file($tempConf)) { @unlink($tempConf); } if ($cleanupPidFile && \is_file($pidPath)) { @unlink($pidPath); } } if ($result['exitCode'] === 0) { $note = $result['output'] !== '' ? $result['output'] . "\n" : ''; $note .= '(syntax check: minimal config + include ' . $mapPath . ')'; return ['valid' => true, 'output' => trim($note)]; } $this->logger->error('NGINX config validation failed: ' . $result['output']); $err = $result['output'] !== '' ? $result['output'] : 'nginx -t exited with code ' . $result['exitCode']; return [ 'valid' => false, 'error' => $err, 'output' => $result['output'], ]; } /** * @return array{success: bool, output?: string, error?: string} */ public function reloadNginx(): array { $wrapper = trim((string) $this->scopeConfig->getValue(self::XML_RELOAD_WRAPPER)); if ($wrapper !== '') { if (!\is_executable($wrapper)) { $msg = 'Reload wrapper script is missing or not executable: ' . $wrapper; $this->logger->error('MageSail NGINX: ' . $msg); return ['success' => false, 'error' => $msg, 'output' => '']; } $result = $this->runExternalExecutable([$wrapper]); } else { $result = $this->runNginxProcess(['-s', 'reload']); } if ($result['exitCode'] === 0) { $this->logger->info('NGINX reloaded successfully after tunnel map update.'); return ['success' => true, 'output' => $result['output']]; } $this->logger->error('NGINX reload failed: ' . $result['output']); $err = $result['output'] !== '' ? $result['output'] : 'nginx reload exited with code ' . $result['exitCode']; return [ 'success' => false, 'error' => $err, 'output' => $result['output'], ]; } /** * @return array{valid: bool, reloaded: bool, error?: string, output?: string} */ public function validateAndReload(): array { $validation = $this->validateNginxConfig(); if (!$validation['valid']) { return [ 'valid' => false, 'reloaded' => false, 'error' => $validation['error'] ?? 'NGINX config validation failed', 'output' => $validation['output'] ?? null, ]; } $reload = $this->reloadNginx(); return [ 'valid' => true, 'reloaded' => $reload['success'], 'error' => $reload['success'] ? null : ($reload['error'] ?? 'NGINX reload failed'), 'output' => $reload['output'] ?? null, ]; } public function extractHostname(string $baseUrl): string { $parsed = parse_url($baseUrl); if ($parsed === false || !isset($parsed['host'])) { throw new LocalizedException(__('Could not extract hostname from base URL: %1', $baseUrl)); } return $parsed['host']; } /** * Diagnostic info about map file accessibility. * * @return array{path: string|null, exists: bool, readable: bool, writable: bool, error?: string} */ public function getDiagnostics(): array { $path = $this->getMapFilePath(); if ($path === null) { return [ 'path' => null, 'exists' => false, 'readable' => false, 'writable' => false, 'error' => 'Map file path is not configured in Stores → Configuration → Holesail Tunnel → NGINX Configuration.', ]; } $exists = \is_file($path); $readable = $exists && \is_readable($path); $writable = $exists && \is_writable($path); $dirWritable = \is_dir(\dirname($path)) && \is_writable(\dirname($path)); $error = null; if (!$exists && !$dirWritable) { $error = 'Directory ' . \dirname($path) . ' is not writable.'; } elseif (!$exists) { $error = 'File does not exist yet (will be created).'; } elseif (!$readable) { $error = 'File exists but is not readable. Check permissions.'; } elseif (!$writable) { $error = 'File exists but is not writable. Check permissions.'; } return [ 'path' => $path, 'exists' => $exists, 'readable' => $readable, 'writable' => $writable, 'error' => $error, ]; } private function getNginxBinary(): string { $configured = trim((string) $this->scopeConfig->getValue(self::XML_NGINX_BINARY)); return $configured !== '' ? $configured : 'nginx'; } /** * Single pid + events + http { include map } — avoids loading main nginx.conf (duplicate pid, /run/nginx.pid perms). */ private function buildMinimalNginxTestConfig(string $pidPath, string $mapIncludePath): string { $pidLine = 'pid ' . $this->nginxConfigQuotedPath($pidPath) . ';'; $includeLine = 'include ' . $this->nginxConfigQuotedPath(str_replace('\\', '/', $mapIncludePath)) . ';'; return <<scopeConfig->getValue(self::XML_MAP_SOURCE)); return $v !== '' ? ltrim($v, '$') : 'http_host'; } private function getMapRunCodeVar(): string { $v = trim((string) $this->scopeConfig->getValue(self::XML_MAP_RUN_CODE)); return $v !== '' ? ltrim($v, '$') : 'MAGE_RUN_CODE'; } private function getMapRunTypeVar(): string { $v = trim((string) $this->scopeConfig->getValue(self::XML_MAP_RUN_TYPE)); return $v !== '' ? ltrim($v, '$') : 'MAGE_RUN_TYPE'; } /** * Run nginx with argv (binary from config prepended); merges stdout+stderr. * * @param string[] $args * @return array{exitCode: int, output: string} */ private function runNginxProcess(array $args): array { $binary = escapeshellarg($this->getNginxBinary()); $parts = array_map(static fn (string $a): string => escapeshellarg($a), $args); $cmd = $binary . ' ' . implode(' ', $parts); return $this->executeProcOrShellCommand($cmd); } /** * Run an arbitrary executable path with optional args (no nginx binary prefix). * * @param string[] $argv * @return array{exitCode: int, output: string} */ private function runExternalExecutable(array $argv): array { if ($argv === []) { return ['exitCode' => 1, 'output' => 'No command']; } $cmd = implode(' ', array_map(static fn (string $a): string => escapeshellarg($a), $argv)); return $this->executeProcOrShellCommand($cmd); } /** * @return array{exitCode: int, output: string} */ private function executeProcOrShellCommand(string $cmd): array { $disabled = explode(',', str_replace(' ', ',', (string) ini_get('disable_functions'))); if (\in_array('proc_open', $disabled, true)) { return $this->runNginxViaShell($cmd); } $descriptorspec = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $process = @proc_open($cmd, $descriptorspec, $pipes, null, null); if (!\is_resource($process)) { return $this->runNginxViaShell($cmd); } fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $exitCode = proc_close($process); if ($exitCode === -1) { $exitCode = 1; } $combined = trim( ($stdout !== '' ? rtrim($stdout) : '') . ($stderr !== '' ? (($stdout !== '' ? "\n" : '') . rtrim($stderr)) : '') ); return ['exitCode' => $exitCode, 'output' => $combined]; } /** * Fallback when proc_open is unavailable (merges stderr like 2>&1). */ private function runNginxViaShell(string $cmd): array { try { $wrapped = '/bin/sh -c ' . escapeshellarg($cmd . ' 2>&1'); $output = $this->shell->execute($wrapped); return ['exitCode' => 0, 'output' => trim((string) $output)]; } catch (\Throwable $e) { $prev = $e->getPrevious(); $msg = $prev instanceof \Exception ? $prev->getMessage() : $e->getMessage(); return ['exitCode' => 1, 'output' => $msg]; } } private function buildMapLine(string $hostname, string $value): string { $indent = $this->detectIndentOrDefault(); return sprintf( '%s%s %s; %s', $indent, $this->escapeNginxMapKey($hostname), $this->escapeNginxMapValue($value), self::LINE_COMMENT ); } private function detectIndentOrDefault(): string { try { $content = $this->readMapFile(); } catch (LocalizedException) { return ' '; } $src = preg_quote($this->getMapSourceVar(), '/'); $code = preg_quote($this->getMapRunCodeVar(), '/'); if (!preg_match('/map\s+\$' . $src . '\s+\$' . $code . '\s*\{/s', $content, $m, PREG_OFFSET_CAPTURE)) { return ' '; } $openBracePos = $m[0][1] + \strlen($m[0][0]) - 1; $closePos = $this->findMatchingClosingBrace($content, $openBracePos); if ($closePos === null) { return ' '; } $inner = substr($content, $openBracePos + 1, $closePos - $openBracePos - 1); if (preg_match('/^(\s*)default/m', $inner, $im)) { return $im[1]; } return ' '; } private function escapeNginxMapKey(string $value): string { if (preg_match('/^[a-zA-Z0-9._-]+$/', $value)) { return $value; } return '"' . addcslashes($value, '"\\') . '"'; } private function escapeNginxMapValue(string $value): string { if (preg_match('/^[a-zA-Z0-9._-]+$/', $value)) { return $value; } return '"' . addcslashes($value, '"\\') . '"'; } /** * Remove lines we added for this hostname (MAGE_RUN_CODE and MAGE_RUN_TYPE). */ private function removeTunnelMappingFromContent(string $content, string $hostname): string { $hostPat = preg_quote($this->escapeNginxMapKey($hostname), '/'); $comment = preg_quote(self::LINE_COMMENT, '/'); $content = preg_replace( '/^\s*' . $hostPat . '\s+\S+\s*;\s*' . $comment . '\s*$/m', '', $content ) ?? $content; $content = preg_replace( '/^\s*' . $hostPat . '\s+\S+\s*;\s*' . $comment . '\s*\}\s*$/m', '}', $content ) ?? $content; return $content; } /** * Insert a line before the closing `}` of map $source $resultVar { ... } * * @throws LocalizedException */ private function insertIntoNamedMap(string $content, string $resultVar, string $line): string { $source = preg_quote($this->getMapSourceVar(), '/'); $result = preg_quote($resultVar, '/'); if (!preg_match('/map\s+\$' . $source . '\s+\$' . $result . '\s*\{/s', $content, $m, PREG_OFFSET_CAPTURE)) { throw new LocalizedException( __( 'Could not find map block "map $%1 $%2 { ... }" in the global map file. Add it to your Ansible template (see MageSail docs).', $this->getMapSourceVar(), $resultVar ) ); } $openBracePos = $m[0][1] + \strlen($m[0][0]) - 1; $closePos = $this->findMatchingClosingBrace($content, $openBracePos); if ($closePos === null) { throw new LocalizedException(__('Unclosed map block for $%1 in global map file.', $resultVar)); } $inner = substr($content, $openBracePos + 1, $closePos - $openBracePos - 1); if (str_contains($inner, $line)) { return $content; } $before = substr($content, 0, $closePos); $after = substr($content, $closePos); return $before . "\n" . $line . "\n" . $after; } private function findMatchingClosingBrace(string $content, int $openBraceIndex): ?int { $len = \strlen($content); $depth = 0; for ($i = $openBraceIndex; $i < $len; $i++) { $c = $content[$i]; if ($c === '{') { $depth++; } elseif ($c === '}') { $depth--; if ($depth === 0) { return $i; } } } return null; } }