546 lines
19 KiB
PHP
546 lines
19 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace MageSail\Magesail\Model;
|
|
|
|
use Magento\Framework\App\Config\ScopeConfigInterface;
|
|
use Magento\Framework\Exception\LocalizedException;
|
|
use Magento\Framework\Shell;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* Updates Ansible-style global map files: map $http_host $MAGE_RUN_CODE { ... } and $MAGE_RUN_TYPE { ... }.
|
|
*/
|
|
class NginxGlobalMapManager
|
|
{
|
|
private const XML_MAP_PATH = 'magesail/nginx/global_map_path';
|
|
private const XML_NGINX_BINARY = 'magesail/nginx/nginx_binary';
|
|
private const XML_MAP_SOURCE = 'magesail/nginx/map_source_variable';
|
|
private const XML_MAP_RUN_CODE = 'magesail/nginx/map_run_code_variable';
|
|
private const XML_MAP_RUN_TYPE = 'magesail/nginx/map_run_type_variable';
|
|
private const XML_MAP_RUN_TYPE_VALUE = 'magesail/nginx/map_run_type_value';
|
|
private const XML_TEST_PID_PATH = 'magesail/nginx/test_pid_path';
|
|
private const XML_RELOAD_WRAPPER = 'magesail/nginx/reload_wrapper_script';
|
|
|
|
public const LINE_COMMENT = '# MageSail tunnel';
|
|
|
|
public function __construct(
|
|
private readonly ScopeConfigInterface $scopeConfig,
|
|
private readonly Shell $shell,
|
|
private readonly LoggerInterface $logger
|
|
) {
|
|
}
|
|
|
|
public function getMapFilePath(): ?string
|
|
{
|
|
$path = trim((string) $this->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 <php_user>, or setfacl -m u:<php_user>: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 <<<NGINX
|
|
error_log stderr;
|
|
{$pidLine}
|
|
events {
|
|
worker_connections 1;
|
|
}
|
|
http {
|
|
{$includeLine}
|
|
}
|
|
NGINX;
|
|
}
|
|
|
|
private function nginxConfigQuotedPath(string $path): string
|
|
{
|
|
$path = str_replace('\\', '/', $path);
|
|
if (preg_match('/^[a-zA-Z0-9._\/-]+$/', $path)) {
|
|
return $path;
|
|
}
|
|
return '"' . addcslashes($path, '"\\') . '"';
|
|
}
|
|
|
|
private function getMapSourceVar(): string
|
|
{
|
|
$v = trim((string) $this->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;
|
|
}
|
|
}
|