Files
MageSail/Magesail/Model/TunnelStoreProvisioner.php
T
2026-03-21 03:01:08 -05:00

434 lines
16 KiB
PHP

<?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;
/**
* Creates dedicated store views per tunnel with base URLs; tears down on explicit delete or stale process cleanup.
*/
class TunnelStoreProvisioner
{
/** @deprecated Legacy single-tunnel store code; migrated installs may still use this code. */
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 TunnelRegistry $tunnelRegistry,
private readonly LoggerInterface $logger,
private readonly NginxGlobalMapManager $nginxMapManager,
private readonly ScopeConfigInterface $scopeConfig,
private readonly TunnelProcessPaths $tunnelProcessPaths
) {
}
/**
* @return array{tunnel_id: string, store_id: int, store_code: string, nginx_reloaded: bool}
* @throws LocalizedException
*/
public function provisionNewTunnel(
string $label,
int $websiteId,
int $groupId,
string $unsecureBase,
string $secureBase,
bool $useSecureUrls,
int $localPort,
bool $secureHolesail
): array {
$this->assertWebsiteAndStoreGroup($websiteId, $groupId);
$unsecureBase = $this->normalizeBaseUrl($this->expandBareHostToUrl(trim($unsecureBase), 'http'));
$secureTrimmed = trim($secureBase);
if ($secureTrimmed !== '') {
$secureResolved = $this->expandBareHostToUrl($secureTrimmed, 'https');
} else {
$secureResolved = $this->deriveSecureFromUnsecure($unsecureBase);
}
$secureBase = $this->normalizeBaseUrl($secureResolved);
$this->assertValidUrl($unsecureBase);
$this->assertValidUrl($secureBase);
$this->assertLocalPortInRange($localPort);
$this->assertHostnameUniqueAmongTunnels($unsecureBase, $secureBase, null);
$tunnelId = $this->generateTunnelId();
$storeCode = $this->generateUniqueStoreCode();
$store = $this->createStoreWithCode($storeCode, $label, $websiteId, $groupId);
$storeId = (int) $store->getId();
$this->writeStoreBaseConfig($storeId, $unsecureBase, $secureBase, $useSecureUrls);
$nginxReloaded = $this->applyNginxMap($unsecureBase, $storeCode);
$tunnel = [
'id' => $tunnelId,
'label' => $label,
'website_id' => $websiteId,
'group_id' => $groupId,
'store_id' => $storeId,
'store_code' => $storeCode,
'unsecure_base_url' => $unsecureBase,
'secure_base_url' => $secureBase,
'use_secure_urls' => $useSecureUrls,
'local_port' => $localPort,
'secure' => $secureHolesail,
'nginx_pending_reload' => !$nginxReloaded,
];
$this->tunnelRegistry->saveTunnel($tunnel);
$this->storeRepository->clean();
$this->storeManager->reinitStores();
$this->flushConfigCache();
return [
'tunnel_id' => $tunnelId,
'store_id' => $storeId,
'store_code' => $storeCode,
'nginx_reloaded' => $nginxReloaded,
];
}
/**
* Full removal: NGINX map, store view (when safe), registry row, pid/key files.
*/
public function teardownTunnel(string $tunnelId): void
{
$tunnel = $this->tunnelRegistry->getById($tunnelId);
if ($tunnel === null) {
$this->flushConfigCache();
return;
}
$store = $this->storeFactory->create();
$this->storeResource->load($store, $tunnel['store_id']);
if (!$store->getId()) {
$this->tryRemoveNginxMapping($tunnel['unsecure_base_url']);
$this->tunnelRegistry->deleteTunnel($tunnelId);
$this->unlinkProcessFiles($tunnelId);
$this->flushConfigCache();
return;
}
try {
$website = $this->storeManager->getWebsite((int) $store->getWebsiteId());
} catch (LocalizedException $e) {
$this->logger->warning('MageSail tunnel store teardown: ' . $e->getMessage());
$this->tryRemoveNginxMapping($tunnel['unsecure_base_url']);
$this->tunnelRegistry->deleteTunnel($tunnelId);
$this->unlinkProcessFiles($tunnelId);
$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->tryRemoveNginxMapping($tunnel['unsecure_base_url']);
$this->tunnelRegistry->deleteTunnel($tunnelId);
$this->unlinkProcessFiles($tunnelId);
$this->flushConfigCache();
return;
}
$this->tryRemoveNginxMapping($tunnel['unsecure_base_url']);
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->tunnelRegistry->deleteTunnel($tunnelId);
$this->unlinkProcessFiles($tunnelId);
$this->flushConfigCache();
}
/**
* After stale PID cleanup when auto-restart is disabled — remove store for that tunnel only.
*/
public function teardownAfterStaleProcess(string $tunnelId): void
{
$this->teardownTunnel($tunnelId);
}
/**
* @deprecated Use tunnel registry list
* @return array<string, mixed>|null
*/
public function getPersistedState(): ?array
{
$list = $this->tunnelRegistry->listTunnels();
if (\count($list) === 0) {
return null;
}
return $list[0];
}
/**
* @return list<array<string, mixed>>
*/
public function listPersistedTunnels(): array
{
return $this->tunnelRegistry->listTunnels();
}
public function clearNginxPendingReloadFlag(): void
{
$this->tunnelRegistry->clearAllNginxPendingReloadFlags();
}
/**
* @throws LocalizedException
*/
private function assertWebsiteAndStoreGroup(int $websiteId, int $groupId): void
{
try {
$website = $this->storeManager->getWebsite($websiteId);
} catch (LocalizedException $e) {
throw new LocalizedException(__('Invalid website: %1', $e->getMessage()));
}
$found = false;
foreach ($website->getGroups() as $group) {
if ((int) $group->getId() === $groupId) {
$found = true;
break;
}
}
if (!$found) {
throw new LocalizedException(__('Store group does not belong to the selected website.'));
}
}
private function assertLocalPortInRange(int $port): void
{
if ($port < 1 || $port > 65535) {
throw new LocalizedException(__('Local port must be between 1 and 65535.'));
}
}
/**
* Hostnames must remain unique per tunnel so NGINX map and routing stay unambiguous.
*
* @throws LocalizedException
*/
private function assertHostnameUniqueAmongTunnels(
string $unsecureBase,
string $secureBase,
?string $exceptTunnelId
): void {
$hosts = [];
foreach ([$unsecureBase, $secureBase] as $url) {
$h = TunnelRegistry::hostFromBaseUrl($url);
if ($h !== null && $h !== '') {
$hosts[strtolower($h)] = true;
}
}
foreach ($this->tunnelRegistry->listTunnels() as $t) {
if ($exceptTunnelId !== null && ($t['id'] ?? '') === $exceptTunnelId) {
continue;
}
foreach ([$t['unsecure_base_url'] ?? '', $t['secure_base_url'] ?? ''] as $existingUrl) {
if ($existingUrl === '') {
continue;
}
$eh = TunnelRegistry::hostFromBaseUrl((string) $existingUrl);
if ($eh === null) {
continue;
}
if (isset($hosts[strtolower($eh)])) {
throw new LocalizedException(
__('Another tunnel already uses hostname %1. Choose a different host.', $eh)
);
}
}
}
}
private function generateTunnelId(): string
{
return bin2hex(random_bytes(8));
}
/**
* @throws LocalizedException
*/
private function generateUniqueStoreCode(): string
{
for ($i = 0; $i < 32; $i++) {
$code = 'mgtun_' . bin2hex(random_bytes(4));
if (!$this->storeCodeExists($code)) {
return $code;
}
}
throw new LocalizedException(__('Could not generate a unique store code.'));
}
private function storeCodeExists(string $code): bool
{
try {
$this->storeRepository->get($code);
return true;
} catch (NoSuchEntityException) {
return false;
}
}
private function createStoreWithCode(string $storeCode, string $label, int $websiteId, int $groupId): \Magento\Store\Api\Data\StoreInterface
{
$name = $label !== ''
? (string) __('MageSail: %1', $label)
: (string) __('MageSail Tunnel');
$store = $this->storeFactory->create();
$store->setCode($storeCode);
$store->setName($name);
$store->setWebsiteId($websiteId);
$store->setStoreGroupId($groupId);
$store->setIsActive(true);
$this->storeResource->save($store);
return $store;
}
private function writeStoreBaseConfig(int $storeId, string $unsecureBase, string $secureBase, bool $useSecureUrls): void
{
$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);
}
}
/**
* @return bool True if nginx reported reloaded
*/
private function applyNginxMap(string $unsecureBase, string $storeCode): bool
{
$nginxReloaded = true;
try {
$hostname = $this->nginxMapManager->extractHostname($unsecureBase);
$this->nginxMapManager->addTunnelMapping($hostname, $storeCode);
$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;
}
return $nginxReloaded;
}
private function tryRemoveNginxMapping(string $unsecureBaseUrl): void
{
try {
$hostname = $this->nginxMapManager->extractHostname($unsecureBaseUrl);
$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());
}
}
private function unlinkProcessFiles(string $tunnelId): void
{
$pid = $this->tunnelProcessPaths->getPidFilePath($tunnelId);
$key = $this->tunnelProcessPaths->getKeyFilePath($tunnelId);
if (\is_file($pid)) {
@unlink($pid);
}
if (\is_file($key)) {
@unlink($key);
}
}
/**
* Accept full URLs or hostname-only (e.g. dev.myshop.local or dev.myshop.local:8080).
* When no http/https scheme is present, prepends {@see $defaultScheme}://
*/
private function expandBareHostToUrl(string $input, string $defaultScheme): string
{
if ($input === '') {
return '';
}
if (preg_match('#^https?://#i', $input) === 1) {
return $input;
}
return $defaultScheme . '://' . ltrim($input, '/');
}
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();
}
}