CI / php (push) Successful in 2m53s
- Add EnvServicePortResolver (DeploymentConfig) for MySQL, Redis frontends, session Redis, AMQP, http_cache_hosts, optional search - Extend tunnel registry with tunnel_type, service_key, service metadata; provisionServiceTunnel without store/NGINX map; teardown guards - Admin: Type (Website|Service), service dropdown, list Type column; JS toggle - TunnelManager/start-server: forward to resolved host:port - Tests: EnvServicePortResolver + service teardown; test stubs for Magento/PSR - docs: admin-ui Type/Service and security notes
376 lines
13 KiB
PHP
376 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MageSail\Magesail\Model;
|
|
|
|
use Magento\Framework\App\Config\ScopeConfigInterface;
|
|
use Magento\Framework\App\Filesystem\DirectoryList;
|
|
use Magento\Framework\Serialize\Serializer\Json;
|
|
use Magento\Store\Api\StoreRepositoryInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* Persists all MageSail tunnels in var/magesail_tunnels.json (replaces single magesail_tunnel_store.json).
|
|
*/
|
|
class TunnelRegistry
|
|
{
|
|
public const FILENAME = 'magesail_tunnels.json';
|
|
|
|
public const CURRENT_VERSION = 1;
|
|
|
|
public const MIGRATED_LEGACY_TUNNEL_ID = 'migrated_legacy';
|
|
|
|
private const XML_MODE = 'magesail/settings/mode';
|
|
|
|
/** Legacy single-tunnel JSON had no per-tunnel port; use this when migrating to the registry. */
|
|
private const LEGACY_MIGRATION_DEFAULT_PORT = 80;
|
|
|
|
public function __construct(
|
|
private readonly DirectoryList $directoryList,
|
|
private readonly Json $json,
|
|
private readonly TunnelStoreState $legacyTunnelStoreState,
|
|
private readonly TunnelProcessPaths $tunnelProcessPaths,
|
|
private readonly ScopeConfigInterface $scopeConfig,
|
|
private readonly StoreRepositoryInterface $storeRepository,
|
|
private readonly LoggerInterface $logger
|
|
) {
|
|
}
|
|
|
|
public function getFilePath(): string
|
|
{
|
|
return $this->directoryList->getPath(DirectoryList::VAR_DIR) . '/' . self::FILENAME;
|
|
}
|
|
|
|
/**
|
|
* @return array{version: int, tunnels: list<array<string, mixed>>}
|
|
*/
|
|
public function loadData(): array
|
|
{
|
|
$path = $this->getFilePath();
|
|
if (!\is_readable($path)) {
|
|
$this->migrateFromLegacyIfNeeded();
|
|
}
|
|
if (!\is_readable($path)) {
|
|
return ['version' => self::CURRENT_VERSION, 'tunnels' => []];
|
|
}
|
|
$raw = file_get_contents($path);
|
|
if ($raw === false || trim($raw) === '') {
|
|
return ['version' => self::CURRENT_VERSION, 'tunnels' => []];
|
|
}
|
|
try {
|
|
/** @var array $data */
|
|
$data = $this->json->unserialize($raw);
|
|
} catch (\InvalidArgumentException) {
|
|
return ['version' => self::CURRENT_VERSION, 'tunnels' => []];
|
|
}
|
|
if (!isset($data['tunnels']) || !\is_array($data['tunnels'])) {
|
|
$data['tunnels'] = [];
|
|
}
|
|
if (\count($data['tunnels']) === 0) {
|
|
$this->migrateFromLegacyIfNeeded();
|
|
if (\is_readable($path)) {
|
|
$raw2 = file_get_contents($path);
|
|
if ($raw2 !== false && trim($raw2) !== '') {
|
|
try {
|
|
/** @var array $data2 */
|
|
$data2 = $this->json->unserialize($raw2);
|
|
if (isset($data2['tunnels']) && \is_array($data2['tunnels'])) {
|
|
$data = $data2;
|
|
}
|
|
} catch (\InvalidArgumentException) {
|
|
// keep empty
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!isset($data['tunnels']) || !\is_array($data['tunnels'])) {
|
|
return ['version' => self::CURRENT_VERSION, 'tunnels' => []];
|
|
}
|
|
return [
|
|
'version' => (int) ($data['version'] ?? self::CURRENT_VERSION),
|
|
'tunnels' => array_values($data['tunnels']),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
public function listTunnels(): array
|
|
{
|
|
$out = [];
|
|
foreach ($this->loadData()['tunnels'] as $t) {
|
|
$out[] = $this->normalizeTunnelRecord($t);
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function getById(string $tunnelId): ?array
|
|
{
|
|
foreach ($this->listTunnels() as $t) {
|
|
if (($t['id'] ?? '') === $tunnelId) {
|
|
return $this->normalizeTunnelRecord($t);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function getByStoreCode(string $storeCode): ?array
|
|
{
|
|
foreach ($this->listTunnels() as $t) {
|
|
if (($t['store_code'] ?? '') === $storeCode) {
|
|
return $this->normalizeTunnelRecord($t);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Match HTTP_HOST (normalized) against tunnel base URL hosts.
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function findByRequestHost(string $requestHost): ?array
|
|
{
|
|
$host = self::hostFromHttpHost($requestHost);
|
|
if ($host === null) {
|
|
return null;
|
|
}
|
|
foreach ($this->listTunnels() as $t) {
|
|
$t = $this->normalizeTunnelRecord($t);
|
|
if (($t['tunnel_type'] ?? 'website') === 'service') {
|
|
continue;
|
|
}
|
|
foreach ([$t['unsecure_base_url'] ?? '', $t['secure_base_url'] ?? ''] as $baseUrl) {
|
|
if ($baseUrl === '') {
|
|
continue;
|
|
}
|
|
$configured = self::hostFromBaseUrl((string) $baseUrl);
|
|
if ($configured !== null && strcasecmp($host, $configured) === 0) {
|
|
return $t;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public static function hostFromHttpHost(?string $httpHost): ?string
|
|
{
|
|
if ($httpHost === null || $httpHost === '') {
|
|
return null;
|
|
}
|
|
$parsed = parse_url('http://' . $httpHost);
|
|
if ($parsed === false || !isset($parsed['host'])) {
|
|
return null;
|
|
}
|
|
return $parsed['host'];
|
|
}
|
|
|
|
public static function hostFromBaseUrl(string $baseUrl): ?string
|
|
{
|
|
$parsed = parse_url($baseUrl);
|
|
if ($parsed === false || !isset($parsed['host'])) {
|
|
return null;
|
|
}
|
|
return $parsed['host'];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $tunnel
|
|
*/
|
|
public function saveTunnel(array $tunnel): void
|
|
{
|
|
if (!isset($tunnel['id']) || (string) $tunnel['id'] === '') {
|
|
throw new \InvalidArgumentException('Tunnel id is required.');
|
|
}
|
|
$data = $this->loadData();
|
|
$tunnel = $this->normalizeTunnelRecord($tunnel);
|
|
$found = false;
|
|
foreach ($data['tunnels'] as $i => $existing) {
|
|
if (($existing['id'] ?? '') === $tunnel['id']) {
|
|
$data['tunnels'][$i] = $tunnel;
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!$found) {
|
|
$data['tunnels'][] = $tunnel;
|
|
}
|
|
$data['version'] = self::CURRENT_VERSION;
|
|
$this->writeData($data);
|
|
}
|
|
|
|
public function deleteTunnel(string $tunnelId): void
|
|
{
|
|
$data = $this->loadData();
|
|
$data['tunnels'] = array_values(array_filter(
|
|
$data['tunnels'],
|
|
static fn ($t) => ($t['id'] ?? '') !== $tunnelId
|
|
));
|
|
$this->writeData($data);
|
|
}
|
|
|
|
public function clearAllNginxPendingReloadFlags(): void
|
|
{
|
|
$data = $this->loadData();
|
|
$changed = false;
|
|
foreach ($data['tunnels'] as $i => $t) {
|
|
if (!empty($t['nginx_pending_reload'])) {
|
|
$data['tunnels'][$i]['nginx_pending_reload'] = false;
|
|
$changed = true;
|
|
}
|
|
}
|
|
if ($changed) {
|
|
$this->writeData($data);
|
|
}
|
|
}
|
|
|
|
public function anyTunnelHasNginxPendingReload(): bool
|
|
{
|
|
foreach ($this->listTunnels() as $t) {
|
|
if (!empty($t['nginx_pending_reload'])) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public function clearNginxPendingReloadFlagForTunnel(string $tunnelId): void
|
|
{
|
|
$data = $this->loadData();
|
|
$changed = false;
|
|
foreach ($data['tunnels'] as $i => $t) {
|
|
if (($t['id'] ?? '') === $tunnelId && !empty($t['nginx_pending_reload'])) {
|
|
$data['tunnels'][$i]['nginx_pending_reload'] = false;
|
|
$changed = true;
|
|
break;
|
|
}
|
|
}
|
|
if ($changed) {
|
|
$this->writeData($data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array{version: int, tunnels: list<array<string, mixed>>} $data
|
|
*/
|
|
private function writeData(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 registry file.');
|
|
}
|
|
}
|
|
|
|
private function migrateFromLegacyIfNeeded(): void
|
|
{
|
|
if ($this->hasNonEmptyRegistry()) {
|
|
return;
|
|
}
|
|
$legacy = $this->legacyTunnelStoreState->read();
|
|
if ($legacy === null) {
|
|
return;
|
|
}
|
|
$port = self::LEGACY_MIGRATION_DEFAULT_PORT;
|
|
$secure = (bool) $this->scopeConfig->getValue(self::XML_MODE);
|
|
$websiteId = 0;
|
|
$groupId = 0;
|
|
try {
|
|
$store = $this->storeRepository->get($legacy['store_code']);
|
|
$websiteId = (int) $store->getWebsiteId();
|
|
$groupId = (int) $store->getStoreGroupId();
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('MageSail legacy migration: could not load store: ' . $e->getMessage());
|
|
}
|
|
$tunnel = [
|
|
'id' => self::MIGRATED_LEGACY_TUNNEL_ID,
|
|
'label' => (string) __('Migrated tunnel'),
|
|
'website_id' => $websiteId,
|
|
'group_id' => $groupId,
|
|
'store_id' => (int) $legacy['store_id'],
|
|
'store_code' => (string) $legacy['store_code'],
|
|
'unsecure_base_url' => (string) $legacy['unsecure_base_url'],
|
|
'secure_base_url' => (string) $legacy['secure_base_url'],
|
|
'use_secure_urls' => (bool) ($legacy['use_secure_urls'] ?? false),
|
|
'local_port' => $port,
|
|
'secure' => $secure,
|
|
'nginx_pending_reload' => (bool) ($legacy['nginx_pending_reload'] ?? false),
|
|
];
|
|
$tunnel = $this->normalizeTunnelRecord($tunnel);
|
|
try {
|
|
$this->writeData(['version' => self::CURRENT_VERSION, 'tunnels' => [$tunnel]]);
|
|
$this->migrateLegacyProcessFilesIfPresent();
|
|
$this->legacyTunnelStoreState->delete();
|
|
} catch (\Throwable $e) {
|
|
$this->logger->error('MageSail legacy migration failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function hasNonEmptyRegistry(): bool
|
|
{
|
|
$path = $this->getFilePath();
|
|
if (!\is_readable($path)) {
|
|
return false;
|
|
}
|
|
$raw = file_get_contents($path);
|
|
if ($raw === false || trim($raw) === '') {
|
|
return false;
|
|
}
|
|
try {
|
|
/** @var array $data */
|
|
$data = $this->json->unserialize($raw);
|
|
} catch (\InvalidArgumentException) {
|
|
return false;
|
|
}
|
|
return isset($data['tunnels']) && \is_array($data['tunnels']) && \count($data['tunnels']) > 0;
|
|
}
|
|
|
|
private function migrateLegacyProcessFilesIfPresent(): void
|
|
{
|
|
$id = self::MIGRATED_LEGACY_TUNNEL_ID;
|
|
$legacyPid = $this->tunnelProcessPaths->getLegacyPidFilePath();
|
|
$newPid = $this->tunnelProcessPaths->getPidFilePath($id);
|
|
if (\is_file($legacyPid) && !\is_file($newPid)) {
|
|
@rename($legacyPid, $newPid);
|
|
}
|
|
$legacyKey = $this->tunnelProcessPaths->getLegacyKeyFilePath();
|
|
$newKey = $this->tunnelProcessPaths->getKeyFilePath($id);
|
|
if (\is_file($legacyKey) && !\is_file($newKey)) {
|
|
@rename($legacyKey, $newKey);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $t
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeTunnelRecord(array $t): array
|
|
{
|
|
return [
|
|
'id' => (string) ($t['id'] ?? ''),
|
|
'tunnel_type' => (string) ($t['tunnel_type'] ?? 'website'),
|
|
'label' => (string) ($t['label'] ?? ''),
|
|
'website_id' => (int) ($t['website_id'] ?? 0),
|
|
'group_id' => (int) ($t['group_id'] ?? 0),
|
|
'store_id' => (int) ($t['store_id'] ?? 0),
|
|
'store_code' => (string) ($t['store_code'] ?? ''),
|
|
'unsecure_base_url' => (string) ($t['unsecure_base_url'] ?? ''),
|
|
'secure_base_url' => (string) ($t['secure_base_url'] ?? ''),
|
|
'use_secure_urls' => (bool) ($t['use_secure_urls'] ?? false),
|
|
'local_port' => (int) ($t['local_port'] ?? 80),
|
|
'secure' => (bool) ($t['secure'] ?? false),
|
|
'nginx_pending_reload' => (bool) ($t['nginx_pending_reload'] ?? false),
|
|
'service_key' => (string) ($t['service_key'] ?? ''),
|
|
'service_label' => (string) ($t['service_label'] ?? ''),
|
|
'service_target_host' => (string) ($t['service_target_host'] ?? ''),
|
|
];
|
|
}
|
|
}
|