scopeConfig->getValue(self::XML_SCRIPT_PATH)); if ($configured !== '') { return $configured; } return BP . '/' . self::DEFAULT_SCRIPT_REL; } /** * Executable for Node (config or "node" on PATH). Shell-escaped when embedded in commands. */ private function getNodeBinaryForShell(): string { $configured = trim((string) $this->scopeConfig->getValue(self::XML_NODE_BINARY)); if ($configured === '' || strpbrk($configured, "\r\n") !== false) { return escapeshellarg('node'); } return escapeshellarg($configured); } /** * Starts the Node process for an existing registry tunnel (uses persisted port and secure flag). * * @return array{ok: bool, pid?: int, error?: string} */ public function start(string $tunnelId): array { $tunnel = $this->tunnelRegistry->getById($tunnelId); if ($tunnel === null) { return ['ok' => false, 'error' => 'unknown_tunnel']; } $port = (int) ($tunnel['local_port'] ?? 0); $secure = (bool) ($tunnel['secure'] ?? false); if ($this->tunnelStatus->isRunning($tunnelId)) { return ['ok' => false, 'error' => 'already_running']; } if ($port < 1 || $port > 65535) { return ['ok' => false, 'error' => 'invalid_port']; } $dir = $this->tunnelProcessPaths->getVarTunnelDir(); if (!\is_dir($dir) && !@mkdir($dir, 0775, true) && !\is_dir($dir)) { $this->logger->warning('MageSail: could not create var tunnel directory.'); return ['ok' => false, 'error' => 'could_not_create_var_dir']; } $scriptDir = $this->getScriptDir(); $startScript = rtrim($scriptDir, '/') . '/start-server.js'; if (!\is_readable($startScript)) { $this->logger->warning('MageSail: start-server.js missing or not readable at {path}', ['path' => $startScript]); return ['ok' => false, 'error' => 'script_not_found', 'detail' => $startScript]; } $nodeModules = rtrim($scriptDir, '/') . '/node_modules'; if (!\is_dir($nodeModules)) { $this->logger->warning('MageSail: npm dependencies missing (no node_modules in scripts directory). Path: {path}', ['path' => $scriptDir]); return ['ok' => false, 'error' => 'node_modules_missing', 'detail' => $scriptDir]; } $keyFile = $this->tunnelProcessPaths->getKeyFilePath($tunnelId); $logFile = $this->tunnelProcessPaths->getLogFilePath($tunnelId); $pidFile = $this->tunnelProcessPaths->getPidFilePath($tunnelId); $forwardHost = trim((string) ($tunnel['service_target_host'] ?? '')); if ($forwardHost === '') { $forwardHost = '127.0.0.1'; } $jsArgs = json_encode([ 'port' => $port, 'secure' => $secure, 'forwardHost' => $forwardHost, 'keyfile' => $keyFile, 'logfile' => $logFile, ], JSON_THROW_ON_ERROR); $arg = escapeshellarg($jsArgs); $dirEscaped = escapeshellarg($scriptDir); $log = escapeshellarg($logFile); $nodeBin = $this->getNodeBinaryForShell(); $fullCmd = "cd $dirEscaped && nohup $nodeBin start-server.js $arg > $log 2>&1 & echo \$!"; try { $output = $this->shell->execute($fullCmd); $pid = (int) trim($output); if ($pid > 0) { file_put_contents($pidFile, (string) $pid); $this->eventManager->dispatch('magesail_tunnel_start_after', [ 'tunnel_id' => $tunnelId, 'store_code' => $tunnel['store_code'] ?? '', 'pid' => $pid, 'port' => $port, 'secure' => $secure, ]); return ['ok' => true, 'pid' => $pid]; } $this->logger->warning('MageSail: failed to capture PID after start. Output: {output}', ['output' => $output]); return ['ok' => false, 'error' => 'pid_capture_failed', 'detail' => $output]; } catch (\Throwable $e) { $this->logger->error('MageSail: tunnel start exception: ' . $e->getMessage(), ['exception' => $e]); return ['ok' => false, 'error' => $e->getMessage()]; } } /** * @return array{ok: bool, error?: string} */ public function stop(string $tunnelId): array { $pidFile = $this->tunnelProcessPaths->getPidFilePath($tunnelId); $keyFile = $this->tunnelProcessPaths->getKeyFilePath($tunnelId); if (!\is_file($pidFile)) { return ['ok' => false, 'error' => 'not_running']; } $pid = (int) trim((string) file_get_contents($pidFile)); if ($pid <= 0 || !$this->tunnelStatus->isProcessAlive($pid)) { @unlink($pidFile); if (\is_file($keyFile)) { @unlink($keyFile); } return ['ok' => false, 'error' => 'not_running']; } try { $this->shell->execute('kill %s', [$pid]); @unlink($pidFile); if (\is_file($keyFile)) { @unlink($keyFile); } $this->eventManager->dispatch('magesail_tunnel_stop_after', [ 'tunnel_id' => $tunnelId, 'pid' => $pid, ]); return ['ok' => true]; } catch (\Throwable $e) { $this->logger->error('MageSail: tunnel stop exception: ' . $e->getMessage(), ['exception' => $e]); return ['ok' => false, 'error' => $e->getMessage()]; } } }