434 lines
16 KiB
PHP
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();
|
|
}
|
|
}
|