create(); $team = $user->teams()->first(); $privateKeyContent = '-----BEGIN OPENSSH PRIVATE KEY----- '. 'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW '. 'QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk '. 'hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA '. 'AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV '. 'uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== '. '-----END OPENSSH PRIVATE KEY-----'; $privateKey = PrivateKey::create([ 'name' => 'mux-test-key-'.uniqid(), 'private_key' => $privateKeyContent, 'team_id' => $team->id, ]); Storage::fake('ssh-keys'); Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent); $server = Server::factory()->create([ 'team_id' => $team->id, 'private_key_id' => $privateKey->id, ]); // These tests count ssh processes; the host key check would add a key scan. $server->settings->forceFill(['is_ssh_host_key_check_disabled' => true])->save(); Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key); return $server; } /** * A fake process that times out. Laravel does not record a fake that throws, so the * timeout of each call goes into $timeouts. * * @param array $timeouts */ function muxProcessTimeout(array &$timeouts = []): Closure { return function ($pendingProcess) use (&$timeouts) { $timeouts[] = $pendingProcess->timeout; $process = new SymfonyProcess(['ssh']); throw new ProcessTimedOutException( new SymfonyTimeoutException($process, SymfonyTimeoutException::TYPE_GENERAL), new ProcessResult($process), ); }; } /** * The command that starts a mux master. It runs `true`, so it exits when another process owns the socket. */ function isMuxEstablishCommand(string $command): bool { return str_starts_with($command, 'ssh -o ControlMaster=auto ') && str_ends_with($command, ' true'); } it('establishes a master with a command that exits, never with an ssh -fN that can stay open forever', function () { config(['constants.ssh.mux_enabled' => true]); $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(exitCode: 1), '* true' => Process::result(exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); Process::assertRan(fn ($process) => isMuxEstablishCommand($process->command) && str_contains($process->command, 'ControlPersist=') && ! str_contains($process->command, '-fN')); }); it('reuses an existing healthy master without spawning a new one', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_health_check_enabled' => true, ]); $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(exitCode: 0), '*health_check_ok*' => Process::result(output: 'health_check_ok', exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); Process::assertNotRan(fn ($process) => isMuxEstablishCommand($process->command)); }); it('reuses a healthy master regardless of its absolute age', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_health_check_enabled' => false, ]); $server = makeMuxServer(); Cache::put("ssh_mux_connection_time_{$server->uuid}", time() - 7200, 10800); Process::fake([ '*-O check*' => Process::result(exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -F /dev/null -O stop')); Process::assertNotRan(fn ($process) => isMuxEstablishCommand($process->command)); }); it('bounds mux check commands so a wedged master cannot block callers', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_health_check_timeout' => 5, ]); $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(exitCode: 1), '* true' => Process::result(exitCode: 0), ]); SshMultiplexingHelper::ensureMultiplexedConnection($server); Process::assertRan(fn ($process) => str_contains($process->command, '-O check')); Process::assertNotRan(fn ($process) => str_contains($process->command, '-O check') && $process->timeout !== 5); }); it('replaces a wedged master whose control check times out', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_health_check_timeout' => 5, ]); $server = makeMuxServer(); $muxSocket = "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"; File::partialMock()->shouldReceive('delete')->once()->with($muxSocket)->andReturnTrue(); $timeouts = []; Process::fake([ '*-O check*' => muxProcessTimeout($timeouts), '* true' => Process::result(exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue() ->and($timeouts)->each->toBe(5); Process::assertRan(fn ($process) => isMuxEstablishCommand($process->command)); Process::assertNotRan(fn ($process) => str_contains($process->command, '-O stop')); }); it('does not delete the control socket when the master is simply absent', function () { config(['constants.ssh.mux_enabled' => true]); $server = makeMuxServer(); File::partialMock()->shouldNotReceive('delete'); Process::fake([ '*-O check*' => Process::result(exitCode: 255), '* true' => Process::result(exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); }); it('bounds the control commands used to stop a mux master', function () { config(['constants.ssh.mux_health_check_timeout' => 5]); $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(output: 'Master running (pid=222)', exitCode: 0), '*-O stop*' => Process::result(exitCode: 0), ]); SshMultiplexingHelper::removeMuxFile($server); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -F /dev/null -O check') && $process->timeout === 5); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -F /dev/null -O stop') && $process->timeout === 5); }); it('replaces a master whose health check times out', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_health_check_enabled' => true, ]); $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(output: 'Master running (pid=222)', exitCode: 0), '*health_check_ok*' => muxProcessTimeout(), '*-O stop*' => Process::result(exitCode: 0), '* true' => Process::result(exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -F /dev/null -O stop')); Process::assertRan(fn ($process) => isMuxEstablishCommand($process->command)); }); it('gives the mux health check at least the server connection timeout', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_health_check_enabled' => true, 'constants.ssh.mux_health_check_timeout' => 5, ]); $server = makeMuxServer(); $server->settings->update(['connection_timeout' => 30]); Process::fake([ '*-O check*' => Process::result(exitCode: 0), '*health_check_ok*' => Process::result(output: 'health_check_ok', exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server->fresh()))->toBeTrue(); Process::assertRan(fn ($process) => str_contains($process->command, 'health_check_ok') && $process->timeout === 30); Process::assertNotRan(fn ($process) => str_contains($process->command, '-O check') && $process->timeout !== 5); }); it('runs the mux health check with the server port and key, and never as a new master', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_health_check_enabled' => true, ]); $server = makeMuxServer(); $server->update(['port' => 2222]); $server->settings->update(['connection_timeout' => 17]); $server = $server->fresh(); Process::fake([ '*-O check*' => Process::result(exitCode: 0), '*health_check_ok*' => Process::result(output: 'health_check_ok', exitCode: 0), ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); Process::assertRan(fn ($process) => str_contains($process->command, 'health_check_ok') && str_contains($process->command, '-o ControlMaster=no ') && ! str_contains($process->command, 'ControlMaster=auto') && str_contains($process->command, "-p '2222' ") && str_contains($process->command, '-i '.escapeshellarg($server->privateKey->getKeyLocation()).' ') && str_contains($process->command, '-o ConnectTimeout=17 ') && str_contains($process->command, '-o StrictHostKeyChecking=no ')); }); it('does not spawn a master when the per-server lock is already held', function () { config([ 'constants.ssh.mux_enabled' => true, 'constants.ssh.mux_lock_timeout' => 0, ]); $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(exitCode: 1), ]); $lockKey = 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; $held = Cache::lock($lockKey, 30); expect($held->get())->toBeTrue(); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse(); Process::assertNotRan(fn ($process) => isMuxEstablishCommand($process->command)); $held->release(); }); it('returns false and runs no ssh when multiplexing is disabled', function () { config(['constants.ssh.mux_enabled' => false]); $server = makeMuxServer(); Process::fake(); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse(); Process::assertNothingRan(); }); it('adds mux options to ssh commands only after the explicit master is ready', function () { config(['constants.ssh.mux_enabled' => true]); $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(exitCode: 1), '* true' => Process::result(exitCode: 0), ]); $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok'); expect($command) ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath='/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}'") ->toContain('-o ControlPersist=3600') ->toContain("'sh -c \"if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi\"' << \\") ->not->toContain('<< $delimiter'); Process::assertRan(fn ($process) => isMuxEstablishCommand($process->command)); }); it('quotes the mux socket and key paths in every local ssh command', function () { config(['constants.ssh.mux_enabled' => true]); $server = makeMuxServer(); // Ids with shell syntax, as an import bundle could have had them before ids were validated. $server->forceFill(['uuid' => 'x;touch /tmp/pwn;#'])->saveQuietly(); $server->privateKey->forceFill(['uuid' => 'a $(id)'])->saveQuietly(); $server = Server::find($server->id); Storage::disk('ssh-keys')->put('ssh_key@a $(id)', $server->privateKey->private_key); Process::fake([ '*-O check*' => Process::result(exitCode: 1), '* true' => Process::result(exitCode: 0), ]); $socket = escapeshellarg('/var/www/html/storage/app/ssh/mux/mux_x;touch /tmp/pwn;#'); $key = escapeshellarg($server->privateKey->getKeyLocation()); $sshCommand = SshMultiplexingHelper::generateSshCommand($server, 'echo ok'); $scpCommand = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest'); foreach ([$sshCommand, $scpCommand] as $command) { expect($command)->toContain("-o ControlPath={$socket} ")->toContain("-i {$key} "); } Process::assertRan(fn ($process) => isMuxEstablishCommand($process->command) && str_contains($process->command, "-o ControlPath={$socket} ") && str_contains($process->command, "-i {$key} ")); Process::assertRan(fn ($process) => str_contains($process->command, "ssh -F /dev/null -O check -o ControlPath={$socket} ")); Process::assertDidntRun(fn ($process) => str_contains($process->command, 'mux_x;touch /tmp/pwn;# ') || str_contains($process->command, '-i /') || str_contains($process->command, 'ControlPath=/')); }); it('can generate terminal ssh commands without a hard command timeout', function () { config(['constants.ssh.mux_enabled' => false]); $server = makeMuxServer(); $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0); expect($command) ->toStartWith('ssh ') ->not->toStartWith('timeout ') ->not->toContain('timeout 3600 ssh'); }); it('omits multiplexing options and setup when disabled for a command', function () { config(['constants.ssh.mux_enabled' => true]); $server = makeMuxServer(); Process::fake(); $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true); expect($command) ->not->toContain('-o ControlMaster=auto') ->not->toContain('-o ControlPath=') ->not->toContain('-o ControlPersist='); Process::assertNothingRan(); }); it('adds mux options to scp commands only after the explicit master is ready', function () { config(['constants.ssh.mux_enabled' => true]); $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(exitCode: 1), '* true' => Process::result(exitCode: 0), ]); $command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest'); expect($command) ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath='/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}'") ->toContain('-o ControlPersist=3600'); Process::assertRan(fn ($process) => isMuxEstablishCommand($process->command)); }); it('kills only old orphaned ssh masters whose control socket no longer exists', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); $muxDir = storage_path('app/ssh/mux'); File::ensureDirectoryExists($muxDir); $liveSocket = $muxDir.'/mux_live_'.uniqid(); $orphanSocket = $muxDir.'/mux_orphan_'.uniqid(); $youngSocket = $muxDir.'/mux_young_'.uniqid(); File::put($liveSocket, 'x'); Process::fake([ 'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$liveSocket} root@1.2.3.4 ". "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4 ". "333 1 30 ssh -fN -o ControlMaster=auto -o ControlPath={$youngSocket} root@1.2.3.4 "), '*ssh -F /dev/null -O check*' => Process::result(errorOutput: 'Master running (pid=111)'), 'kill*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); $method->setAccessible(true); $method->invoke($job); Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222')); Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111')); Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333')); File::delete($liveSocket); }); it('kills a ControlPersist master whose control socket was removed', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); $muxDir = storage_path('app/ssh/mux'); File::ensureDirectoryExists($muxDir); $liveSocket = $muxDir.'/mux_live_'.uniqid(); $removedSocket = $muxDir.'/mux_removed_'.uniqid(); File::put($liveSocket, 'x'); // ControlPersist sets the title of a backgrounded master to `ssh: [mux]`. Process::fake([ 'ps*' => Process::result(output: "111 1 5000 ssh: {$liveSocket} [mux]\n". "222 1 5000 ssh: {$removedSocket} [mux]\n". "333 1 5000 ssh: /tmp/other/mux_removed [mux]\n"), '*ssh -F /dev/null -O check*' => Process::result(errorOutput: 'Master running (pid=111)'), 'kill*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); $method->setAccessible(true); $method->invoke($job); Process::assertRan(fn ($process) => $process->command === 'kill -9 '.escapeshellarg('222')); Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111')); Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333')); File::delete($liveSocket); }); it('kills ssh -fN processes that share a live ControlPath but do not own it', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); $muxDir = storage_path('app/ssh/mux'); File::ensureDirectoryExists($muxDir); $socket = $muxDir.'/mux_shared_'.uniqid(); File::put($socket, 'x'); Process::fake([ 'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$socket} root@1.2.3.4\n". "222 1 7000 ssh -fN -o ControlMaster=auto -o ControlPath={$socket} root@1.2.3.4\n". "333 1 9000 ssh -fN -o ControlMaster=auto -o ControlPath={$socket} root@1.2.3.4\n"), '*ssh -F /dev/null -O check*' => Process::result(errorOutput: 'Master running (pid=111)'), 'kill*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); $method->setAccessible(true); $method->invoke($job); Process::assertRan(fn ($process) => $process->command === 'kill -9 '.escapeshellarg('222')); Process::assertRan(fn ($process) => $process->command === 'kill -9 '.escapeshellarg('333')); Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111')); Process::assertRanTimes(fn ($process) => str_contains($process->command, 'ssh -F /dev/null -O check'), 1); File::delete($socket); }); it('does not kill ssh -fN processes when the socket owner is unknown', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); $muxDir = storage_path('app/ssh/mux'); File::ensureDirectoryExists($muxDir); $socket = $muxDir.'/mux_unknown_'.uniqid(); File::put($socket, 'x'); Process::fake([ 'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$socket} root@1.2.3.4\n"), '*ssh -F /dev/null -O check*' => Process::result(exitCode: 255, errorOutput: 'Control socket connect: Connection refused'), 'kill*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); $method->setAccessible(true); $method->invoke($job); Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill')); File::delete($socket); }); it('does not reap an ssh master that is intentionally retiring', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); $muxDir = storage_path('app/ssh/mux'); $retiringSocket = $muxDir.'/mux_retiring_'.uniqid(); SshMultiplexingHelper::markMuxProcessAsRetiring('222', $retiringSocket); Process::fake([ 'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$retiringSocket} root@1.2.3.4\n"), 'kill*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); $method->setAccessible(true); $method->invoke($job); Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill')); }); it('does not treat a reused pid as retiring', function () { $socket = storage_path('app/ssh/mux/mux_original'); SshMultiplexingHelper::markMuxProcessAsRetiring('222', $socket, '1000'); expect(SshMultiplexingHelper::isMuxProcessRetiring('222', $socket, '1000'))->toBeTrue() ->and(SshMultiplexingHelper::isMuxProcessRetiring('222', $socket, '2000'))->toBeFalse(); }); it('scopes retirement markers to the current host and pid namespace', function () { $method = new ReflectionMethod(SshMultiplexingHelper::class, 'processScope'); $method->setAccessible(true); expect($method->invoke(null)) ->toBeString() ->toStartWith((gethostname() ?: 'unknown').'|'); }); it('keeps a retirement marker for long-running ssh sessions', function () { $socket = storage_path('app/ssh/mux/mux_retired'); SshMultiplexingHelper::markMuxProcessAsRetiring('222', $socket); $this->travel((int) config('constants.ssh.mux_persist_time') * 2 + 1)->seconds(); expect(SshMultiplexingHelper::isMuxProcessRetiring('222', $socket))->toBeTrue(); }); it('reads the process start time used to distinguish pid reuse', function () { $method = new ReflectionMethod(SshMultiplexingHelper::class, 'processStartTime'); $method->setAccessible(true); expect($method->invoke(null, (string) getmypid()))->toMatch('/^\d+$/'); }); it('marks a successfully stopped mux process as retiring', function () { $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(output: 'Master running (pid=222)', exitCode: 0), '*-O stop*' => Process::result(exitCode: 0), ]); SshMultiplexingHelper::removeMuxFile($server); expect(SshMultiplexingHelper::isMuxProcessRetiring('222', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeTrue(); }); it('marks a mux process as retiring before stopping it', function () { $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(output: 'Master running (pid=555)', exitCode: 0), '*-O stop*' => function () use ($server) { expect(SshMultiplexingHelper::isMuxProcessRetiring('555', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeTrue(); return Process::result(exitCode: 0); }, ]); SshMultiplexingHelper::removeMuxFile($server); }); it('does not mark a mux process as retiring when stop fails', function () { $server = makeMuxServer(); Process::fake([ '*-O check*' => Process::result(output: 'Master running (pid=444)', exitCode: 0), '*-O stop*' => function () use ($server) { expect(SshMultiplexingHelper::isMuxProcessRetiring('444', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeTrue(); return Process::result(exitCode: 1); }, ]); SshMultiplexingHelper::removeMuxFile($server); expect(SshMultiplexingHelper::isMuxProcessRetiring('444', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeFalse(); }); it('removes the control socket when the mux master does not stop', function (bool $stopTimesOut) { $server = makeMuxServer(); File::partialMock()->shouldReceive('delete')->once()->with("/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"); Process::fake([ '*-O check*' => Process::result(output: 'Master running (pid=888)', exitCode: 0), '*-O stop*' => $stopTimesOut ? muxProcessTimeout() : Process::result(exitCode: 255), ]); SshMultiplexingHelper::removeMuxFile($server); expect(SshMultiplexingHelper::isMuxProcessRetiring('888', "/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}"))->toBeFalse(); })->with([ 'stop fails' => false, 'stop times out' => true, ]); it('keeps the control socket untouched when the mux master stops', function () { $server = makeMuxServer(); File::partialMock()->shouldNotReceive('delete'); Process::fake([ '*-O check*' => Process::result(output: 'Master running (pid=999)', exitCode: 0), '*-O stop*' => Process::result(exitCode: 0), ]); SshMultiplexingHelper::removeMuxFile($server); }); it('kills only old cloudflared proxies whose stdin pipe no live ssh holds', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); // This test process stands in for a live ssh master: it holds the write end // of the reparented proxy's stdin pipe. The orphan's writer is closed. $descriptors = [0 => ['pipe', 'r'], 1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']]; $reparented = proc_open(['sleep', '60'], $descriptors, $reparentedPipes); $orphan = proc_open(['sleep', '60'], $descriptors, $orphanPipes); $tunnelSsh = proc_open(['sleep', '60'], $descriptors, $tunnelSshPipes); fclose($orphanPipes[0]); fclose($tunnelSshPipes[0]); $reparentedPid = proc_get_status($reparented)['pid']; $orphanPid = proc_get_status($orphan)['pid']; $tunnelSshPid = proc_get_status($tunnelSsh)['pid']; $sshPid = getmypid(); $muxSocket = storage_path('app/ssh/mux/mux_tunnel'); Process::fake([ 'ps*' => Process::result(output: "{$sshPid} 1 5000 ssh: {$muxSocket} [mux]\n". "200 {$sshPid} 5000 cloudflared access ssh --hostname host.example.com\n". "{$reparentedPid} 1 5000 cloudflared access ssh --hostname host.example.com\n". "{$orphanPid} 1 5000 cloudflared access ssh --hostname host.example.com\n". "400 1 30 cloudflared access ssh --hostname host.example.com\n". "{$tunnelSshPid} 1 5000 ssh -fN -o ProxyCommand=cloudflared access ssh --hostname %h root@1.2.3.4\n"), 'kill*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupOrphanedCloudflaredProcesses'); $method->invoke($job); proc_terminate($reparented); proc_terminate($orphan); proc_terminate($tunnelSsh); proc_close($reparented); proc_close($orphan); proc_close($tunnelSsh); Process::assertRan(fn ($process) => $process->command === 'kill '.escapeshellarg((string) $orphanPid)); Process::assertRanTimes(fn ($process) => str_starts_with($process->command, 'kill'), 1); }); it('dry-run mode logs orphans but kills nothing when reaping is disabled', function () { config(['constants.ssh.mux_orphan_reap_enabled' => false]); $muxDir = storage_path('app/ssh/mux'); File::ensureDirectoryExists($muxDir); $orphanSocket = $muxDir.'/mux_orphan_'.uniqid(); Process::fake([ 'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4 "), 'kill*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); $method->setAccessible(true); $method->invoke($job); Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill')); }); it('removes mux files for non-existent servers when reaping is enabled', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); Storage::fake('ssh-mux'); $file = 'mux_ghost'.uniqid(); Storage::disk('ssh-mux')->put($file, 'x'); Process::fake([ '*-O check*' => Process::result(errorOutput: 'Master running (pid=333)', exitCode: 0), '*-O stop*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); $method->setAccessible(true); $method->invoke($job); expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse(); expect(SshMultiplexingHelper::isMuxProcessRetiring('333', "/var/www/html/storage/app/ssh/mux/{$file}"))->toBeTrue(); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -F /dev/null -O stop')); Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -F /dev/null -O exit')); }); it('marks a stale mux process as retiring before stopping it', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); Storage::fake('ssh-mux'); $file = 'mux_ghost'.uniqid(); Storage::disk('ssh-mux')->put($file, 'x'); Process::fake([ '*-O check*' => Process::result(output: 'Master running (pid=666)', exitCode: 0), '*-O stop*' => function () use ($file) { expect(SshMultiplexingHelper::isMuxProcessRetiring('666', "/var/www/html/storage/app/ssh/mux/{$file}"))->toBeTrue(); return Process::result(exitCode: 0); }, ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); $method->setAccessible(true); $method->invoke($job); }); it('removes a stale mux retirement marker when stopping fails', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); Storage::fake('ssh-mux'); $file = 'mux_ghost'.uniqid(); $muxSocket = "/var/www/html/storage/app/ssh/mux/{$file}"; Storage::disk('ssh-mux')->put($file, 'x'); Process::fake([ '*-O check*' => Process::result(output: 'Master running (pid=777)', exitCode: 0), '*-O stop*' => function () use ($muxSocket) { expect(SshMultiplexingHelper::isMuxProcessRetiring('777', $muxSocket))->toBeTrue(); return Process::result(exitCode: 1); }, ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); $method->setAccessible(true); $method->invoke($job); expect(SshMultiplexingHelper::isMuxProcessRetiring('777', $muxSocket))->toBeFalse(); }); it('removes a mux connection whose control check times out during cleanup', function () { config([ 'constants.ssh.mux_orphan_reap_enabled' => true, 'constants.ssh.mux_health_check_timeout' => 5, ]); Storage::fake('ssh-mux'); $server = makeMuxServer(); $file = "mux_{$server->uuid}"; Storage::disk('ssh-mux')->put($file, 'x'); $timeouts = []; Process::fake([ '*-O check*' => muxProcessTimeout($timeouts), '*-O stop*' => muxProcessTimeout($timeouts), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupStaleConnections'); $method->invoke($job); expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse() ->and($timeouts)->toBe([5, 5, 5]); Process::assertNotRan(fn ($process) => str_contains($process->command, $server->ip)); }); it('does not reap ssh processes when the socket owner check times out', function () { config([ 'constants.ssh.mux_orphan_reap_enabled' => true, 'constants.ssh.mux_health_check_timeout' => 5, ]); $muxDir = storage_path('app/ssh/mux'); File::ensureDirectoryExists($muxDir); $socket = $muxDir.'/mux_wedged_'.uniqid(); File::put($socket, 'x'); $timeouts = []; Process::fake([ 'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$socket} root@1.2.3.4\n"), '*ssh -F /dev/null -O check*' => muxProcessTimeout($timeouts), 'kill*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); $method->invoke($job); expect($timeouts)->toBe([5]); Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill')); File::delete($socket); }); it('does not remove a healthy mux connection based on its absolute age', function () { config(['constants.ssh.mux_orphan_reap_enabled' => true]); Storage::fake('ssh-mux'); $server = makeMuxServer(); $file = "mux_{$server->uuid}"; Storage::disk('ssh-mux')->put($file, str_repeat('x', 37).now()->subHours(2)->toIso8601String()); Process::fake([ '*-O check*' => Process::result(exitCode: 0), ]); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupStaleConnections'); $method->setAccessible(true); $method->invoke($job); expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue(); Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -F /dev/null -O stop')); }); it('keeps mux files for non-existent servers in dry-run mode', function () { config(['constants.ssh.mux_orphan_reap_enabled' => false]); Storage::fake('ssh-mux'); $file = 'mux_ghost'.uniqid(); Storage::disk('ssh-mux')->put($file, 'x'); Process::fake(); $job = new CleanupStaleMultiplexedConnections; $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); $method->setAccessible(true); $method->invoke($job); expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue(); Process::assertNothingRan(); });