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
452 lines
19 KiB
PHP
452 lines
19 KiB
PHP
<?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 (1–65535).');
|
||
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');
|
||
}
|
||
}
|