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|null */ public function getPersistedState(): ?array { $list = $this->tunnelRegistry->listTunnels(); if (\count($list) === 0) { return null; } return $list[0]; } /** * @return list> */ 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(); } }