Files
MageSail/Magesail/Model/TunnelRegistry.php
T
snxraven 960db9801c
CI / php (push) Successful in 2m53s
feat(tunnels): add Service type with dynamic env.php endpoint discovery
- 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
2026-03-21 04:38:37 -05:00

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'] ?? ''),
];
}
}