Store views, Globalmaps - Confirmed Loading

This commit is contained in:
2026-03-20 21:59:38 -05:00
parent db97652bc2
commit 6b73c6f781
27 changed files with 2770 additions and 5 deletions
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Block\Adminhtml\Nginx;
use Magento\Backend\Block\Template;
use Magento\Backend\Block\Template\Context;
use MageSail\Magesail\Model\NginxGlobalMapManager;
class Map extends Template
{
public function __construct(
Context $context,
private readonly NginxGlobalMapManager $nginxMapManager,
array $data = []
) {
parent::__construct($context, $data);
}
public function getMapFilePath(): ?string
{
return $this->nginxMapManager->getMapFilePath();
}
public function getMapContent(): string
{
try {
return $this->nginxMapManager->readMapFile();
} catch (\Exception) {
return '';
}
}
public function getMapUrl(): string
{
return $this->getUrl('*/*/map');
}
public function getFormKey(): string
{
return $this->formKey->getFormKey();
}
}
+17
View File
@@ -6,6 +6,7 @@ namespace MageSail\Magesail\Block\Adminhtml;
use Magento\Backend\Block\Template;
use Magento\Backend\Block\Template\Context;
use MageSail\Magesail\Model\TunnelStatus;
use MageSail\Magesail\Model\TunnelStoreProvisioner;
class Tunnel extends Template
{
@@ -15,6 +16,7 @@ class Tunnel extends Template
public function __construct(
Context $context,
private readonly TunnelStatus $tunnelStatus,
private readonly TunnelStoreProvisioner $tunnelStoreProvisioner,
array $data = []
) {
parent::__construct($context, $data);
@@ -28,6 +30,21 @@ class Tunnel extends Template
return $this->tunnelStatus->getStatus();
}
/**
* Persisted tunnel store (present while tunnel is configured; may exist briefly before PID appears).
*
* @return array{store_id: int, store_code: string, unsecure_base_url: string, secure_base_url: string, use_secure_urls: bool}|null
*/
public function getTunnelStoreState(): ?array
{
return $this->tunnelStoreProvisioner->getPersistedState();
}
public function getTunnelStoreCodeConstant(): string
{
return TunnelStoreProvisioner::STORE_CODE;
}
public function getConfigUrl(): string
{
return $this->getUrl('adminhtml/system_config/edit/section/magesail');
+277
View File
@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Controller\Adminhtml\Nginx;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Exception\LocalizedException;
use MageSail\Magesail\Model\NginxGlobalMapManager;
use MageSail\Magesail\Model\NginxMapParser;
use MageSail\Magesail\Model\TunnelStoreProvisioner;
class Map extends Action
{
public function __construct(
Context $context,
private readonly NginxGlobalMapManager $nginxMapManager,
private readonly NginxMapParser $mapParser,
private readonly TunnelStoreProvisioner $tunnelStoreProvisioner
) {
parent::__construct($context);
}
public function execute()
{
$action = $this->getRequest()->getParam('action');
$wantJson = $this->isAjaxRequest();
if ($action === 'read') {
return $this->handleRead($wantJson);
}
if ($action === 'write') {
return $this->handleWrite($wantJson);
}
if ($action === 'validate') {
return $this->handleValidate($wantJson);
}
if ($action === 'reload') {
return $this->handleReload($wantJson);
}
if ($action === 'parse') {
return $this->handleParse($wantJson);
}
if ($action === 'save_blocks') {
return $this->handleSaveBlocks($wantJson);
}
if ($action === 'diagnostics') {
return $this->handleDiagnostics($wantJson);
}
$resultPage = $this->resultFactory->create(ResultFactory::TYPE_PAGE);
$resultPage->setActiveMenu('MageSail_Magesail::config');
$resultPage->getConfig()->getTitle()->prepend(__('NGINX Global Map'));
return $resultPage;
}
private function isAjaxRequest(): bool
{
return strtolower((string) $this->getRequest()->getHeader('X-Requested-With')) === 'xmlhttprequest'
|| (string) $this->getRequest()->getParam('ajax') === '1';
}
private function jsonResponse(bool $success, string $message = '', array $data = [])
{
$json = $this->resultFactory->create(ResultFactory::TYPE_JSON);
$json->setData(array_merge([
'success' => $success,
'message' => $message,
], $data));
return $json;
}
private function handleRead(bool $wantJson)
{
try {
$content = $this->nginxMapManager->readMapFile();
if ($wantJson) {
return $this->jsonResponse(true, '', ['content' => $content]);
}
$this->messageManager->addSuccessMessage(__('Map file loaded.'));
return $this->_redirect('*/*/map');
} catch (LocalizedException $e) {
if ($wantJson) {
return $this->jsonResponse(false, (string) $e->getMessage());
}
$this->messageManager->addErrorMessage($e->getMessage());
return $this->_redirect('*/*/map');
}
}
private function handleWrite(bool $wantJson)
{
$content = (string) $this->getRequest()->getParam('content', '');
try {
$this->nginxMapManager->writeMapFile($content);
$validation = $this->nginxMapManager->validateNginxConfig();
if ($wantJson) {
return $this->jsonResponse(
true,
(string) ($validation['valid'] ? __('Map file saved and validated.') : __('Map file saved, but NGINX validation failed.')),
['validation' => $validation]
);
}
if ($validation['valid']) {
$this->messageManager->addSuccessMessage(__('Map file saved and validated.'));
} else {
$this->messageManager->addWarningMessage(__('Map file saved, but NGINX validation failed: %1', $validation['error'] ?? ''));
}
return $this->_redirect('*/*/map');
} catch (LocalizedException $e) {
if ($wantJson) {
return $this->jsonResponse(false, (string) $e->getMessage());
}
$this->messageManager->addErrorMessage($e->getMessage());
return $this->_redirect('*/*/map');
} catch (\Throwable $e) {
if ($wantJson) {
return $this->jsonResponse(false, (string) __('Could not save map file: %1', $e->getMessage()));
}
$this->messageManager->addErrorMessage((string) __('Could not save map file: %1', $e->getMessage()));
return $this->_redirect('*/*/map');
}
}
private function handleValidate(bool $wantJson)
{
$validation = $this->nginxMapManager->validateNginxConfig();
if ($wantJson) {
return $this->jsonResponse($validation['valid'], '', ['validation' => $validation]);
}
if ($validation['valid']) {
$this->messageManager->addSuccessMessage(__('NGINX configuration is valid.'));
} else {
$this->messageManager->addErrorMessage(__('NGINX configuration validation failed: %1', $validation['error'] ?? ''));
}
return $this->_redirect('*/*/map');
}
private function handleReload(bool $wantJson)
{
$validation = $this->nginxMapManager->validateAndReload();
if ($validation['valid'] && $validation['reloaded']) {
$this->tunnelStoreProvisioner->clearNginxPendingReloadFlag();
}
if ($wantJson) {
return $this->jsonResponse(
$validation['valid'] && $validation['reloaded'],
(string) ($validation['valid'] && $validation['reloaded']
? __('NGINX validated and reloaded successfully.')
: ($validation['error'] ?? __('NGINX validation or reload failed.'))),
['validation' => $validation]
);
}
if ($validation['valid'] && $validation['reloaded']) {
$this->messageManager->addSuccessMessage(__('NGINX validated and reloaded successfully.'));
} else {
$this->messageManager->addErrorMessage(__('NGINX validation or reload failed: %1', $validation['error'] ?? ''));
}
return $this->_redirect('*/*/map');
}
private function handleParse(bool $wantJson)
{
try {
$content = $this->nginxMapManager->readMapFile();
$parsed = $this->mapParser->parse($content);
if ($wantJson) {
return $this->jsonResponse(true, '', ['parsed' => $parsed, 'raw' => $content]);
}
return $this->_redirect('*/*/map');
} catch (LocalizedException $e) {
if ($wantJson) {
return $this->jsonResponse(false, (string) $e->getMessage());
}
$this->messageManager->addErrorMessage($e->getMessage());
return $this->_redirect('*/*/map');
}
}
private function handleSaveBlocks(bool $wantJson)
{
try {
$original = $this->nginxMapManager->readMapFile();
$blocksJson = (string) $this->getRequest()->getParam('blocks', '');
$guiBlocks = json_decode($blocksJson, true);
if (!\is_array($guiBlocks)) {
throw new LocalizedException(__('Invalid blocks data (JSON).'));
}
$fileParsed = $this->mapParser->parse($original);
$fileBlocks = $fileParsed['blocks'];
if (\count($guiBlocks) !== \count($fileBlocks)) {
throw new LocalizedException(__(
'Block count mismatch (%1 in editor, %2 in file). Click "Parse & Load Blocks" and try again.',
\count($guiBlocks),
\count($fileBlocks)
));
}
$mergedBlocks = [];
foreach ($fileBlocks as $idx => $fileBlock) {
$g = $guiBlocks[$idx] ?? null;
if (!\is_array($g)) {
throw new LocalizedException(__('Invalid block at index %1.', $idx));
}
$src = (string) ($g['source'] ?? '');
$tgt = (string) ($g['target'] ?? '');
if ($src !== $fileBlock['source'] || $tgt !== $fileBlock['target']) {
throw new LocalizedException(__(
'Map $%1 $%2 no longer matches the file. Click "Parse & Load Blocks" again.',
$src,
$tgt
));
}
$rawEntries = $g['entries'] ?? [];
if (!\is_array($rawEntries)) {
throw new LocalizedException(__('Invalid entries for map $%1 $%2.', $src, $tgt));
}
$entries = [];
foreach ($rawEntries as $e) {
if (!\is_array($e)) {
continue;
}
$entries[] = [
'hostname' => (string) ($e['hostname'] ?? ''),
'value' => (string) ($e['value'] ?? ''),
'comment' => isset($e['comment']) && $e['comment'] !== '' && $e['comment'] !== null
? (string) $e['comment']
: null,
];
}
$mergedBlocks[] = array_merge($fileBlock, ['entries' => $entries]);
}
$newContent = $this->mapParser->rebuild($original, ['blocks' => $mergedBlocks]);
$this->nginxMapManager->writeMapFile($newContent);
$validation = $this->nginxMapManager->validateNginxConfig();
if ($wantJson) {
return $this->jsonResponse(
true,
(string) ($validation['valid'] ? __('Map blocks saved and validated.') : __('Map blocks saved, but NGINX validation failed.')),
['validation' => $validation]
);
}
if ($validation['valid']) {
$this->messageManager->addSuccessMessage(__('Map blocks saved and validated.'));
} else {
$this->messageManager->addWarningMessage(__('Map blocks saved, but NGINX validation failed: %1', $validation['error'] ?? ''));
}
return $this->_redirect('*/*/map');
} catch (LocalizedException $e) {
if ($wantJson) {
return $this->jsonResponse(false, (string) $e->getMessage());
}
$this->messageManager->addErrorMessage($e->getMessage());
return $this->_redirect('*/*/map');
} catch (\Throwable $e) {
if ($wantJson) {
return $this->jsonResponse(false, (string) __('Could not save map blocks: %1', $e->getMessage()));
}
$this->messageManager->addErrorMessage((string) __('Could not save map blocks: %1', $e->getMessage()));
return $this->_redirect('*/*/map');
}
}
private function handleDiagnostics(bool $wantJson)
{
$diag = $this->nginxMapManager->getDiagnostics();
if ($wantJson) {
return $this->jsonResponse(true, '', ['diagnostics' => $diag]);
}
return $this->_redirect('*/*/map');
}
protected function _isAllowed()
{
return $this->_authorization->isAllowed('MageSail_Magesail::config');
}
}
+61 -2
View File
@@ -7,9 +7,11 @@ use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Exception\LocalizedException;
use MageSail\Magesail\Model\MagesailLog;
use MageSail\Magesail\Model\TunnelManager;
use MageSail\Magesail\Model\TunnelStatus;
use MageSail\Magesail\Model\TunnelStoreProvisioner;
class Index extends Action
{
@@ -18,7 +20,8 @@ class Index extends Action
private readonly ScopeConfigInterface $scopeConfig,
private readonly TunnelManager $tunnelManager,
private readonly TunnelStatus $tunnelStatus,
private readonly MagesailLog $magesailLog
private readonly MagesailLog $magesailLog,
private readonly TunnelStoreProvisioner $tunnelStoreProvisioner
) {
parent::__construct($context);
}
@@ -76,17 +79,57 @@ class Index extends Action
$this->messageManager->addErrorMessage(__('Invalid port. Must be 80 or 443.'));
return $this->_redirect('*/*/index');
}
$tunnelBaseUrl = trim((string) $this->getRequest()->getParam('tunnel_base_url', ''));
if ($tunnelBaseUrl === '') {
$msg = (string) __('Tunnel base URL is required (must match your holesail-browser virtual host).');
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
$tunnelSecureBaseUrl = trim((string) $this->getRequest()->getParam('tunnel_secure_base_url', ''));
$useSecureUrls = $this->isTruthyParam($this->getRequest()->getParam('tunnel_use_secure_urls'));
try {
$provision = $this->tunnelStoreProvisioner->provision($tunnelBaseUrl, $tunnelSecureBaseUrl, $useSecureUrls);
} catch (LocalizedException $e) {
$this->magesailLog->add('Tunnel store provision failed: ' . $e->getMessage());
if ($wantJson) {
return $this->jsonPayload(false, (string) $e->getMessage());
}
$this->messageManager->addErrorMessage($e->getMessage());
return $this->_redirect('*/*/index');
}
$result = $this->tunnelManager->start((int) $port, $secure);
if (!$result['ok']) {
$this->tunnelStoreProvisioner->teardownPersistedState();
}
if ($result['ok']) {
$this->magesailLog->add(sprintf('Tunnel started PID %s port %s', $result['pid'], $port));
$msg = (string) __(
'Tunnel started (PID: %1). Key will appear once ready.',
$result['pid']
);
$nginxReloadMsg = '';
if (empty($provision['nginx_reloaded'])) {
$nginxReloadMsg = (string) __(
'The NGINX global map was updated, but Magento could not reload nginx (common when the PHP user cannot signal the nginx master). ' .
'On the server run: sudo systemctl reload nginx, or set Reload wrapper script in Admin under Configuration for the Holesail Tunnel NGINX settings. ' .
'Until nginx reloads, your tunnel hostname may not route to the tunnel store.'
);
}
if ($wantJson) {
return $this->jsonPayload(true, $msg);
$extra = [];
if ($nginxReloadMsg !== '') {
$extra['nginx_reload_required'] = true;
$extra['nginx_reload_message'] = $nginxReloadMsg;
}
return $this->jsonPayload(true, $msg, $extra);
}
$this->messageManager->addSuccessMessage($msg);
if ($nginxReloadMsg !== '') {
$this->messageManager->addWarningMessage($nginxReloadMsg);
}
return $this->_redirect('*/*/index');
} else {
$msg = match ($result['error'] ?? '') {
@@ -107,6 +150,10 @@ class Index extends Action
private function handleStop(bool $wantJson)
{
$stop = $this->tunnelManager->stop();
$stoppedOrIdle = $stop['ok'] || (($stop['error'] ?? '') === 'not_running');
if ($stoppedOrIdle) {
$this->tunnelStoreProvisioner->teardownPersistedState();
}
if ($stop['ok']) {
$this->magesailLog->add('Tunnel stopped');
if ($wantJson) {
@@ -129,6 +176,18 @@ class Index extends Action
return $wantJson ? $this->jsonPayload($stop['ok'], '') : $this->_redirect('*/*/index');
}
private function isTruthyParam(mixed $value): bool
{
if ($value === true || $value === 1) {
return true;
}
if ($value === false || $value === null || $value === '') {
return false;
}
$s = strtolower(trim((string) $value));
return \in_array($s, ['1', 'true', 'on', 'yes'], true);
}
protected function _isAllowed()
{
return $this->_authorization->isAllowed('MageSail_Magesail::tunnel');
+545
View File
@@ -0,0 +1,545 @@
<?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;
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Model;
/**
* Parses NGINX map blocks into structured data for GUI editing.
*/
class NginxMapParser
{
/**
* @return array{blocks: array<int, array{source: string, target: string, startLine: int, endLine: int, entries: array<int, array{hostname: string, value: string, line: int, comment?: string}>}>}
*/
public function parse(string $content): array
{
$lines = explode("\n", $content);
$blocks = [];
$i = 0;
$len = \count($lines);
while ($i < $len) {
if (preg_match('/^\s*map\s+\$(\w+)\s+\$(\w+)\s*\{/', $lines[$i], $m)) {
$block = [
'source' => $m[1],
'target' => $m[2],
'startLine' => $i,
'endLine' => $i,
'entries' => [],
];
$openBrace = $i;
$depth = 0;
$inBlock = false;
for ($j = $i; $j < $len; $j++) {
$line = $lines[$j];
if (str_contains($line, '{')) {
$depth++;
$inBlock = true;
}
if (str_contains($line, '}')) {
$depth--;
if ($depth === 0 && $inBlock) {
$block['endLine'] = $j;
break;
}
}
if ($inBlock && $j > $openBrace) {
$trimmed = trim($line);
if ($trimmed === '' || $trimmed === '}') {
continue;
}
if (preg_match('/^default\s+(\S+)\s*;\s*(.*)$/', $trimmed, $defaultMatch)) {
$block['default'] = trim($defaultMatch[1], '"\'');
continue;
}
if (preg_match('/^(\S+)\s+(\S+)\s*;\s*(.*)$/', $trimmed, $entry)) {
$hostname = trim($entry[1], '"\'');
$value = trim($entry[2], '"\'');
$comment = trim($entry[3] ?? '');
$block['entries'][] = [
'hostname' => $hostname,
'value' => $value,
'line' => $j,
'comment' => $comment !== '' ? $comment : null,
];
}
}
}
if ($block['endLine'] > $block['startLine']) {
$blocks[] = $block;
}
$i = $block['endLine'] + 1;
} else {
$i++;
}
}
return ['blocks' => $blocks];
}
/**
* Rebuild file content from parsed blocks, preserving non-map content and original map order/position.
*
* Each block must include startLine, endLine from {@see parse()} (GUI saves merge file metadata with edited entries).
*/
public function rebuild(string $originalContent, array $parsedBlocks): string
{
$lines = explode("\n", $originalContent);
$len = \count($lines);
/** @var array<int, array<string, mixed>> $blockAtLine */
$blockAtLine = [];
foreach ($parsedBlocks['blocks'] as $block) {
if (!isset($block['startLine'], $block['endLine'])) {
throw new \InvalidArgumentException(
'Each map block must include startLine and endLine (re-parse the file or use merged save data).'
);
}
$blockAtLine[(int) $block['startLine']] = $block;
}
ksort($blockAtLine, SORT_NUMERIC);
$output = [];
$i = 0;
while ($i < $len) {
if (isset($blockAtLine[$i])) {
$block = $blockAtLine[$i];
foreach ($this->renderMapBlockLines($lines, $block) as $line) {
$output[] = $line;
}
$i = (int) $block['endLine'] + 1;
continue;
}
$output[] = $lines[$i];
$i++;
}
return implode("\n", $output);
}
/**
* @param array<string, mixed> $block
* @return list<string>
*/
private function renderMapBlockLines(array $fileLines, array $block): array
{
$start = (int) $block['startLine'];
$indent = $this->detectIndent($fileLines, $start);
$out = [];
$out[] = sprintf('%smap $%s $%s {', $indent, $block['source'], $block['target']);
$defaultValue = $block['default'] ?? "''";
$out[] = sprintf('%s default %s;', $indent, $defaultValue);
foreach ($block['entries'] as $entry) {
if (!\is_array($entry)) {
continue;
}
$hostname = $this->escapeIfNeeded((string) ($entry['hostname'] ?? ''));
$value = $this->escapeIfNeeded((string) ($entry['value'] ?? ''));
$comment = !empty($entry['comment']) ? ' ' . (string) $entry['comment'] : '';
$out[] = sprintf('%s %s %s;%s', $indent, $hostname, $value, $comment);
}
$out[] = $indent . '}';
return $out;
}
private function detectIndent(array $lines, int $lineNum): string
{
if ($lineNum < \count($lines)) {
$line = $lines[$lineNum];
if (preg_match('/^(\s*)/', $line, $m)) {
return $m[1];
}
}
return '';
}
private function escapeIfNeeded(string $value): string
{
if (preg_match('/^[a-zA-Z0-9._-]+$/', $value)) {
return $value;
}
return '"' . addcslashes($value, '"\\') . '"';
}
}
+20 -1
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace MageSail\Magesail\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\Shell;
@@ -11,9 +12,13 @@ use Magento\Framework\Shell;
*/
class TunnelStatus
{
private const XML_AUTO_RESTART = 'magesail/settings/auto_restart';
public function __construct(
private readonly Shell $shell,
private readonly DirectoryList $directoryList
private readonly DirectoryList $directoryList,
private readonly ScopeConfigInterface $scopeConfig,
private readonly TunnelStoreProvisioner $tunnelStoreProvisioner
) {
}
@@ -47,6 +52,7 @@ class TunnelStatus
return ['running' => false, 'pid' => null, 'key' => null];
}
if (!$this->isProcessAlive($pid)) {
$this->maybeTeardownTunnelStoreForStaleProcess();
@unlink($pidFile);
$kf = $this->getKeyFilePath();
if (\is_file($kf)) {
@@ -93,6 +99,7 @@ class TunnelStatus
}
$pid = (int) trim((string) file_get_contents($pidFile));
if ($pid <= 0 || !$this->isProcessAlive($pid)) {
$this->maybeTeardownTunnelStoreForStaleProcess();
@unlink($pidFile);
$keyFile = $this->getKeyFilePath();
if (\is_file($keyFile)) {
@@ -100,4 +107,16 @@ class TunnelStatus
}
}
}
/**
* When the tunnel process is gone and auto-restart is off, remove the tunnel store view.
*/
private function maybeTeardownTunnelStoreForStaleProcess(): void
{
$autoRestart = (bool) $this->scopeConfig->getValue(self::XML_AUTO_RESTART);
if ($autoRestart) {
return;
}
$this->tunnelStoreProvisioner->teardownAfterStaleProcess();
}
}
+258
View File
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Model;
use Magento\Framework\App\Cache\TypeListInterface;
use Magento\Framework\App\Config\ReinitableConfigInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\Config\Storage\WriterInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Store\Api\StoreRepositoryInterface;
use Magento\Store\Model\ResourceModel\Store as StoreResource;
use Magento\Store\Model\ScopeInterface;
use Magento\Store\Model\StoreFactory;
use Magento\Store\Model\StoreManagerInterface;
use Psr\Log\LoggerInterface;
use MageSail\Magesail\Model\NginxGlobalMapManager;
/**
* Creates a dedicated store view with tunnel base URLs; tears it down when the tunnel is stopped.
*/
class TunnelStoreProvisioner
{
public const STORE_CODE = 'magesail_tunnel';
private const PATH_UNSECURE_BASE = 'web/unsecure/base_url';
private const PATH_SECURE_BASE = 'web/secure/base_url';
private const PATH_USE_IN_FRONTEND = 'web/secure/use_in_frontend';
private const PATH_USE_IN_ADMIN = 'web/secure/use_in_adminhtml';
public function __construct(
private readonly StoreManagerInterface $storeManager,
private readonly StoreFactory $storeFactory,
private readonly StoreResource $storeResource,
private readonly StoreRepositoryInterface $storeRepository,
private readonly WriterInterface $configWriter,
private readonly TypeListInterface $typeList,
private readonly ReinitableConfigInterface $reinitableConfig,
private readonly TunnelStoreState $tunnelStoreState,
private readonly LoggerInterface $logger,
private readonly NginxGlobalMapManager $nginxMapManager,
private readonly ScopeConfigInterface $scopeConfig
) {
}
/**
* Remove store + state file after stale PID cleanup when auto-restart is disabled.
*/
public function teardownAfterStaleProcess(): void
{
$this->teardownPersistedState();
}
/**
* Remove tunnel store and state file (idempotent).
*/
public function teardownPersistedState(): void
{
$state = $this->tunnelStoreState->read();
$this->tunnelStoreState->delete();
if ($state === null) {
$this->flushConfigCache();
return;
}
$store = $this->storeFactory->create();
$this->storeResource->load($store, $state['store_id']);
if (!$store->getId()) {
$this->flushConfigCache();
return;
}
$websiteId = (int) $store->getWebsiteId();
try {
$website = $this->storeManager->getWebsite($websiteId);
} catch (LocalizedException $e) {
$this->logger->warning('MageSail tunnel store teardown: ' . $e->getMessage());
$this->flushConfigCache();
return;
}
$defaultStoreId = (int) $website->getDefaultStoreId();
if ((int) $store->getId() === $defaultStoreId) {
$this->logger->warning(
'MageSail: tunnel store is the website default; skipping delete. Change default store view in Admin.'
);
$this->flushConfigCache();
return;
}
try {
$hostname = $this->nginxMapManager->extractHostname($state['unsecure_base_url']);
$this->nginxMapManager->removeTunnelMapping($hostname);
$validation = $this->nginxMapManager->validateAndReload();
if (!$validation['valid']) {
$this->logger->warning('NGINX config validation failed after tunnel map removal: ' . ($validation['error'] ?? 'unknown'));
} elseif (!$validation['reloaded']) {
$this->logger->warning('NGINX config valid but reload failed after map removal: ' . ($validation['error'] ?? 'unknown'));
}
} catch (LocalizedException $e) {
$this->logger->warning('NGINX global map removal skipped: ' . $e->getMessage());
}
try {
$this->storeResource->delete($store);
$this->storeRepository->clean();
$this->storeManager->reinitStores();
} catch (\Throwable $e) {
$this->logger->error('MageSail tunnel store delete failed: ' . $e->getMessage());
}
$this->flushConfigCache();
}
/**
* @return array{store_id: int, store_code: string, nginx_reloaded: bool}
* @throws LocalizedException
*/
public function provision(string $unsecureBase, string $secureBase, bool $useSecureUrls): array
{
$unsecureBase = $this->normalizeBaseUrl($unsecureBase);
$secureBase = $this->normalizeBaseUrl($secureBase !== '' ? $secureBase : $this->deriveSecureFromUnsecure($unsecureBase));
$this->assertValidUrl($unsecureBase);
$this->assertValidUrl($secureBase);
$default = $this->storeManager->getDefaultStoreView();
if ($default === null) {
throw new LocalizedException(__('No default store view is configured.'));
}
$websiteId = (int) $default->getWebsiteId();
$groupId = (int) $default->getStoreGroupId();
$store = $this->loadOrCreateStore($websiteId, $groupId);
$storeId = (int) $store->getId();
$this->configWriter->save(self::PATH_UNSECURE_BASE, $unsecureBase, ScopeInterface::SCOPE_STORES, $storeId);
$this->configWriter->save(self::PATH_SECURE_BASE, $secureBase, ScopeInterface::SCOPE_STORES, $storeId);
$useSecure = $useSecureUrls ? '1' : '0';
$this->configWriter->save(self::PATH_USE_IN_FRONTEND, $useSecure, ScopeInterface::SCOPE_STORES, $storeId);
$this->configWriter->save(self::PATH_USE_IN_ADMIN, $useSecure, ScopeInterface::SCOPE_STORES, $storeId);
$disableRedirect = (bool) $this->scopeConfig->getValue('magesail/nginx/disable_base_url_redirect');
if ($disableRedirect) {
$this->configWriter->save('web/url/redirect_to_base', '0', ScopeInterface::SCOPE_STORES, $storeId);
}
$nginxReloaded = true;
try {
$hostname = $this->nginxMapManager->extractHostname($unsecureBase);
$this->nginxMapManager->addTunnelMapping($hostname, self::STORE_CODE);
$validation = $this->nginxMapManager->validateAndReload();
if (!$validation['valid']) {
$msg = 'NGINX config validation failed after tunnel map update: ' . ($validation['error'] ?? 'unknown');
$this->logger->error($msg);
throw new LocalizedException(__('Tunnel store was not finalized: NGINX map validation failed: %1', $validation['error'] ?? 'unknown'));
}
$nginxReloaded = !empty($validation['reloaded']);
if (!$nginxReloaded) {
$this->logger->warning(
'NGINX map updated for tunnel but reload failed (reload nginx manually): '
. ($validation['error'] ?? 'unknown')
);
}
} catch (LocalizedException $e) {
$this->logger->error('NGINX global map update failed: ' . $e->getMessage());
throw $e;
}
$this->tunnelStoreState->write([
'store_id' => $storeId,
'store_code' => self::STORE_CODE,
'unsecure_base_url' => $unsecureBase,
'secure_base_url' => $secureBase,
'use_secure_urls' => $useSecureUrls,
'nginx_pending_reload' => !$nginxReloaded,
]);
$this->storeRepository->clean();
$this->storeManager->reinitStores();
$this->flushConfigCache();
return [
'store_id' => $storeId,
'store_code' => self::STORE_CODE,
'nginx_reloaded' => $nginxReloaded,
];
}
/**
* @return array{store_id: int, store_code: string, unsecure_base_url: string, secure_base_url: string, use_secure_urls: bool, nginx_pending_reload?: bool}|null
*/
public function getPersistedState(): ?array
{
return $this->tunnelStoreState->read();
}
/**
* Call after nginx reload succeeds (e.g. Admin NGINX map "reload") to clear the tunnel "reload nginx" banner.
*/
public function clearNginxPendingReloadFlag(): void
{
$state = $this->tunnelStoreState->read();
if ($state === null || !($state['nginx_pending_reload'] ?? false)) {
return;
}
$state['nginx_pending_reload'] = false;
$this->tunnelStoreState->write($state);
}
private function loadOrCreateStore(int $websiteId, int $groupId): \Magento\Store\Api\Data\StoreInterface
{
try {
$existing = $this->storeRepository->get(self::STORE_CODE);
$store = $existing;
$store->setName((string) __('MageSail Tunnel'));
$store->setWebsiteId($websiteId);
$store->setStoreGroupId($groupId);
$store->setIsActive(true);
$this->storeResource->save($store);
return $store;
} catch (NoSuchEntityException) {
$store = $this->storeFactory->create();
$store->setCode(self::STORE_CODE);
$store->setName((string) __('MageSail Tunnel'));
$store->setWebsiteId($websiteId);
$store->setStoreGroupId($groupId);
$store->setIsActive(true);
$this->storeResource->save($store);
return $store;
}
}
private function normalizeBaseUrl(string $url): string
{
return rtrim(trim($url), '/') . '/';
}
private function deriveSecureFromUnsecure(string $unsecureBase): string
{
if (stripos($unsecureBase, 'http://') === 0) {
return 'https://' . substr($unsecureBase, \strlen('http://'));
}
return $unsecureBase;
}
private function assertValidUrl(string $url): void
{
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
throw new LocalizedException(__('Invalid base URL: %1', $url));
}
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!\in_array($scheme, ['http', 'https'], true)) {
throw new LocalizedException(__('Base URL must use http or https: %1', $url));
}
}
private function flushConfigCache(): void
{
$this->typeList->cleanType('config');
$this->typeList->cleanType('full_page');
$this->reinitableConfig->reinit();
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Model;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\Serialize\Serializer\Json;
/**
* Persists tunnel store metadata in var/ for teardown and admin display.
*/
class TunnelStoreState
{
private const FILENAME = 'magesail_tunnel_store.json';
public function __construct(
private readonly DirectoryList $directoryList,
private readonly Json $json
) {
}
public function getFilePath(): string
{
return $this->directoryList->getPath(DirectoryList::VAR_DIR) . '/' . self::FILENAME;
}
/**
* @return array{store_id: int, store_code: string, unsecure_base_url: string, secure_base_url: string, use_secure_urls: bool, nginx_pending_reload?: bool}|null
*/
public function read(): ?array
{
$path = $this->getFilePath();
if (!\is_readable($path)) {
return null;
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
return null;
}
try {
/** @var array $data */
$data = $this->json->unserialize($raw);
} catch (\InvalidArgumentException) {
return null;
}
if (!isset($data['store_id'], $data['store_code'])) {
return null;
}
return [
'store_id' => (int) $data['store_id'],
'store_code' => (string) $data['store_code'],
'unsecure_base_url' => (string) ($data['unsecure_base_url'] ?? ''),
'secure_base_url' => (string) ($data['secure_base_url'] ?? ''),
'use_secure_urls' => (bool) ($data['use_secure_urls'] ?? false),
'nginx_pending_reload' => (bool) ($data['nginx_pending_reload'] ?? false),
];
}
/**
* @param array{store_id: int, store_code: string, unsecure_base_url: string, secure_base_url: string, use_secure_urls: bool, nginx_pending_reload?: bool} $data
*/
public function write(array $data): void
{
$path = $this->getFilePath();
$payload = $this->json->serialize($data);
if (file_put_contents($path, $payload, LOCK_EX) === false) {
throw new \RuntimeException('Could not write tunnel store state file.');
}
}
public function delete(): void
{
$path = $this->getFilePath();
if (\is_file($path)) {
@unlink($path);
}
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Plugin\App\Request;
use Magento\Framework\App\Request\Http;
use Magento\Store\Model\StoreManager;
/**
* When request has X-MageSail-Store-Code header or magesail_store query param (from tunnel proxy),
* make getServerValue return it for MAGE_RUN_CODE and 'store' for MAGE_RUN_TYPE so Magento serves that store.
*/
class StoreFromMageSailHeaderPlugin
{
private const HEADER_STORE_CODE = 'X-MageSail-Store-Code';
private const QUERY_PARAM_STORE = 'magesail_store';
/**
* Around getServerValue: if MageSail store header is set, return it for MAGE_RUN_CODE / MAGE_RUN_TYPE.
*
* @param Http $subject
* @param callable $proceed
* @param string|null $name
* @param mixed $default
* @return mixed
*/
public function aroundGetServerValue(Http $subject, callable $proceed, $name = null, $default = null)
{
$storeCode = $this->getStoreCodeFromRequest($subject);
if ($storeCode === null) {
return $proceed($name, $default);
}
if ($name === StoreManager::PARAM_RUN_CODE) {
return $storeCode;
}
if ($name === StoreManager::PARAM_RUN_TYPE) {
return 'store';
}
return $proceed($name, $default);
}
private function getStoreCodeFromRequest(Http $request): ?string
{
$value = $request->getParam(self::QUERY_PARAM_STORE);
if ($value !== null && trim((string) $value) !== '') {
return trim((string) $value);
}
$value = $request->getHeader(self::HEADER_STORE_CODE);
if ($value !== false && $value !== null && trim((string) $value) !== '') {
return trim((string) $value);
}
$value = $request->getServer('HTTP_X_MAGESAIL_STORE_CODE');
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
return trim((string) $value);
}
return null;
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Plugin\FrontController;
use Magento\Framework\App\FrontController;
use Magento\Framework\App\RequestInterface;
/**
* Run before Store RequestPreprocessor (sortOrder 10 < 50).
* Ensure magesail_store is on the request from superglobals so base-URL redirect skip works
* even if the Request object was built before query params were fully available.
*/
class EnsureTunnelParamPlugin
{
private const QUERY_PARAM_STORE = 'magesail_store';
private const SERVER_HEADER = 'HTTP_X_MAGESAIL_STORE_CODE';
public function aroundDispatch(
FrontController $subject,
\Closure $proceed,
RequestInterface $request
) {
$this->ensureTunnelParamOnRequest($request);
return $proceed($request);
}
/**
* If tunnel indicator is in $_GET or REQUEST_URI or header, set it on the request
* so later plugins (BaseUrlChecker, RequestPreprocessor skip) see it.
*/
private function ensureTunnelParamOnRequest(RequestInterface $request): void
{
$value = $this->getTunnelStoreFromSuperglobals();
if ($value === null) {
return;
}
if (!$request instanceof \Magento\Framework\App\Request\Http) {
return;
}
if ($request->getParam(self::QUERY_PARAM_STORE) !== null) {
return;
}
$request->setParam(self::QUERY_PARAM_STORE, $value);
}
private function getTunnelStoreFromSuperglobals(): ?string
{
if (!empty($_GET[self::QUERY_PARAM_STORE])) {
$v = trim((string) $_GET[self::QUERY_PARAM_STORE]);
if ($v !== '') {
return $v;
}
}
$uri = $_SERVER['REQUEST_URI'] ?? '';
if ($uri !== '' && str_contains($uri, self::QUERY_PARAM_STORE . '=')) {
$query = str_contains($uri, '?') ? substr($uri, strpos($uri, '?') + 1) : '';
parse_str($query, $params);
$v = $params[self::QUERY_PARAM_STORE] ?? '';
if ($v !== '' && trim((string) $v) !== '') {
return trim((string) $v);
}
}
$h = $_SERVER[self::SERVER_HEADER] ?? '';
if ($h !== '' && trim((string) $h) !== '') {
return trim((string) $h);
}
return null;
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Plugin\Store;
use Magento\Framework\App\Request\Http;
use Magento\Framework\App\RequestInterface;
use Magento\Store\Model\BaseUrlChecker;
/**
* When request has magesail_store (tunnel proxy), consider base URL valid so no 301 is issued.
*/
class BaseUrlCheckerTunnelPlugin
{
private const QUERY_PARAM_STORE = 'magesail_store';
private const SERVER_HEADER = 'HTTP_X_MAGESAIL_STORE_CODE';
/** @var RequestInterface */
private $request;
public function __construct(RequestInterface $request)
{
$this->request = $request;
}
/**
* Disable redirect-to-base when tunnel param is present so RequestPreprocessor skips the block.
*
* @param BaseUrlChecker $subject
* @return array|null [false] to disable, null to proceed
*/
public function beforeIsEnabled(BaseUrlChecker $subject): ?array
{
if ($this->request instanceof Http && $this->hasTunnelStore($this->request)) {
return [false];
}
return null;
}
/**
* If tunnel store indicator is present, treat URL as valid (skip redirect).
*
* @param BaseUrlChecker $subject
* @param callable $proceed
* @param array $uri
* @param Http $request
* @return bool
*/
public function aroundExecute(BaseUrlChecker $subject, callable $proceed, $uri, $request): bool
{
if ($request instanceof Http && $this->hasTunnelStore($request)) {
return true;
}
return $proceed($uri, $request);
}
private function hasTunnelStore(Http $request): bool
{
$value = $request->getParam(self::QUERY_PARAM_STORE);
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
return true;
}
if (method_exists($request, 'getQueryValue')) {
$value = $request->getQueryValue(self::QUERY_PARAM_STORE);
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
return true;
}
}
$uri = $request->getRequestUri();
if ($uri !== null && str_contains((string) $uri, self::QUERY_PARAM_STORE . '=')) {
return true;
}
$value = $request->getServer(self::SERVER_HEADER);
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
return true;
}
// Fallback: superglobals (e.g. if request was built before query string was available)
return $this->hasTunnelStoreFromSuperglobals();
}
private function hasTunnelStoreFromSuperglobals(): bool
{
if (!empty($_GET[self::QUERY_PARAM_STORE]) && trim((string) $_GET[self::QUERY_PARAM_STORE]) !== '') {
return true;
}
$uri = $_SERVER['REQUEST_URI'] ?? '';
if ($uri !== '' && str_contains($uri, self::QUERY_PARAM_STORE . '=')) {
return true;
}
$h = $_SERVER[self::SERVER_HEADER] ?? '';
return $h !== '' && trim((string) $h) !== '';
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Plugin\Store;
use Magento\Framework\App\FrontController;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\ResponseInterface;
/**
* When request has X-MageSail-Store-Code (tunnel proxy), skip the base URL redirect
* so developers can use http://127.0.0.1:port without being redirected to the store's base URL.
*/
class SkipBaseUrlRedirectForTunnelPlugin
{
/** $_SERVER key for X-MageSail-Store-Code (web server passes headers as HTTP_*). */
private const SERVER_HEADER_STORE_CODE = 'HTTP_X_MAGESAIL_STORE_CODE';
/** Query param fallback when server strips custom headers (proxy appends this). */
private const QUERY_PARAM_STORE = 'magesail_store';
/**
* Skip base URL redirect when tunnel store header is present.
*
* @param \Magento\Store\App\FrontController\Plugin\RequestPreprocessor $subject
* @param callable $proceed
* @param FrontController $frontController
* @param \Closure $chain
* @param RequestInterface $request
* @return ResponseInterface
*/
public function aroundDispatch(
\Magento\Store\App\FrontController\Plugin\RequestPreprocessor $subject,
callable $proceed,
FrontController $frontController,
\Closure $chain,
RequestInterface $request
) {
if ($this->hasTunnelStoreHeader($request)) {
$request->setDispatched(false);
return $chain($request);
}
return $proceed($frontController, $chain, $request);
}
private function hasTunnelStoreHeader(RequestInterface $request): bool
{
$value = $request->getParam(self::QUERY_PARAM_STORE);
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
return true;
}
if (!$request instanceof \Magento\Framework\App\Request\Http) {
return false;
}
/** @var \Magento\Framework\App\Request\Http $request */
if (method_exists($request, 'getQueryValue')) {
$value = $request->getQueryValue(self::QUERY_PARAM_STORE);
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
return true;
}
}
$uri = $request->getRequestUri();
if ($uri !== null && str_contains((string) $uri, self::QUERY_PARAM_STORE . '=')) {
return true;
}
$value = $request->getServer(self::SERVER_HEADER_STORE_CODE);
return $value !== null && $value !== '' && trim((string) $value) !== '';
}
}
+129
View File
@@ -0,0 +1,129 @@
# Tunnel and base URL redirect (301)
## Tunnel store view (MageSail admin)
When you start the tunnel from **Admin → Holesail Tunnel**, you must enter **base URLs** that match the hostname shown in the browser when using [holesail-browser](https://git.ssh.surf/snxraven/holesail-browser) (your virtual host there and Magentos **Base URL** / **Base URL (Secure)** for the tunnel store must align, including `http` vs `https`).
MageSail then:
1. Creates or updates a dedicated store view with code **`magesail_tunnel`** on the default websites default store group.
2. Sets scoped config: `web/unsecure/base_url`, `web/secure/base_url`, and (if you check the box) `web/secure/use_in_frontend` / `web/secure/use_in_adminhtml`.
3. Writes `var/magesail_tunnel_store.json` so the same store can be torn down when the tunnel stops.
4. **Stop tunnel** in admin → that store view is deleted (unless it is the website default—see logs). If the tunnel **process dies** and **auto-restart** is **off**, stale cleanup also removes the store; if **auto-restart** is **on**, the store and URLs stay so cron can restart the process without re-entering URLs.
**Proxy / holesail-browser:** Forward the tunnel hostname Magento expects, and select the tunnel store so links and redirects stay on that host. Either:
- Header: `X-MageSail-Store-Code: magesail_tunnel`, or
- Query: `magesail_store=magesail_tunnel`, or
- Native Magento: `___store=magesail_tunnel`
See **StoreFromMageSailHeaderPlugin** and related frontend plugins in this module.
### NGINX global map (JetRails / Ansible style)
If your stack uses the standard global map:
```nginx
map $http_host $MAGE_RUN_CODE {
default '';
}
map $http_host $MAGE_RUN_TYPE {
default '';
}
```
configure **Stores → Configuration → Holesail Tunnel → NGINX Configuration**:
- **Global map file path** — absolute path to that file (the one managed from Ansible).
- Defaults: source `http_host`, maps `MAGE_RUN_CODE` and `MAGE_RUN_TYPE`, type value `store`.
When a tunnel starts, MageSail inserts **two** lines (before each maps closing `}`):
- `test.magento.local magesail_tunnel; # MageSail tunnel`
- `test.magento.local store; # MageSail tunnel`
So PHP-FPM receives `MAGE_RUN_CODE` / `MAGE_RUN_TYPE` for the tunneled host without hand-editing Ansible. On tunnel stop, those lines are removed.
**Validation / reload:** MageSail runs `nginx -t -c <minimal.conf>` then `nginx -s reload`. The minimal config contains a single `pid` (writable path), `events {}`, and `http { include <your global map>; }`—it does **not** load `/etc/nginx/nginx.conf`, so you avoid duplicate `pid` errors and `/run/nginx.pid` permission issues. **Temporary PID file** in config sets that `pid` path (default under system temp). This checks **map file syntax**, not SSL vhosts. Reload still targets the real master process.
Commands run via `proc_open` so stderr is captured.
**Permissions (PHP-FPM vs SSH user):** Admin runs as the **web/PHP user** (e.g. `www-data`, `nginx`), not your SSH login (`s1x64._Quae0`). The map file must be **writable by that user**. Typical patterns:
- File is `664` and group `jetrails`: add PHP user to that group, then restart PHP-FPM:
`usermod -aG jetrails www-data` (replace `www-data` with your pool user from `grep '^user' /etc/php-fpm.d/www.conf`).
- Or use ACL on the file only:
`setfacl -m u:www-data:rw /etc/nginx/conf.d/global-map.conf`
MageSail only requires the **directory** writable when the map file does **not** exist yet; for an existing `global-map.conf`, only the **file** must be writable.
---
## Goal
When developers use the MageSail tunnel (e.g. `http://127.0.0.1:8086/`), Magento should **not** redirect to the stores base URL (e.g. `https://s1.x64.world/`). The request should be handled in place and return 200.
## Where the redirect comes from
The only place in Magento core that performs the “redirect to base URL” is:
- **`Magento\Store\App\FrontController\Plugin\RequestPreprocessor::aroundDispatch`**
It runs as a plugin on **`Magento\Framework\App\FrontController::dispatch`** (frontend only: `module-store/etc/frontend/di.xml`, sortOrder 50).
Logic:
1. If `getBaseUrlChecker()->isEnabled()` and request is not POST, it gets the store base URL.
2. It parses the base URL and calls `getBaseUrlChecker()->execute($uri, $request)`.
3. If `execute()` returns `false` (URL “invalid”), it builds a redirect URL via `$this->_url->getRedirectUrl($this->_url->getDirectUrl(...))` and returns a response with `setRedirect($redirectUrl, 301 or 302)`.
- **`Magento\Store\Model\BaseUrlChecker`**
- `isEnabled()`: reads `web/url/redirect_to_base` (store scope).
- `execute($uri, $request)`: compares request scheme, host, and path to the parsed base URL; returns true only if they match.
So the 301 is issued by **RequestPreprocessor** when **BaseUrlChecker::isEnabled()** is true and **BaseUrlChecker::execute()** returns false (request host/scheme/path dont match the store base URL).
## Request flow (relevant part)
1. `Magento\Framework\App\Http::launch()`:
- Resolves area from `$this->_request->getFrontName()`.
- Loads area config (e.g. frontend).
- Gets `FrontControllerInterface` and calls `$frontController->dispatch($this->_request)`.
2. **FrontController::dispatch** is wrapped by plugins (frontend). One of them is **RequestPreprocessor** (sortOrder 50). When the chain reaches it, **RequestPreprocessor::aroundDispatch** runs and may return the redirect response **before** the actual router loop runs.
3. The **Request** object is the same `Http` instance from bootstrap; it is populated from PHP (e.g. `$_GET`, `$_SERVER['REQUEST_URI']`) by the Laminas/Magento request implementation. So in normal setups, `?magesail_store=default` should be visible via `$request->getParam('magesail_store')` and `getRequestUri()`.
## What can still cause 301
1. **Redirect happens before PHP**
If the request hits a reverse proxy (e.g. nginx, JetRails) that does **HTTP → HTTPS** or “redirect to canonical host” **before** passing the request to PHP, Magento never runs and the 301 is from the edge. Our plugins cannot change that. Fix: proxy to an HTTP backend that does not redirect (e.g. different vhost/port or PHP built-in server for dev), or configure the edge to not redirect when e.g. `X-Forwarded-Proto: https` is present.
2. **Request doesnt contain `magesail_store` when plugins run**
In theory the request is built once and should include query params. If in some deployment the request is built or modified in a way that doesnt expose `magesail_store` (or the header) when our plugins run, we would still enter the redirect block. We mitigate this with:
- An early **FrontController** plugin that copies the tunnel indicator from `$_GET` / `REQUEST_URI` / header onto the request.
- Fallbacks in our **BaseUrlChecker** and **SkipBaseUrlRedirect** plugins to superglobals.
## MageSails approach (no base URL redirect for tunnel)
We use several layers so that when the tunnel is detected, the base-URL redirect is skipped:
1. **`EnsureTunnelParamPlugin`** (plugin on `FrontController::dispatch`, sortOrder 10)
Runs before RequestPreprocessor (50). If `magesail_store` (or header) is in `$_GET`, `REQUEST_URI`, or `HTTP_X_MAGESAIL_STORE_CODE`, it **sets** `magesail_store` on the request. So later code always sees the param.
2. **`SkipBaseUrlRedirectForTunnelPlugin`** (plugin on `RequestPreprocessor::aroundDispatch`, sortOrder 1)
If the request has the tunnel store (param or header), we **do not** call the original RequestPreprocessor; we call the next closure in the FrontController chain (`$chain($request)`), so the redirect block is never run.
3. **`BaseUrlCheckerTunnelPlugin`**
- **beforeIsEnabled**: if the request (or superglobals) has the tunnel indicator, return `false` so RequestPreprocessors condition `getBaseUrlChecker()->isEnabled()` is false and the whole redirect block is skipped.
- **aroundExecute**: if the request (or superglobals) has the tunnel indicator, return `true` (URL considered valid) so even if the block is entered, no redirect is issued.
4. **StoreFromMageSailHeaderPlugin** (on `Request::getServerValue`)
Makes Magento resolve the store from `magesail_store` or `X-MageSail-Store-Code`, so the correct store is used and URLs (including any redirect URL) are for that store. This does not by itself prevent the redirect; it only ensures that when redirect *is* built, it uses the right store (and we still avoid building it via the plugins above).
## Summary
- The **only** Magento code that performs the base-URL 301 is **RequestPreprocessor**, using **BaseUrlChecker::isEnabled()** and **BaseUrlChecker::execute()**.
- We disable that redirect for tunnel traffic by: (1) ensuring the request has the tunnel param early, (2) skipping RequestPreprocessors redirect when tunnel is present, (3) making BaseUrlChecker report “enabled = false” and “execute = true” when tunnel is present.
- If 301 still appears, it is likely from the **web server / reverse proxy** (e.g. HTTP→HTTPS) before the request reaches PHP; in that case the hosting layer must be adjusted or the tunnel must target an endpoint that does not perform that redirect.
+1
View File
@@ -3,5 +3,6 @@
<menu>
<add id="MageSail_Magesail::tunnel" title="Holesail Tunnel" module="MageSail_Magesail" sortOrder="10" parent="Magento_Backend::system" resource="MageSail_Magesail::tunnel" />
<add id="MageSail_Magesail::tunnel_index" title="Manage Tunnel" module="MageSail_Magesail" sortOrder="10" action="magesail/tunnel/index" resource="MageSail_Magesail::tunnel" parent="MageSail_Magesail::tunnel" />
<add id="MageSail_Magesail::nginx_map" title="NGINX Global Map" module="MageSail_Magesail" sortOrder="20" action="magesail/nginx/map" resource="MageSail_Magesail::config" parent="MageSail_Magesail::tunnel" />
</menu>
</config>
+41
View File
@@ -30,6 +30,47 @@
<comment>If Yes, daily cron restarts the tunnel when the process is no longer running (uses port/secure above).</comment>
</field>
</group>
<group id="nginx" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1">
<label>NGINX Configuration</label>
<field id="global_map_path" translate="label" type="text" sortOrder="10" showInDefault="1">
<label>Global map file path</label>
<comment>Absolute path to your Ansible global map (e.g. the file that contains map $http_host $MAGE_RUN_CODE). MageSail appends lines inside those blocks—do not use a separate MageSail-only map unless your vhost includes it.</comment>
</field>
<field id="nginx_binary" translate="label" type="text" sortOrder="15" showInDefault="1">
<label>nginx binary path</label>
<comment>Default "nginx" (PATH). Use full path if PHP cannot find nginx, e.g. /usr/sbin/nginx. Used for nginx -t and reload.</comment>
</field>
<field id="test_pid_path" translate="label" type="text" sortOrder="16" showInDefault="1">
<label>Temporary PID file (nginx -t minimal config)</label>
<comment>MageSail validates syntax with a tiny generated nginx config (not main nginx.conf) that includes only your global map—avoids duplicate pid and /run/nginx.pid permission errors. Leave empty to use a unique file under system temp per validation (writable by PHP; avoids a stale root-owned fixed path). Set explicitly only if you need a fixed path.</comment>
</field>
<field id="reload_wrapper_script" translate="label" type="text" sortOrder="17" showInDefault="1">
<label>Reload wrapper script (optional)</label>
<comment>Absolute path to an executable that reloads nginx (e.g. script calling sudo -n /usr/sbin/nginx -s reload). Leave empty to run nginx -s reload as PHP (often fails with kill EPERM). See app/code/MageSail/Magesail/scripts/README-NGINX-RELOAD.md and nginx-reload-probe.php.</comment>
</field>
<field id="map_source_variable" translate="label" type="text" sortOrder="20" showInDefault="1">
<label>Map source variable</label>
<comment>First argument of map directives (without $). Must match your global map, usually http_host.</comment>
</field>
<field id="map_run_code_variable" translate="label" type="text" sortOrder="30" showInDefault="1">
<label>MAGE_RUN_CODE map variable name</label>
<comment>Second map name (without $), usually MAGE_RUN_CODE. MageSail inserts "hostname storecode;" here.</comment>
</field>
<field id="map_run_type_variable" translate="label" type="text" sortOrder="40" showInDefault="1">
<label>MAGE_RUN_TYPE map variable name</label>
<comment>Second map name (without $), usually MAGE_RUN_TYPE. MageSail inserts "hostname store;" here so Magento runs in store scope.</comment>
</field>
<field id="map_run_type_value" translate="label" type="text" sortOrder="50" showInDefault="1">
<label>Value for MAGE_RUN_TYPE line</label>
<comment>Usually store when using a store view code in MAGE_RUN_CODE.</comment>
</field>
<field id="disable_base_url_redirect" translate="label" type="select" sortOrder="60" showInDefault="1">
<label>Disable base URL redirect for tunnel</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
<default>1</default>
<comment>If Yes, disables web/url/redirect_to_base for the tunnel store view to prevent redirects.</comment>
</field>
</group>
</section>
</system>
</config>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
<default>
<magesail>
<nginx>
<map_source_variable>http_host</map_source_variable>
<map_run_code_variable>MAGE_RUN_CODE</map_run_code_variable>
<map_run_type_variable>MAGE_RUN_TYPE</map_run_type_variable>
<map_run_type_value>store</map_run_type_value>
</nginx>
</magesail>
</default>
</config>
+3
View File
@@ -1,3 +1,6 @@
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Framework\App\Request\Http">
<plugin name="magesail_store_from_header" type="MageSail\Magesail\Plugin\App\Request\StoreFromMageSailHeaderPlugin"/>
</type>
</config>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Framework\App\FrontController">
<plugin name="magesail_ensure_tunnel_param" type="MageSail\Magesail\Plugin\FrontController\EnsureTunnelParamPlugin" sortOrder="10"/>
</type>
<type name="Magento\Store\App\FrontController\Plugin\RequestPreprocessor">
<plugin name="magesail_skip_baseurl_redirect" type="MageSail\Magesail\Plugin\Store\SkipBaseUrlRedirectForTunnelPlugin" sortOrder="1"/>
</type>
<type name="Magento\Store\Model\BaseUrlChecker">
<plugin name="magesail_baseurl_checker_tunnel" type="MageSail\Magesail\Plugin\Store\BaseUrlCheckerTunnelPlugin"/>
</type>
</config>
+2 -1
View File
@@ -1,9 +1,10 @@
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="MageSail_Magesail" setup_version="1.0.2">
<module name="MageSail_Magesail" setup_version="1.0.3">
<sequence>
<module name="Magento_Backend"/>
<module name="Magento_Config"/>
<module name="Magento_Store"/>
</sequence>
</module>
</config>
+77
View File
@@ -0,0 +1,77 @@
# NGINX test & reload from PHP (MageSail)
Magentos PHP user (e.g. `www-data`) usually **cannot** send `SIGHUP` to the nginx master (`nginx -s reload``kill(..., 1)`**Operation not permitted**). Validation with a **minimal config** (`nginx -t -c …`) can still work because it does not touch the live master.
## 1. Probe (find what works on your server)
Run **as the same user as PHP-FPM**:
```bash
sudo -u www-data php /path/to/magento/app/code/MageSail/Magesail/scripts/nginx-reload-probe.php \
--map=/etc/nginx/conf.d/global-map.conf \
--bin=/usr/sbin/nginx
```
Then try real reload attempts (reloads production nginx):
```bash
sudo -u www-data php .../nginx-reload-probe.php --try-reload --map=/etc/nginx/conf.d/global-map.conf --bin=/usr/sbin/nginx
```
JSON:
```bash
sudo -u www-data php .../nginx-reload-probe.php --json --try-reload
```
## 2. Typical fix: wrapper + sudoers
1. Install the example script:
```bash
sudo cp app/code/MageSail/Magesail/scripts/magesail-nginx-reload.sh.example /usr/local/bin/magesail-nginx-reload
sudo chmod 755 /usr/local/bin/magesail-nginx-reload
sudo chown root:root /usr/local/bin/magesail-nginx-reload
```
2. Allow **only** that script for the PHP user (replace `www-data`):
```text
# /etc/sudoers.d/magesail-nginx-reload
www-data ALL=(root) NOPASSWD: /usr/local/bin/magesail-nginx-reload
```
3. Wrapper that calls sudo (PHP runs the wrapper; wrapper escalates):
Create `/usr/local/bin/magesail-nginx-reload-php` owned by root, mode `755`:
```bash
#!/bin/sh
exec sudo -n /usr/local/bin/magesail-nginx-reload
```
Sudoers:
```text
www-data ALL=(root) NOPASSWD: /usr/local/bin/magesail-nginx-reload
```
PHP user runs `/usr/local/bin/magesail-nginx-reload-php` (no sudo in MageSail config) — **or** set MageSail **Reload wrapper script** to `/usr/local/bin/magesail-nginx-reload-php` if that script only contains the sudo line… Actually simpler: set **Reload wrapper script** in admin to a script that is **executable by www-data** and contains:
```bash
#!/bin/sh
exec sudo -n /usr/local/bin/magesail-nginx-reload
```
chmod 755, owned by root — www-data can execute it; inside it calls `sudo -n` which is allowed by sudoers for the inner script.
4. In **Stores → Configuration → Holesail Tunnel → NGINX Configuration**, set **Reload wrapper script** to the absolute path of that wrapper (config path `magesail/nginx/reload_wrapper_script`).
## 3. Alternative: `systemctl reload nginx`
If probe shows `systemctl_reload_nginx` works (uncommon for unprivileged users), point the wrapper at `systemctl` or use a sudoers rule for `/bin/systemctl reload nginx`.
## Security
- Keep sudoers to **one fixed path**, not generic `nginx` or `sudo` for arbitrary commands.
- Wrapper script should be root-owned and not writable by the web user.
@@ -0,0 +1,11 @@
#!/bin/sh
# Example wrapper: reload nginx as root. Install as e.g. /usr/local/bin/magesail-nginx-reload
# sudo cp magesail-nginx-reload.sh.example /usr/local/bin/magesail-nginx-reload
# sudo chmod 755 /usr/local/bin/magesail-nginx-reload
# sudo chown root:root /usr/local/bin/magesail-nginx-reload
#
# Option A — nginx runs as root master (typical): call nginx directly.
exec /usr/sbin/nginx -s reload
# Option B — if your policy requires systemctl instead, comment A and use:
# exec /bin/systemctl reload nginx
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env php
<?php
/**
* Standalone probe: which nginx test/reload strategy works for THIS user (e.g. PHP-FPM / www-data)?
*
* Run on the app server as the same user that runs Magento (important):
* sudo -u www-data php /path/to/Magesail/scripts/nginx-reload-probe.php
*
* Or from SSH (shows your user — not the same as web PHP unless you match):
* php nginx-reload-probe.php
*
* Options:
* --json Machine-readable output
* --map=/path Global map file for minimal nginx -t (default: /etc/nginx/conf.d/global-map.conf)
* --bin=/path nginx binary (default: /usr/sbin/nginx)
* --try-reload Actually attempt reload for strategies that look safe (default: off for probe-only)
*/
declare(strict_types=1);
$opts = getopt('', ['json', 'map:', 'bin:', 'try-reload', 'help']);
if (isset($opts['help'])) {
fwrite(STDERR, "Usage: php nginx-reload-probe.php [--json] [--map=PATH] [--bin=PATH] [--try-reload]\n");
exit(0);
}
$asJson = isset($opts['json']);
$mapPath = $opts['map'] ?? '/etc/nginx/conf.d/global-map.conf';
$nginxBin = $opts['bin'] ?? '/usr/sbin/nginx';
$tryReload = isset($opts['try-reload']);
function runArgv(array $argv): array
{
$descriptorspec = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$cmd = implode(' ', array_map('escapeshellarg', $argv));
$process = @proc_open($cmd, $descriptorspec, $pipes, null, null);
if (!is_resource($process)) {
return [127, '', 'proc_open failed for: ' . $cmd];
}
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
$err = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process);
$combined = trim(($out !== '' ? rtrim($out) : '') . ($err !== '' ? "\n" . rtrim($err) : ''));
return [$code, $combined, $cmd];
}
function minimalConfPath(string $mapInclude, string $pidFile): string
{
$map = str_replace('\\', '/', $mapInclude);
$mapQ = preg_match('/^[a-zA-Z0-9._\/-]+$/', $map) ? $map : '"' . addcslashes($map, '"\\') . '"';
$pidQ = preg_match('/^[a-zA-Z0-9._\/-]+$/', $pidFile) ? $pidFile : '"' . addcslashes($pidFile, '"\\') . '"';
$body = "error_log stderr;\npid {$pidQ};\nevents {\n worker_connections 1;\n}\nhttp {\n include {$mapQ};\n}\n";
$tmp = sys_get_temp_dir() . '/magesail-probe-' . getmypid() . '.conf';
file_put_contents($tmp, $body, LOCK_EX);
return $tmp;
}
$user = function_exists('posix_getpwuid') && function_exists('posix_geteuid')
? (posix_getpwuid(posix_geteuid())['name'] ?? 'unknown')
: 'unknown';
$uid = function_exists('posix_geteuid') ? posix_geteuid() : -1;
$results = [
'meta' => [
'user' => $user,
'uid' => $uid,
'sapi' => PHP_SAPI,
'map_path' => $mapPath,
'nginx_bin' => $nginxBin,
],
'tests' => [],
];
// 1) Minimal nginx -t (same idea as MageSail)
// Unique pid path per run: a fixed /tmp/magesail-probe-pid.pid may exist root-owned and break for jetrails.
$pidFile = sys_get_temp_dir() . '/magesail-probe-pid-' . getmypid() . '-' . bin2hex(random_bytes(4)) . '.pid';
$tmpConf = null;
if (is_readable($mapPath)) {
$tmpConf = minimalConfPath($mapPath, $pidFile);
[$c, $o, $shown] = runArgv([$nginxBin, '-t', '-c', $tmpConf]);
$results['tests']['minimal_nginx_t'] = ['exit' => $c, 'ok' => $c === 0, 'output' => $o, 'cmd' => $shown];
@unlink($tmpConf);
@unlink($pidFile);
} else {
$results['tests']['minimal_nginx_t'] = [
'exit' => -1,
'ok' => false,
'output' => 'Map file not readable: ' . $mapPath,
'cmd' => null,
];
}
$reloadStrategies = [
'direct_nginx_reload' => [$nginxBin, '-s', 'reload'],
'sudo_n_nginx_reload' => ['sudo', '-n', $nginxBin, '-s', 'reload'],
'systemctl_reload_nginx' => ['systemctl', 'reload', 'nginx'],
'service_nginx_reload' => ['service', 'nginx', 'reload'],
];
foreach ($reloadStrategies as $name => $argv) {
if (!$tryReload) {
$results['tests'][$name] = [
'skipped' => true,
'hint' => 'Re-run with --try-reload to execute (may reload production nginx).',
];
continue;
}
if ($argv[0] === 'sudo' && !is_executable('/usr/bin/sudo') && !is_executable('/bin/sudo')) {
$results['tests'][$name] = ['exit' => -1, 'ok' => false, 'output' => 'sudo not found', 'skipped_binary' => true];
continue;
}
if ($argv[0] === 'systemctl' && !is_executable('/usr/bin/systemctl') && !is_executable('/bin/systemctl')) {
$results['tests'][$name] = ['exit' => -1, 'ok' => false, 'output' => 'systemctl not found', 'skipped_binary' => true];
continue;
}
if ($argv[0] === 'service' && !is_executable('/usr/sbin/service') && !is_executable('/usr/bin/service')) {
$results['tests'][$name] = ['exit' => -1, 'ok' => false, 'output' => 'service not found', 'skipped_binary' => true];
continue;
}
[$c, $o, $shown] = runArgv($argv);
$results['tests'][$name] = ['exit' => $c, 'ok' => $c === 0, 'output' => $o, 'cmd' => $shown];
}
$results['recommendation'] = [];
if (!empty($results['tests']['minimal_nginx_t']['ok'])) {
$results['recommendation'][] = 'minimal nginx -t works — MageSail map syntax validation can succeed.';
} else {
$results['recommendation'][] = 'Fix minimal_nginx_t first (map path, nginx binary, map file syntax).';
}
if ($tryReload) {
$reloadOk = false;
foreach (['direct_nginx_reload', 'sudo_n_nginx_reload', 'systemctl_reload_nginx', 'service_nginx_reload'] as $k) {
if (!empty($results['tests'][$k]['ok'])) {
$results['recommendation'][] = "WORKING RELOAD: {$k} — use a wrapper or MageSail reload_wrapper_script (see README-NGINX-RELOAD.md).";
$reloadOk = true;
break;
}
}
if (!$reloadOk) {
$results['recommendation'][] = 'Reload: none succeeded as user ' . $user . ' (typical: master is root → kill EPERM; sudo/systemctl need a TTY or NOPASSWD). '
. 'Use a root-owned helper script + /etc/sudoers.d/ allowing ' . $user . ' NOPASSWD for that path only, point MageSail “Reload wrapper script” at a small script that runs sudo -n on the helper. '
. 'See Magesail/scripts/README-NGINX-RELOAD.md and magesail-nginx-reload.sh.example.';
}
} else {
$results['recommendation'][] = 'Run as your PHP user, e.g.: sudo -u ' . $user . ' php ' . __FILE__ . ' --try-reload --map=' . escapeshellarg($mapPath) . ' --bin=' . escapeshellarg($nginxBin);
$results['recommendation'][] = 'If direct_nginx_reload fails (kill EPERM), use sudoers NOPASSWD for a fixed wrapper script (README-NGINX-RELOAD.md).';
}
if ($asJson) {
echo json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
echo "MageSail nginx probe\n";
echo "====================\n";
echo "User: {$user} (uid {$uid}) SAPI: " . PHP_SAPI . "\n\n";
foreach ($results['tests'] as $name => $t) {
echo "[{$name}]\n";
if (!empty($t['skipped'])) {
echo " skipped: {$t['hint']}\n\n";
continue;
}
$ok = $t['ok'] ?? ($t['exit'] === 0);
echo ' ok: ' . ($ok ? 'YES' : 'NO') . ' exit: ' . ($t['exit'] ?? 'n/a') . "\n";
if (!empty($t['output'])) {
echo ' output: ' . str_replace("\n", "\n ", $t['output']) . "\n";
}
if (!empty($t['cmd'])) {
echo " cmd: {$t['cmd']}\n";
}
echo "\n";
}
echo "Recommendation\n";
echo "--------------\n";
foreach ($results['recommendation'] as $line) {
echo "- {$line}\n";
}
}
exit(0);
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Test script: emulate the tunnel proxy and hit Magento to verify redirect behavior.
* Usage: node test-proxy-redirect.js [listenPort] [backendPort] [storeCode] [storeHost]
* Defaults: listenPort=9999, backendPort=80, storeCode=default, storeHost=s1.x64.world
*
* Then: curl -IL http://127.0.0.1:9999/
* If you get 301, the redirect is still happening. If 200, the fix works.
*/
const http = require('http');
const listenPort = parseInt(process.argv[2], 10) || 9999;
const backendPort = parseInt(process.argv[3], 10) || 80;
const storeCode = process.argv[4] || 'default';
const storeHost = process.argv[5] || 's1.x64.world';
console.log('Test proxy: listen', listenPort, '-> backend 127.0.0.1:' + backendPort);
console.log('Headers sent to backend: Host=' + storeHost + ', X-MageSail-Store-Code=' + storeCode + ', X-Forwarded-Proto=https');
console.log('curl -IL http://127.0.0.1:' + listenPort + '/');
console.log('');
const server = http.createServer((clientReq, clientRes) => {
let path = clientReq.url;
const sep = path.includes('?') ? '&' : '?';
path = path + sep + 'magesail_store=' + encodeURIComponent(storeCode);
const opts = {
hostname: '127.0.0.1',
port: backendPort,
path: path,
method: clientReq.method,
headers: {
...clientReq.headers,
host: storeHost,
'x-magesail-store-code': storeCode,
'x-magesail-store-type': 'store',
'x-forwarded-proto': 'https',
},
};
console.log('[proxy]', clientReq.method, clientReq.url, '->', 'http://127.0.0.1:' + backendPort + path);
const backendReq = http.request(opts, (backendRes) => {
console.log('[backend]', backendRes.statusCode, backendRes.headers.location || '');
clientRes.writeHead(backendRes.statusCode, backendRes.headers);
backendRes.pipe(clientRes, { end: true });
});
backendReq.on('error', (err) => {
console.error('[backend error]', err.message);
clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
clientRes.end('Bad Gateway: ' + err.message);
});
clientReq.pipe(backendReq, { end: true });
});
server.listen(listenPort, '127.0.0.1', () => {
console.log('Listening on http://127.0.0.1:' + listenPort + '/');
});
@@ -0,0 +1,11 @@
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<css src="MageSail_Magesail::css/magesail-tunnel.css"/>
</head>
<body>
<referenceContainer name="content">
<block class="MageSail\Magesail\Block\Adminhtml\Nginx\Map" name="magesail.nginx.map" template="MageSail_Magesail::nginx/map.phtml"/>
</referenceContainer>
</body>
</page>
@@ -0,0 +1,392 @@
<?php
/** @var \MageSail\Magesail\Block\Adminhtml\Nginx\Map $block */
$mapPath = $block->getMapFilePath();
$mapContent = $block->getMapContent();
$mapUrl = $block->getMapUrl();
$formKey = $block->escapeHtml($block->getFormKey());
$configUrl = $block->escapeUrl($block->getUrl('adminhtml/system_config/edit/section/magesail'));
?>
<div class="magesail-nginx-map">
<div class="page-title-wrapper">
<h1 class="page-title"><?= $block->escapeHtml(__('NGINX Global Map Editor')) ?></h1>
</div>
<div id="magesail-flash" class="message" style="display:none"></div>
<?php if ($mapPath === null): ?>
<div class="message message-warning warning">
<span><?= $block->escapeHtml(__('NGINX global map path is not configured. Please set it in Stores → Configuration → Holesail Tunnel → NGINX Configuration.')) ?></span>
<p><a href="<?= $configUrl ?>" class="action-secondary"><?= $block->escapeHtml(__('Go to Configuration')) ?></a></p>
</div>
<?php else: ?>
<div class="admin__page-section">
<div class="admin__page-section-title">
<span class="title"><?= $block->escapeHtml(__('Map file: %1', $mapPath)) ?></span>
</div>
<div class="admin__page-section-content">
<p class="admin__field-note">
<?= $block->escapeHtml(__('This file is automatically updated when tunnels are created or stopped. Edit map blocks below or use raw editor.')) ?>
</p>
<div class="magesail-actions" style="margin-bottom: 1rem;">
<button type="button" class="action-secondary" id="magesail-btn-parse">
<span><?= $block->escapeHtml(__('Parse & Load Blocks')) ?></span>
</button>
<button type="button" class="action-secondary" id="magesail-btn-validate">
<span><?= $block->escapeHtml(__('Validate NGINX Config')) ?></span>
</button>
<button type="button" class="action-secondary" id="magesail-btn-reload">
<span><?= $block->escapeHtml(__('Validate & Reload NGINX')) ?></span>
</button>
</div>
<div id="magesail-map-blocks" style="display:none;">
<!-- Blocks will be rendered here -->
</div>
<div id="magesail-map-raw-editor" style="display:none;">
<div class="admin__field">
<label class="admin__field-label" for="magesail-map-content">
<span><?= $block->escapeHtml(__('Raw map file content')) ?></span>
</label>
<div class="admin__field-control">
<textarea id="magesail-map-content" name="content" class="admin__control-textarea" rows="20" style="font-family: monospace; font-size: 12px;"></textarea>
</div>
</div>
<div class="magesail-actions">
<button type="button" class="action-primary" id="magesail-btn-save-raw">
<span><?= $block->escapeHtml(__('Save Raw Content')) ?></span>
</button>
<button type="button" class="action-secondary" id="magesail-btn-switch-gui">
<span><?= $block->escapeHtml(__('Switch to GUI Editor')) ?></span>
</button>
</div>
</div>
<div id="magesail-map-loading" class="message message-info info">
<span><?= $block->escapeHtml(__('Click "Parse & Load Blocks" to load the map file.')) ?></span>
</div>
<div id="magesail-diagnostics" style="margin-top: 1rem; padding: 1rem; background: #f0f0f0; border: 1px solid #ddd;">
<strong><?= $block->escapeHtml(__('File Diagnostics:')) ?></strong>
<div id="magesail-diagnostics-content">
<p><?= $block->escapeHtml(__('Path: %1', $mapPath)) ?></p>
<p><?= $block->escapeHtml(__('Click "Parse & Load Blocks" to check file status.')) ?></p>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
<script>
require(['jquery', 'domReady!'], function ($) {
var mapUrl = <?= json_encode($mapUrl) ?>;
var formKey = <?= json_encode($formKey) ?>;
var parsedData = null;
function flash(msg, isError) {
var el = $('#magesail-flash');
el.removeClass('message-success success message-error error message-warning warning')
.addClass(isError ? 'message-error error' : 'message-success success')
.html('<span>' + $('<div/>').text(msg).html() + '</span>')
.show();
if (!isError) {
setTimeout(function () { el.fadeOut(); }, 5000);
}
}
function renderBlocks(blocks) {
var html = '<div class="magesail-map-blocks-container">';
blocks.forEach(function(block, blockIdx) {
html += '<div class="admin__fieldset magesail-map-block" data-block-index="' + blockIdx + '">';
html += '<div class="admin__fieldset-header">';
html += '<strong>map $' + $('<div/>').text(block.source).html() + ' $' + $('<div/>').text(block.target).html() + ' {</strong>';
html += '<button type="button" class="action-secondary magesail-add-entry" data-block="' + blockIdx + '">Add Entry</button>';
html += '</div>';
html += '<table class="admin__table-primary magesail-map-entries">';
html += '<thead><tr><th>Hostname</th><th>Value</th><th>Comment</th><th>Actions</th></tr></thead>';
html += '<tbody>';
block.entries.forEach(function(entry, entryIdx) {
html += '<tr data-entry-index="' + entryIdx + '">';
html += '<td><input type="text" class="admin__control-text magesail-hostname" value="' + $('<div/>').text(entry.hostname).html() + '" /></td>';
html += '<td><input type="text" class="admin__control-text magesail-value" value="' + $('<div/>').text(entry.value).html() + '" /></td>';
html += '<td><input type="text" class="admin__control-text magesail-comment" value="' + $('<div/>').text(entry.comment || '').html() + '" /></td>';
html += '<td><button type="button" class="action-delete magesail-delete-entry">Delete</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
html += '<div class="magesail-block-footer">}</div>';
html += '</div>';
});
html += '</div>';
html += '<div class="magesail-actions" style="margin-top: 1rem;">';
html += '<button type="button" class="action-primary" id="magesail-btn-save-blocks">';
html += '<span>Save Blocks & Validate</span>';
html += '</button>';
html += '<button type="button" class="action-secondary" id="magesail-btn-switch-raw">';
html += '<span>Switch to Raw Editor</span>';
html += '</button>';
html += '</div>';
$('#magesail-map-blocks').html(html).show();
$('#magesail-map-loading').hide();
$('#magesail-map-raw-editor').hide();
$('.magesail-add-entry').on('click', function() {
var blockIdx = $(this).data('block');
var tbody = $(this).closest('.magesail-map-block').find('tbody');
var row = '<tr data-entry-index="new">';
row += '<td><input type="text" class="admin__control-text magesail-hostname" value="" /></td>';
row += '<td><input type="text" class="admin__control-text magesail-value" value="" /></td>';
row += '<td><input type="text" class="admin__control-text magesail-comment" value="" /></td>';
row += '<td><button type="button" class="action-delete magesail-delete-entry">Delete</button></td>';
row += '</tr>';
tbody.append(row);
});
$('.magesail-delete-entry').on('click', function() {
$(this).closest('tr').remove();
});
}
function updateDiagnostics() {
$.ajax({
url: mapUrl,
type: 'POST',
dataType: 'json',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
data: { form_key: formKey, action: 'diagnostics', ajax: '1' }
}).done(function (res) {
if (res.success && res.diagnostics) {
var d = res.diagnostics;
var html = '<p><strong>Path:</strong> ' + $('<div/>').text(d.path || 'Not configured').html() + '</p>';
html += '<p><strong>Exists:</strong> ' + (d.exists ? 'Yes' : 'No') + '</p>';
html += '<p><strong>Readable:</strong> ' + (d.readable ? 'Yes' : 'No') + '</p>';
html += '<p><strong>Writable:</strong> ' + (d.writable ? 'Yes' : 'No') + '</p>';
if (d.error) {
html += '<p class="message message-error"><strong>Issue:</strong> ' + $('<div/>').text(d.error).html() + '</p>';
}
$('#magesail-diagnostics-content').html(html);
}
});
}
$('#magesail-btn-parse').on('click', function() {
updateDiagnostics();
$.ajax({
url: mapUrl,
type: 'POST',
dataType: 'json',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
data: { form_key: formKey, action: 'parse', ajax: '1' }
}).done(function (res) {
if (res.success && res.parsed) {
parsedData = res.parsed;
renderBlocks(res.parsed.blocks);
flash('Map file parsed successfully.', false);
} else {
flash(res.message || 'Failed to parse map file', true);
}
}).fail(function (xhr) {
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : '';
if (!m) {
m = xhr.status ? ('HTTP ' + xhr.status + (xhr.statusText ? ' ' + xhr.statusText : '')) : 'Request failed';
if (xhr.responseText && xhr.responseText.length && xhr.responseText.charAt(0) !== '<') {
m += ': ' + xhr.responseText.substring(0, 300);
}
}
flash(m, true);
});
});
updateDiagnostics();
$(document).on('click', '#magesail-btn-save-blocks', function() {
var blocks = [];
$('.magesail-map-block').each(function() {
var block = $(this);
var headerText = block.find('.admin__fieldset-header strong').text();
var matches = headerText.match(/\$(\w+)\s+\$(\w+)/);
if (!matches || matches.length < 3) {
flash('Invalid map block header format', true);
return false;
}
var source = matches[1];
var target = matches[2];
var entries = [];
block.find('tbody tr').each(function() {
var hostname = $(this).find('.magesail-hostname').val().trim();
var value = $(this).find('.magesail-value').val().trim();
var comment = $(this).find('.magesail-comment').val().trim();
if (hostname && value) {
entries.push({
hostname: hostname,
value: value,
comment: comment || null
});
}
});
blocks.push({
source: source,
target: target,
entries: entries
});
});
$.ajax({
url: mapUrl,
type: 'POST',
dataType: 'json',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
data: {
form_key: formKey,
action: 'save_blocks',
ajax: '1',
blocks: JSON.stringify(blocks)
}
}).done(function (res) {
if (res.success) {
flash(res.message || 'Blocks saved successfully.', false);
if (res.validation && !res.validation.valid) {
flash('Warning: ' + (res.validation.error || 'NGINX validation failed'), true);
}
} else {
flash(res.message || 'Failed to save blocks', true);
}
}).fail(function (xhr) {
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : '';
if (!m) {
m = xhr.status ? ('HTTP ' + xhr.status + (xhr.statusText ? ' ' + xhr.statusText : '')) : 'Request failed';
if (xhr.responseText && xhr.responseText.length && xhr.responseText.charAt(0) !== '<') {
m += ': ' + xhr.responseText.substring(0, 300);
}
}
flash(m, true);
});
});
$('#magesail-btn-switch-raw').on('click', function() {
$('#magesail-map-blocks').hide();
$('#magesail-map-raw-editor').show();
if ($('#magesail-map-content').val() === '') {
$.ajax({
url: mapUrl,
type: 'POST',
dataType: 'json',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
data: { form_key: formKey, action: 'read', ajax: '1' }
}).done(function (res) {
if (res.success && res.content) {
$('#magesail-map-content').val(res.content);
}
});
}
});
$('#magesail-btn-switch-gui').on('click', function() {
$('#magesail-map-raw-editor').hide();
$('#magesail-map-loading').show();
$('#magesail-btn-parse').trigger('click');
});
$('#magesail-btn-save-raw').on('click', function() {
var content = $('#magesail-map-content').val();
$.ajax({
url: mapUrl,
type: 'POST',
dataType: 'json',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
data: { form_key: formKey, action: 'write', ajax: '1', content: content }
}).done(function (res) {
if (res.success) {
flash(res.message || 'Raw content saved.', false);
if (res.validation && !res.validation.valid) {
flash('Warning: ' + (res.validation.error || 'NGINX validation failed'), true);
}
} else {
flash(res.message || 'Failed to save', true);
}
}).fail(function (xhr) {
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : '';
if (!m) {
m = xhr.status ? ('HTTP ' + xhr.status + (xhr.statusText ? ' ' + xhr.statusText : '')) : 'Request failed';
if (xhr.responseText && xhr.responseText.length && xhr.responseText.charAt(0) !== '<') {
m += ': ' + xhr.responseText.substring(0, 300);
}
}
flash(m, true);
});
});
function ajaxAction(action, data, successMsg) {
$.ajax({
url: mapUrl,
type: 'POST',
dataType: 'json',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
data: $.extend({ form_key: formKey, action: action, ajax: '1' }, data || {})
}).done(function (res) {
if (res.success) {
flash(successMsg || res.message || 'Operation completed successfully.', false);
if (res.validation && !res.validation.valid) {
flash('Warning: ' + (res.validation.error || 'NGINX validation failed'), true);
}
} else {
flash(res.message || 'Operation failed', true);
}
}).fail(function (xhr) {
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : '';
if (!m) {
m = xhr.status ? ('HTTP ' + xhr.status + (xhr.statusText ? ' ' + xhr.statusText : '')) : 'Request failed';
if (xhr.responseText && xhr.responseText.length && xhr.responseText.charAt(0) !== '<') {
m += ': ' + xhr.responseText.substring(0, 300);
}
}
flash(m, true);
});
}
$('#magesail-btn-validate').on('click', function() {
ajaxAction('validate', null, 'NGINX configuration validated.');
});
$('#magesail-btn-reload').on('click', function() {
ajaxAction('reload', null, 'NGINX reloaded.');
});
});
</script>
<style>
.magesail-map-block {
margin-bottom: 2rem;
border: 1px solid #e3e3e3;
padding: 1rem;
background: #f8f8f8;
}
.magesail-map-block .admin__fieldset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #ddd;
}
.magesail-map-entries {
width: 100%;
margin-top: 0.5rem;
}
.magesail-map-entries th {
text-align: left;
padding: 0.5rem;
background: #fff;
}
.magesail-map-entries td {
padding: 0.5rem;
background: #fff;
}
.magesail-map-entries input {
width: 100%;
}
.magesail-block-footer {
margin-top: 0.5rem;
font-family: monospace;
}
</style>
@@ -1,6 +1,8 @@
<?php
/** @var \MageSail\Magesail\Block\Adminhtml\Tunnel $block */
$status = $block->getTunnelStatus();
$tunnelStoreState = $block->getTunnelStoreState();
$defaultStoreCode = $block->getTunnelStoreCodeConstant();
$formKey = $block->escapeHtml($block->getFormKey());
$statusUrl = $block->getStatusUrl();
$indexUrl = $block->getIndexUrl();
@@ -8,6 +10,14 @@ $configUrl = $block->escapeUrl($block->getConfigUrl());
$logPath = $block->escapeHtml($block->getLogFilePath());
$logLines = $block->getRecentLogLines();
$running = $status['running'];
$prefillUnsecure = $tunnelStoreState['unsecure_base_url'] ?? '';
$prefillSecure = $tunnelStoreState['secure_base_url'] ?? '';
$prefillUseSecure = !empty($tunnelStoreState['use_secure_urls']);
$nginxPendingReload = $running && !empty($tunnelStoreState['nginx_pending_reload']);
$nginxReloadHint = (string) __(
'Reload NGINX on this server so the updated global map is active (for example: sudo systemctl reload nginx). ' .
'Or set Reload wrapper script in Admin under Configuration for the Holesail Tunnel NGINX settings so Magento can reload automatically next time.'
);
?>
<div class="magesail-tunnel"
id="magesail-root"
@@ -36,6 +46,44 @@ $running = $status['running'];
<div class="message message-warning warning">
<span><?= $block->escapeHtml(__('No active tunnel. Start one to expose your dev site.')) ?></span>
</div>
<div class="magesail-tunnel-url-fields admin__fieldset">
<p class="admin__field-note" style="margin-bottom:1rem">
<?= $block->escapeHtml(__('Use the same hostname you configure as a virtual host in holesail-browser (e.g. http://dev.myshop.local/). A store view with code %1 will be created and removed when you stop the tunnel.', $defaultStoreCode)) ?>
</p>
<p class="message message-notice notice" style="margin-bottom:1rem">
<?= $block->escapeHtml(__('Removing the tunnel deletes that store view. If you used it for real orders, back up first.')) ?>
</p>
<div class="admin__field _required">
<label class="admin__field-label" for="magesail-tunnel-base-url">
<span><?= $block->escapeHtml(__('Tunnel base URL (unsecure)')) ?></span>
</label>
<div class="admin__field-control">
<input type="text" id="magesail-tunnel-base-url" class="admin__control-text"
value="<?= $block->escapeHtmlAttr($prefillUnsecure) ?>"
placeholder="http://dev.myshop.local/"
autocomplete="off"/>
</div>
</div>
<div class="admin__field">
<label class="admin__field-label" for="magesail-tunnel-secure-base-url">
<span><?= $block->escapeHtml(__('Tunnel base URL (secure, optional)')) ?></span>
</label>
<div class="admin__field-control">
<input type="text" id="magesail-tunnel-secure-base-url" class="admin__control-text"
value="<?= $block->escapeHtmlAttr($prefillSecure) ?>"
placeholder="https://dev.myshop.local/"
autocomplete="off"/>
<p class="admin__field-note"><?= $block->escapeHtml(__('Leave empty to mirror unsecure with https if unsecure is http, otherwise same as unsecure.')) ?></p>
</div>
</div>
<div class="admin__field admin__field-option">
<input type="checkbox" id="magesail-tunnel-use-secure" class="admin__control-checkbox"
<?= $prefillUseSecure ? 'checked="checked"' : '' ?> />
<label for="magesail-tunnel-use-secure" class="admin__field-label">
<span><?= $block->escapeHtml(__('Use secure URLs on storefront and in admin')) ?></span>
</label>
</div>
</div>
<div class="magesail-actions">
<button type="button" class="action-primary" id="magesail-btn-start">
<span><?= $block->escapeHtml(__('Start Tunnel')) ?></span>
@@ -51,6 +99,17 @@ $running = $status['running'];
}
?></span>
</div>
<?php if ($nginxPendingReload): ?>
<div class="message message-warning warning" id="magesail-nginx-reload-banner" role="alert">
<span><?= $block->escapeHtml($nginxReloadHint) ?></span>
</div>
<?php endif; ?>
<?php
$displayStoreCode = $tunnelStoreState['store_code'] ?? $defaultStoreCode;
?>
<p class="admin__field-note" id="magesail-store-code-hint" style="margin-bottom:1rem">
<?= $block->escapeHtml(__('Tunnel store view code: %1 — configure holesail-browser to send header X-MageSail-Store-Code: %2 (or query %3 / Magento %4).', $displayStoreCode, $displayStoreCode, 'magesail_store=' . $displayStoreCode, '___store=' . $displayStoreCode)) ?>
</p>
<div class="magesail-key-wrap" id="magesail-key-section">
<strong><?= $block->escapeHtml(__('Share key')) ?></strong>
<code class="magesail-key" id="magesail-key-value"><?= $block->escapeHtml($status['key'] ?: '') ?></code>
@@ -115,6 +174,15 @@ require(['jquery', 'domReady!'], function ($) {
}
}
function flashWarning(msg) {
var el = $('#magesail-flash');
el.removeClass('message-success success message-error error message-warning warning')
.addClass('message-warning warning')
.html('<span>' + $('<div/>').text(msg).html() + '</span>')
.show();
setTimeout(function () { el.fadeOut(); }, 20000);
}
function copyText(text) {
if (!text) return false;
try {
@@ -225,16 +293,37 @@ require(['jquery', 'domReady!'], function ($) {
});
$('#magesail-btn-start').on('click', function () {
var baseUrl = ($('#magesail-tunnel-base-url').val() || '').trim();
if (!baseUrl) {
flash(<?= json_encode((string) __('Enter the tunnel base URL (must match holesail-browser vhost).')) ?>, true);
return;
}
var btn = $(this).prop('disabled', true);
$.ajax({
url: indexUrl,
type: 'POST',
dataType: 'json',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
data: { form_key: formKey, action: 'start', ajax: '1' }
data: {
form_key: formKey,
action: 'start',
ajax: '1',
tunnel_base_url: baseUrl,
tunnel_secure_base_url: ($('#magesail-tunnel-secure-base-url').val() || '').trim(),
tunnel_use_secure_urls: $('#magesail-tunnel-use-secure').is(':checked') ? '1' : '0'
}
}).done(function (res) {
if (res.success && res.status) {
flash(res.message || '', false);
if (res.nginx_reload_required && res.nginx_reload_message) {
flashWarning(res.nginx_reload_message);
if (!$('#magesail-nginx-reload-banner').length) {
$('#magesail-status-banner').after(
'<div class="message message-warning warning" id="magesail-nginx-reload-banner" role="alert"><span>'
+ $('<div/>').text(res.nginx_reload_message).html() + '</span></div>'
);
}
}
showRunning(res.status);
} else {
flash(res.message || 'Start failed', true);