,int):ProcessResult */ private $runner; /** * @param null|callable(array,int):ProcessResult $runner */ public function __construct(?callable $runner = null) { $this->runner = $runner ?? [self::class, 'runProcess']; } /** * @return list */ public static function buildCommand(Settings $settings, LoginUrlRequest $request): array { $base = [ 'ssh', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' . $settings->sshKnownHosts(), '-o', 'IdentitiesOnly=yes', '-i', $settings->sshIdentity(), '-p', (string)$settings->sshPort(), $settings->sshUser() . '@' . $settings->sshHost(), ]; $command = array_merge($base, [ '/usr/bin/da', 'login-url', '--user=' . $request->username, '--redirect-url=' . $request->redirectPath, ]); if ($request->ttl > 0) { $command[] = '--expiry=' . $request->ttl . 's'; } if ($request->userIpBound) { $command[] = '--ip=' . $request->clientIp; } return $command; } public function create(Settings $settings, LoginUrlRequest $request): string { $command = self::buildCommand($settings, $request); $result = ($this->runner)($command, $settings->connectTimeout()); if (!$result instanceof ProcessResult) { throw new RuntimeException('Invalid process runner result'); } if ($result->exitCode !== 0) { throw new RuntimeException(self::failureMessage($settings, $result)); } if (!preg_match('/https:\/\/\S+/', $result->stdout, $match)) { throw new RuntimeException('MX direct SSH command did not return a login URL'); } try { return UrlValidator::assertGeneratedLoginUrl(trim($match[0]), $settings); } catch (InvalidArgumentException $e) { throw new RuntimeException($e->getMessage(), 0, $e); } } private static function failureMessage(Settings $settings, ProcessResult $result): string { $message = 'MX direct SSH command failed (exit ' . $result->exitCode . ')'; $detail = self::safeDiagnostic($result->stderr); if ($detail !== '') { $message .= ': ' . $detail; } return $message; } private static function safeDiagnostic(string $text): string { $text = preg_replace('/https?:\/\/\S+/i', '[redacted-url]', $text) ?? ''; $text = preg_replace('/\s+/', ' ', trim($text)) ?? ''; return substr($text, 0, 512); } /** * @param list $command */ public static function runProcess(array $command, int $timeout): ProcessResult { $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $process = proc_open($command, $descriptors, $pipes); if (!is_resource($process)) { throw new RuntimeException('Cannot start MX direct SSH command'); } fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $stdout = ''; $stderr = ''; $deadline = time() + $timeout; do { $stdout .= stream_get_contents($pipes[1]) ?: ''; $stderr .= stream_get_contents($pipes[2]) ?: ''; $status = proc_get_status($process); if (!$status['running']) { break; } if (time() >= $deadline) { proc_terminate($process); foreach ([$pipes[1], $pipes[2]] as $pipe) { fclose($pipe); } proc_close($process); throw new RuntimeException('MX direct SSH command timed out'); } usleep(100000); } while (true); $stdout .= stream_get_contents($pipes[1]) ?: ''; $stderr .= stream_get_contents($pipes[2]) ?: ''; fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($process); return new ProcessResult($exit, substr($stdout, 0, 8192), substr($stderr, 0, 8192)); } }