176 lines
5.5 KiB
PHP
176 lines
5.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
final class LoginUrlRequest
|
|
{
|
|
public function __construct(
|
|
public readonly string $username,
|
|
public readonly string $sourceHost,
|
|
public readonly string $serverName,
|
|
public readonly int $ttl,
|
|
public readonly bool $userIpBound,
|
|
public readonly string $clientIp,
|
|
public readonly string $redirectPath,
|
|
public readonly int $createdAt
|
|
) {
|
|
}
|
|
}
|
|
|
|
final class ProcessResult
|
|
{
|
|
public function __construct(
|
|
public readonly int $exitCode,
|
|
public readonly string $stdout,
|
|
public readonly string $stderr
|
|
) {
|
|
}
|
|
}
|
|
|
|
final class RemoteLoginUrlClient
|
|
{
|
|
/** @var callable(array<int,string>,int):ProcessResult */
|
|
private $runner;
|
|
|
|
/**
|
|
* @param null|callable(array<int,string>,int):ProcessResult $runner
|
|
*/
|
|
public function __construct(?callable $runner = null)
|
|
{
|
|
$this->runner = $runner ?? [self::class, 'runProcess'];
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
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(),
|
|
];
|
|
|
|
if ($settings->mxLoginMode() === 1) {
|
|
$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;
|
|
}
|
|
|
|
return array_merge($base, [
|
|
'mail-login-create-url',
|
|
$request->username,
|
|
$request->sourceHost,
|
|
$request->serverName,
|
|
(string)$request->ttl,
|
|
$request->userIpBound ? '1' : '0',
|
|
$request->clientIp,
|
|
$request->redirectPath,
|
|
(string)$request->createdAt,
|
|
]);
|
|
}
|
|
|
|
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 helper 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 = $settings->mxLoginMode() === 1 ? 'MX direct SSH command failed' : 'MX helper failed';
|
|
$message .= ' (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<string> $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 helper');
|
|
}
|
|
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 helper 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));
|
|
}
|
|
}
|