Read X-Forwarded-Host (and HTTP_X_FORWARDED_HOST) so tunnel requests resolve
CI / php (push) Successful in 1m39s

the correct store behind a proxy.
TunnelStoreProvisioner now receives ResourceConnection and copies default web
CMS pages/blocks and theme-related setup for new tunnel stores.
Tighten system.xml (remove invalid default), crontab/config defaults, and
update tests with a ResourceConnection stub for PHPUnit.
This commit is contained in:
2026-04-01 19:49:42 -05:00
parent f9d31a5efa
commit 0fa2415106
7 changed files with 156 additions and 29 deletions
+57 -9
View File
@@ -17,20 +17,68 @@ class TunnelHostnameMatcher
} }
/** /**
* When HTTP_HOST matches unsecure or secure tunnel base URL host, return the persisted store code. * When a request host header matches unsecure or secure tunnel base URL host, return the persisted store code.
*
* Tries HTTP_HOST first, then proxy headers (X-Forwarded-Host / HTTP_X_FORWARDED_HOST), so tunnels still
* resolve when PHP sees an internal host (e.g. 127.0.0.1) but the client Host was dev.example.com.
*/ */
public function getStoreCodeIfRequestHostMatchesState(Http $request): ?string public function getStoreCodeIfRequestHostMatchesState(Http $request): ?string
{ {
$httpHost = $request->getServer('HTTP_HOST'); foreach ($this->collectRequestHostCandidates($request) as $host) {
if ($httpHost === null || $httpHost === '') { $match = $this->tunnelRegistry->findByRequestHost($host);
return null; if ($match !== null) {
}
$match = $this->tunnelRegistry->findByRequestHost($httpHost);
if ($match === null) {
return null;
}
return (string) ($match['store_code'] ?? ''); return (string) ($match['store_code'] ?? '');
} }
}
return null;
}
/**
* @return list<string>
*/
private function collectRequestHostCandidates(Http $request): array
{
$seen = [];
$out = [];
foreach ($this->rawHostCandidates($request) as $raw) {
$raw = trim($raw);
if ($raw === '') {
continue;
}
$hostKey = strtolower($raw);
if (isset($seen[$hostKey])) {
continue;
}
$seen[$hostKey] = true;
$out[] = $raw;
}
return $out;
}
/**
* @return list<string>
*/
private function rawHostCandidates(Http $request): array
{
$candidates = [];
$httpHost = $request->getServer('HTTP_HOST');
if ($httpHost !== null && $httpHost !== '') {
$candidates[] = $httpHost;
}
$xForwardedHost = $request->getServer('HTTP_X_FORWARDED_HOST');
if ($xForwardedHost !== null && $xForwardedHost !== '') {
foreach (explode(',', $xForwardedHost) as $part) {
$candidates[] = trim($part);
}
}
$header = $request->getHeader('X-Forwarded-Host');
if ($header !== false && $header !== null && trim((string) $header) !== '') {
foreach (explode(',', (string) $header) as $part) {
$candidates[] = trim($part);
}
}
return $candidates;
}
public static function hostFromHttpHost(?string $httpHost): ?string public static function hostFromHttpHost(?string $httpHost): ?string
{ {
+63 -1
View File
@@ -8,6 +8,7 @@ use Magento\Framework\App\Cache\TypeListInterface;
use Magento\Framework\App\Config\ReinitableConfigInterface; use Magento\Framework\App\Config\ReinitableConfigInterface;
use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\Config\Storage\WriterInterface; use Magento\Framework\App\Config\Storage\WriterInterface;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Store\Api\StoreRepositoryInterface; use Magento\Store\Api\StoreRepositoryInterface;
@@ -30,6 +31,25 @@ class TunnelStoreProvisioner
private const PATH_USE_IN_FRONTEND = 'web/secure/use_in_frontend'; private const PATH_USE_IN_FRONTEND = 'web/secure/use_in_frontend';
private const PATH_USE_IN_ADMIN = 'web/secure/use_in_adminhtml'; private const PATH_USE_IN_ADMIN = 'web/secure/use_in_adminhtml';
/** @see \Magento\Cms\Helper\Page */
private const PATH_CMS_HOME = 'web/default/cms_home_page';
private const PATH_CMS_NO_ROUTE = 'web/default/cms_no_route';
private const PATH_CMS_NO_COOKIES = 'web/default/cms_no_cookies';
private const PATH_WEB_FRONT = 'web/default/front';
private const PATH_THEME_ID = 'design/theme/theme_id';
/** Paths copied from the website default store view so tunnel storefronts match the main demo/home (CMS + widgets). */
private const WEB_DEFAULT_PATHS = [
self::PATH_CMS_HOME,
self::PATH_CMS_NO_ROUTE,
self::PATH_CMS_NO_COOKIES,
self::PATH_WEB_FRONT,
];
public function __construct( public function __construct(
private readonly StoreManagerInterface $storeManager, private readonly StoreManagerInterface $storeManager,
private readonly StoreFactory $storeFactory, private readonly StoreFactory $storeFactory,
@@ -42,7 +62,8 @@ class TunnelStoreProvisioner
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly NginxGlobalMapManager $nginxMapManager, private readonly NginxGlobalMapManager $nginxMapManager,
private readonly ScopeConfigInterface $scopeConfig, private readonly ScopeConfigInterface $scopeConfig,
private readonly TunnelProcessPaths $tunnelProcessPaths private readonly TunnelProcessPaths $tunnelProcessPaths,
private readonly ResourceConnection $resource
) { ) {
} }
@@ -83,6 +104,7 @@ class TunnelStoreProvisioner
$storeId = (int) $store->getId(); $storeId = (int) $store->getId();
$this->writeStoreBaseConfig($storeId, $unsecureBase, $secureBase, $useSecureUrls); $this->writeStoreBaseConfig($storeId, $unsecureBase, $secureBase, $useSecureUrls);
$this->copyStorefrontDefaultsFromWebsiteDefault($websiteId, $storeId);
$nginxReloaded = $this->applyNginxMap($unsecureBase, $storeCode); $nginxReloaded = $this->applyNginxMap($unsecureBase, $storeCode);
@@ -381,6 +403,46 @@ class TunnelStoreProvisioner
} }
} }
/**
* Align tunnel store view with the website's default store: CMS home/front + theme (Luma widgets) + cms_*_store rows.
*/
private function copyStorefrontDefaultsFromWebsiteDefault(int $websiteId, int $newStoreId): void
{
try {
$website = $this->storeManager->getWebsite($websiteId);
$defaultStore = $website->getDefaultStore();
if ($defaultStore === null || (int) $defaultStore->getId() === $newStoreId) {
return;
}
$fromStoreId = (int) $defaultStore->getId();
$fromStoreCode = $defaultStore->getCode();
foreach (self::WEB_DEFAULT_PATHS as $path) {
$value = $this->scopeConfig->getValue($path, ScopeInterface::SCOPE_STORES, $fromStoreCode);
if ($value !== null && $value !== '') {
$this->configWriter->save($path, (string) $value, ScopeInterface::SCOPE_STORES, $newStoreId);
}
}
$theme = $this->scopeConfig->getValue(self::PATH_THEME_ID, ScopeInterface::SCOPE_STORES, $fromStoreCode);
if ($theme !== null && $theme !== '') {
$this->configWriter->save(self::PATH_THEME_ID, (string) $theme, ScopeInterface::SCOPE_STORES, $newStoreId);
}
$this->mirrorCmsEntityStoreAssignments($fromStoreId, $newStoreId);
} catch (\Throwable $e) {
$this->logger->warning('MageSail: could not copy storefront defaults to tunnel store: ' . $e->getMessage());
}
}
private function mirrorCmsEntityStoreAssignments(int $fromStoreId, int $newStoreId): void
{
$conn = $this->resource->getConnection();
foreach (['cms_page_store' => 'page_id', 'cms_block_store' => 'block_id'] as $table => $idColumn) {
$t = $this->resource->getTableName($table);
$sql = "INSERT IGNORE INTO {$t} ({$idColumn}, store_id) "
. "SELECT DISTINCT {$idColumn}, :new_store FROM {$t} WHERE store_id IN (0, :from_store)";
$conn->query($sql, ['new_store' => $newStoreId, 'from_store' => $fromStoreId]);
}
}
/** /**
* @return bool True if nginx reported reloaded * @return bool True if nginx reported reloaded
*/ */
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Magento\Framework\App;
/**
* Minimal stub so PHPUnit can mock {@see \Magento\Framework\App\ResourceConnection} in unit tests.
*/
class ResourceConnection
{
public function getConnection(?string $resourceName = null): mixed
{
return null;
}
public function getTableName(string $table): string
{
return $table;
}
}
@@ -12,6 +12,7 @@ use Magento\Framework\App\Cache\TypeListInterface;
use Magento\Framework\App\Config\ReinitableConfigInterface; use Magento\Framework\App\Config\ReinitableConfigInterface;
use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\Config\Storage\WriterInterface; use Magento\Framework\App\Config\Storage\WriterInterface;
use Magento\Framework\App\ResourceConnection;
use Magento\Store\Api\StoreRepositoryInterface; use Magento\Store\Api\StoreRepositoryInterface;
use Magento\Store\Model\ResourceModel\Store as StoreResource; use Magento\Store\Model\ResourceModel\Store as StoreResource;
use Magento\Store\Model\StoreFactory; use Magento\Store\Model\StoreFactory;
@@ -82,6 +83,9 @@ class TunnelStoreProvisionerTest extends TestCase
$scopeConfig = $this->createMock(ScopeConfigInterface::class); $scopeConfig = $this->createMock(ScopeConfigInterface::class);
$resource = $this->createMock(ResourceConnection::class);
$resource->expects(self::never())->method('getConnection');
$pidPath = $this->tmpDir . '/' . $tunnelId . '.pid'; $pidPath = $this->tmpDir . '/' . $tunnelId . '.pid';
$keyPath = $this->tmpDir . '/' . $tunnelId . '.key'; $keyPath = $this->tmpDir . '/' . $tunnelId . '.key';
file_put_contents($pidPath, '12345'); file_put_contents($pidPath, '12345');
@@ -103,7 +107,8 @@ class TunnelStoreProvisionerTest extends TestCase
$logger, $logger,
$nginxMapManager, $nginxMapManager,
$scopeConfig, $scopeConfig,
$tunnelProcessPaths $tunnelProcessPaths,
$resource
); );
$provisioner->teardownTunnel($tunnelId); $provisioner->teardownTunnel($tunnelId);
+4 -8
View File
@@ -12,9 +12,8 @@
<label>Settings</label> <label>Settings</label>
<field id="mode" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1"> <field id="mode" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Secure Tunnel</label> <label>Secure Tunnel</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
<default>1</default>
<comment>Yes: private key mode (recommended). No: public discoverable key.</comment> <comment>Yes: private key mode (recommended). No: public discoverable key.</comment>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field> </field>
<field id="script_path" translate="label" type="text" sortOrder="30" showInDefault="1"> <field id="script_path" translate="label" type="text" sortOrder="30" showInDefault="1">
<label>Scripts directory</label> <label>Scripts directory</label>
@@ -26,15 +25,13 @@
</field> </field>
<field id="auto_restart" translate="label" type="select" sortOrder="40" showInDefault="1"> <field id="auto_restart" translate="label" type="select" sortOrder="40" showInDefault="1">
<label>Auto-restart on crash</label> <label>Auto-restart on crash</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
<default>0</default>
<comment>If Yes, the monitor cron restarts each tunnel whose process is no longer running (uses that tunnel&apos;s saved port and secure mode).</comment> <comment>If Yes, the monitor cron restarts each tunnel whose process is no longer running (uses that tunnel&apos;s saved port and secure mode).</comment>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field> </field>
<field id="monitor_cron_expr" translate="label" type="text" sortOrder="45" showInDefault="1" canRestore="1"> <field id="monitor_cron_expr" translate="label" type="text" sortOrder="45" showInDefault="1" canRestore="1">
<label>Monitor cron schedule</label> <label>Monitor cron schedule</label>
<config_path>crontab/default/jobs/magesail_monitor/schedule/cron_expr</config_path>
<backend_model>MageSail\Magesail\Model\Config\Backend\MonitorCronExpr</backend_model>
<comment>Magento cron expression for tunnel health monitor (stale PID cleanup and optional auto-restart). Default: once daily at midnight (0 0 * * *). Invalid expressions may prevent the job from running—flush config cache after changes.</comment> <comment>Magento cron expression for tunnel health monitor (stale PID cleanup and optional auto-restart). Default: once daily at midnight (0 0 * * *). Invalid expressions may prevent the job from running—flush config cache after changes.</comment>
<backend_model>MageSail\Magesail\Model\Config\Backend\MonitorCronExpr</backend_model>
</field> </field>
</group> </group>
<group id="nginx" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1"> <group id="nginx" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1">
@@ -73,9 +70,8 @@
</field> </field>
<field id="disable_base_url_redirect" translate="label" type="select" sortOrder="60" showInDefault="1"> <field id="disable_base_url_redirect" translate="label" type="select" sortOrder="60" showInDefault="1">
<label>Disable base URL redirect for tunnel</label> <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> <comment>If Yes, disables web/url/redirect_to_base for the tunnel store view to prevent redirects.</comment>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field> </field>
</group> </group>
</section> </section>
+6 -11
View File
@@ -2,23 +2,18 @@
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd"> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
<default> <default>
<magesail> <magesail>
<settings>
<mode>1</mode>
<auto_restart>0</auto_restart>
<monitor_cron_expr>0 0 * * *</monitor_cron_expr>
</settings>
<nginx> <nginx>
<disable_base_url_redirect>1</disable_base_url_redirect>
<map_source_variable>http_host</map_source_variable> <map_source_variable>http_host</map_source_variable>
<map_run_code_variable>MAGE_RUN_CODE</map_run_code_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_variable>MAGE_RUN_TYPE</map_run_type_variable>
<map_run_type_value>store</map_run_type_value> <map_run_type_value>store</map_run_type_value>
</nginx> </nginx>
</magesail> </magesail>
<crontab>
<default>
<jobs>
<magesail_monitor>
<schedule>
<cron_expr>0 0 * * *</cron_expr>
</schedule>
</magesail_monitor>
</jobs>
</default>
</crontab>
</default> </default>
</config> </config>
+1 -1
View File
@@ -2,7 +2,7 @@
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd"> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
<group id="default"> <group id="default">
<job name="magesail_monitor" instance="MageSail\Magesail\Cron\DailyMonitor" method="execute"> <job name="magesail_monitor" instance="MageSail\Magesail\Cron\DailyMonitor" method="execute">
<config_path>crontab/default/jobs/magesail_monitor/schedule/cron_expr</config_path> <config_path>magesail/settings/monitor_cron_expr</config_path>
</job> </job>
</group> </group>
</config> </config>