Files
MageSail/Magesail/Controller/Adminhtml/Tunnel/Index.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

452 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Controller\Adminhtml\Tunnel;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Exception\LocalizedException;
use MageSail\Magesail\Model\EnvServicePortResolver;
use MageSail\Magesail\Model\MagesailLog;
use MageSail\Magesail\Model\TunnelManager;
use MageSail\Magesail\Model\TunnelStatus;
use MageSail\Magesail\Model\TunnelStoreProvisioner;
class Index extends Action
{
public function __construct(
Context $context,
private readonly ScopeConfigInterface $scopeConfig,
private readonly TunnelManager $tunnelManager,
private readonly TunnelStatus $tunnelStatus,
private readonly MagesailLog $magesailLog,
private readonly TunnelStoreProvisioner $tunnelStoreProvisioner,
private readonly EnvServicePortResolver $envServicePortResolver
) {
parent::__construct($context);
}
public function execute()
{
$action = $this->getRequest()->getParam('action');
$wantJson = $this->isTunnelAjaxRequest();
if ($action === 'start') {
return $this->handleStart($wantJson);
}
if ($action === 'stop') {
return $this->handleStop($wantJson);
}
if ($action === 'delete') {
return $this->handleDelete($wantJson);
}
$resultPage = $this->resultFactory->create(ResultFactory::TYPE_PAGE);
$resultPage->setActiveMenu('MageSail_Magesail::tunnel_index');
$resultPage->getConfig()->getTitle()->prepend(__('Holesail Tunnel'));
return $resultPage;
}
private function isTunnelAjaxRequest(): bool
{
return strtolower((string) $this->getRequest()->getHeader('X-Requested-With')) === 'xmlhttprequest'
|| (string) $this->getRequest()->getParam('ajax') === '1';
}
private function jsonPayload(bool $ok, string $message = '', array $extra = [])
{
$json = $this->resultFactory->create(ResultFactory::TYPE_JSON);
$json->setData(array_merge([
'success' => $ok,
'message' => $message,
'tunnels' => $this->tunnelStatus->getAllTunnelStatuses(),
], $extra));
return $json;
}
private function handleStart(bool $wantJson)
{
if (!$this->isAllowedTunnelManage()) {
$msg = (string) __('You are not allowed to manage tunnels.');
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
$tunnelId = trim((string) $this->getRequest()->getParam('tunnel_id', ''));
if ($tunnelId !== '') {
return $this->handleStartExisting($tunnelId, $wantJson);
}
return $this->handleStartNew($wantJson);
}
private function handleStartExisting(string $tunnelId, bool $wantJson)
{
if ($this->tunnelStatus->isRunning($tunnelId)) {
if ($wantJson) {
return $this->jsonPayload(false, (string) __('This tunnel is already running.'));
}
$this->messageManager->addErrorMessage(__('This tunnel is already running.'));
return $this->_redirect('*/*/index');
}
$result = $this->tunnelManager->start($tunnelId);
if ($result['ok']) {
$this->magesailLog->add(sprintf('Tunnel started %s PID %s', $tunnelId, $result['pid'] ?? ''));
$msg = (string) __(
'Tunnel started (PID: %1). Key will appear once ready.',
$result['pid'] ?? ''
);
if ($wantJson) {
return $this->jsonPayload(true, $msg);
}
$this->messageManager->addSuccessMessage($msg);
return $this->_redirect('*/*/index');
}
$msg = match ($result['error'] ?? '') {
'unknown_tunnel' => (string) __('Unknown tunnel.'),
'already_running' => (string) __('Tunnel is already running.'),
'invalid_port' => (string) __('Invalid local port for this tunnel.'),
'could_not_create_var_dir' => (string) __('Could not create var directory for tunnel state.'),
'script_not_found' => (string) __(
'start-server.js was not found. Set Scripts directory in Configuration (expected: %1).',
$result['detail'] ?? ''
),
'node_modules_missing' => (string) __(
'Run npm install in the scripts directory (missing node_modules under %1).',
$result['detail'] ?? ''
),
'pid_capture_failed' => (string) __('Failed to capture PID: %1', $result['detail'] ?? ''),
default => (string) __('Error starting tunnel: %1', $result['error'] ?? 'unknown'),
};
$this->magesailLog->add('Start failed: ' . ($result['error'] ?? 'unknown'));
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
private function handleStartNew(bool $wantJson)
{
$addType = trim((string) $this->getRequest()->getParam('tunnel_add_type', 'website'));
if ($addType === 'service') {
return $this->handleStartNewService($wantJson);
}
$label = trim((string) $this->getRequest()->getParam('tunnel_label', ''));
$websiteId = (int) $this->getRequest()->getParam('website_id', 0);
$groupId = (int) $this->getRequest()->getParam('group_id', 0);
$tunnelBaseUrl = trim((string) $this->getRequest()->getParam('tunnel_base_url', ''));
$tunnelSecureBaseUrl = trim((string) $this->getRequest()->getParam('tunnel_secure_base_url', ''));
$useSecureUrls = $this->isTruthyParam($this->getRequest()->getParam('tunnel_use_secure_urls'));
$localPort = filter_var($this->getRequest()->getParam('local_port'), FILTER_VALIDATE_INT);
$holesailSecureParam = $this->getRequest()->getParam('tunnel_holesail_secure');
$secureHolesail = $holesailSecureParam === null
? (bool) $this->scopeConfig->getValue('magesail/settings/mode')
: $this->isTruthyParam($holesailSecureParam);
if ($websiteId <= 0 || $groupId <= 0) {
$msg = (string) __('Select a website and store group.');
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
if ($tunnelBaseUrl === '') {
$msg = (string) __('Tunnel hostname is required (e.g. dev.myshop.local — must match your holesail-browser virtual host).');
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
if (!$localPort || $localPort < 1 || $localPort > 65535) {
$msg = (string) __('Invalid local port (165535).');
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
try {
$provision = $this->tunnelStoreProvisioner->provisionNewTunnel(
$label,
$websiteId,
$groupId,
$tunnelBaseUrl,
$tunnelSecureBaseUrl,
$useSecureUrls,
(int) $localPort,
$secureHolesail
);
} catch (LocalizedException $e) {
$this->magesailLog->add('Tunnel store provision failed: ' . $e->getMessage());
if ($wantJson) {
return $this->jsonPayload(false, (string) $e->getMessage());
}
$this->messageManager->addErrorMessage($e->getMessage());
return $this->_redirect('*/*/index');
}
$newTunnelId = $provision['tunnel_id'];
$result = $this->tunnelManager->start($newTunnelId);
if (!$result['ok']) {
$this->tunnelStoreProvisioner->teardownTunnel($newTunnelId);
}
if ($result['ok']) {
$this->magesailLog->add(sprintf(
'Tunnel started %s PID %s port %s',
$newTunnelId,
$result['pid'] ?? '',
$localPort
));
$msg = (string) __(
'Tunnel started (PID: %1). Key will appear once ready.',
$result['pid'] ?? ''
);
$nginxReloadMsg = '';
if (empty($provision['nginx_reloaded'])) {
$nginxReloadMsg = (string) __(
'The NGINX global map was updated, but Magento could not reload nginx '
. '(common when the PHP user cannot signal the nginx master). '
. 'On the server run: sudo systemctl reload nginx, or set Reload wrapper script '
. 'in Admin under Configuration for the Holesail Tunnel NGINX settings. '
. 'Until nginx reloads, your tunnel hostname may not route to the tunnel store.'
);
}
if ($wantJson) {
$extra = [];
if ($nginxReloadMsg !== '') {
$extra['nginx_reload_required'] = true;
$extra['nginx_reload_message'] = $nginxReloadMsg;
}
return $this->jsonPayload(true, $msg, $extra);
}
$this->messageManager->addSuccessMessage($msg);
if ($nginxReloadMsg !== '') {
$this->messageManager->addWarningMessage($nginxReloadMsg);
}
return $this->_redirect('*/*/index');
}
$msg = match ($result['error'] ?? '') {
'already_running' => (string) __('Tunnel is already running.'),
'invalid_port' => (string) __('Invalid local port.'),
'could_not_create_var_dir' => (string) __('Could not create var directory for tunnel state.'),
'pid_capture_failed' => (string) __('Failed to capture PID: %1', $result['detail'] ?? ''),
default => (string) __('Error starting tunnel: %1', $result['error'] ?? 'unknown'),
};
$this->magesailLog->add('Start failed: ' . ($result['error'] ?? 'unknown'));
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
private function handleStop(bool $wantJson)
{
if (!$this->isAllowedTunnelManage()) {
$msg = (string) __('You are not allowed to manage tunnels.');
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
$tunnelId = trim((string) $this->getRequest()->getParam('tunnel_id', ''));
if ($tunnelId === '') {
if ($wantJson) {
return $this->jsonPayload(false, (string) __('Missing tunnel id.'));
}
$this->messageManager->addErrorMessage(__('Missing tunnel id.'));
return $this->_redirect('*/*/index');
}
$stop = $this->tunnelManager->stop($tunnelId);
if ($stop['ok']) {
$this->magesailLog->add(sprintf('Tunnel stopped %s', $tunnelId));
if ($wantJson) {
return $this->jsonPayload(true, (string) __('Tunnel stopped.'), [
'message' => (string) __('Tunnel stopped.'),
]);
}
$this->messageManager->addSuccessMessage(__('Tunnel stopped.'));
} else {
if (($stop['error'] ?? '') === 'not_running') {
if ($wantJson) {
return $this->jsonPayload(true, (string) __('No process running for this tunnel.'));
}
$this->messageManager->addNoticeMessage(__('No process running for this tunnel.'));
} else {
if ($wantJson) {
return $this->jsonPayload(false, (string) __('Error stopping tunnel: %1', $stop['error'] ?? ''));
}
$this->messageManager->addErrorMessage(__('Error stopping tunnel: %1', $stop['error'] ?? ''));
}
}
return $wantJson ? $this->jsonPayload($stop['ok'], '') : $this->_redirect('*/*/index');
}
private function handleStartNewService(bool $wantJson)
{
if (!$this->isAllowedTunnelManage()) {
$msg = (string) __('You are not allowed to manage tunnels.');
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
$label = trim((string) $this->getRequest()->getParam('tunnel_label', ''));
$serviceKey = trim((string) $this->getRequest()->getParam('service_key', ''));
$holesailSecureParam = $this->getRequest()->getParam('tunnel_holesail_secure');
$secureHolesail = $holesailSecureParam === null
? (bool) $this->scopeConfig->getValue('magesail/settings/mode')
: $this->isTruthyParam($holesailSecureParam);
if ($serviceKey === '') {
$msg = (string) __('Select a service to expose.');
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
$resolved = $this->envServicePortResolver->resolveService($serviceKey);
if ($resolved === null) {
$msg = (string) __(
'That service is not available anymore. Refresh the page to reload the list from env.php.'
);
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
$serviceLabel = $serviceKey;
foreach ($this->envServicePortResolver->getDiscoveredServices() as $row) {
if ($row['service_key'] === $serviceKey) {
$serviceLabel = $row['label'];
break;
}
}
try {
$provision = $this->tunnelStoreProvisioner->provisionServiceTunnel(
$label,
$serviceKey,
$serviceLabel,
$resolved['host'],
$resolved['port'],
$secureHolesail
);
} catch (LocalizedException $e) {
$this->magesailLog->add('Service tunnel provision failed: ' . $e->getMessage());
if ($wantJson) {
return $this->jsonPayload(false, (string) $e->getMessage());
}
$this->messageManager->addErrorMessage($e->getMessage());
return $this->_redirect('*/*/index');
}
$newTunnelId = $provision['tunnel_id'];
$result = $this->tunnelManager->start($newTunnelId);
if (!$result['ok']) {
$this->tunnelStoreProvisioner->teardownTunnel($newTunnelId);
}
if ($result['ok']) {
$this->magesailLog->add(sprintf(
'Service tunnel started %s PID %s port %s',
$newTunnelId,
$result['pid'] ?? '',
$resolved['port']
));
$msg = (string) __(
'Tunnel started (PID: %1). Key will appear once ready.',
$result['pid'] ?? ''
);
if ($wantJson) {
return $this->jsonPayload(true, $msg);
}
$this->messageManager->addSuccessMessage($msg);
return $this->_redirect('*/*/index');
}
$msg = match ($result['error'] ?? '') {
'already_running' => (string) __('Tunnel is already running.'),
'invalid_port' => (string) __('Invalid local port.'),
'could_not_create_var_dir' => (string) __('Could not create var directory for tunnel state.'),
'pid_capture_failed' => (string) __('Failed to capture PID: %1', $result['detail'] ?? ''),
default => (string) __('Error starting tunnel: %1', $result['error'] ?? 'unknown'),
};
$this->magesailLog->add('Service tunnel start failed: ' . ($result['error'] ?? 'unknown'));
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
private function handleDelete(bool $wantJson)
{
if (!$this->isAllowedTunnelManage()) {
$msg = (string) __('You are not allowed to manage tunnels.');
if ($wantJson) {
return $this->jsonPayload(false, $msg);
}
$this->messageManager->addErrorMessage($msg);
return $this->_redirect('*/*/index');
}
$tunnelId = trim((string) $this->getRequest()->getParam('tunnel_id', ''));
if ($tunnelId === '') {
if ($wantJson) {
return $this->jsonPayload(false, (string) __('Missing tunnel id.'));
}
$this->messageManager->addErrorMessage(__('Missing tunnel id.'));
return $this->_redirect('*/*/index');
}
$this->tunnelManager->stop($tunnelId);
$this->tunnelStoreProvisioner->teardownTunnel($tunnelId);
$this->magesailLog->add(sprintf('Tunnel removed %s', $tunnelId));
if ($wantJson) {
return $this->jsonPayload(true, (string) __('Tunnel removed.'));
}
$this->messageManager->addSuccessMessage(__('Tunnel removed.'));
return $this->_redirect('*/*/index');
}
private function isTruthyParam(mixed $value): bool
{
if ($value === true || $value === 1) {
return true;
}
if ($value === false || $value === null || $value === '') {
return false;
}
$s = strtolower(trim((string) $value));
return \in_array($s, ['1', 'true', 'on', 'yes'], true);
}
protected function _isAllowed()
{
return $this->isAllowedTunnelUi();
}
private function isAllowedTunnelUi(): bool
{
return $this->_authorization->isAllowed('MageSail_Magesail::tunnel')
|| $this->_authorization->isAllowed('MageSail_Magesail::tunnel_view')
|| $this->_authorization->isAllowed('MageSail_Magesail::tunnel_manage');
}
private function isAllowedTunnelManage(): bool
{
return $this->_authorization->isAllowed('MageSail_Magesail::tunnel')
|| $this->_authorization->isAllowed('MageSail_Magesail::tunnel_manage');
}
}