Merge pull request #1898 from rposky/master

SSH2: Better support for multiple interactive channels & expose shell functions: 3.0 Backport Master Merge
This commit is contained in:
terrafrost 2023-03-23 12:17:26 -05:00 committed by GitHub
commit 5a208267d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 572 additions and 58 deletions

View File

@ -549,6 +549,13 @@ class SSH2
*/
protected array $channel_status = [];
/**
* The identifier of the interactive channel which was opened most recently
*
* @see self::getInteractiveChannelId()
*/
private int $channel_id_last_interactive = 0;
/**
* Packet Size
*
@ -721,16 +728,6 @@ class SSH2
*/
private bool $request_pty = false;
/**
* Flag set while exec() is running when using enablePTY()
*/
private bool $in_request_pty_exec = false;
/**
* Flag set after startSubsystem() is called
*/
private bool $in_subsystem = false;
/**
* Contents of stdError
*/
@ -2431,7 +2428,7 @@ class SSH2
return false;
}
if ($this->in_request_pty_exec) {
if ($this->isPTYOpen()) {
throw new RuntimeException('If you want to run multiple exec()\'s you will need to disable (and re-enable if appropriate) a PTY for each one.');
}
@ -2481,8 +2478,6 @@ class SSH2
$this->disconnect_helper(DisconnectReason::BY_APPLICATION);
throw new RuntimeException('Unable to request pseudo-terminal');
}
$this->in_request_pty_exec = true;
}
// sending a pty-req SSH_MSG_CHANNEL_REQUEST message is unnecessary and, in fact, in most cases, slows things
@ -2512,7 +2507,8 @@ class SSH2
$this->channel_status[self::CHANNEL_EXEC] = MessageType::CHANNEL_DATA;
if ($this->in_request_pty_exec) {
if ($this->request_pty === true) {
$this->channel_id_last_interactive = self::CHANNEL_EXEC;
return true;
}
@ -2540,15 +2536,24 @@ class SSH2
/**
* Creates an interactive shell
*
* Returns bool(true) if the shell was opened.
* Returns bool(false) if the shell was already open.
*
* @throws InsufficientSetupException if not authenticated
* @throws UnexpectedValueException on receipt of unexpected packets
* @throws RuntimeException on other errors
*@see self::read()
* @see self::isShellOpen()
* @see self::read()
* @see self::write()
*/
private function initShell(): bool
public function openShell(): bool
{
if ($this->in_request_pty_exec === true) {
return true;
if ($this->isShellOpen()) {
return false;
}
if (!$this->isAuthenticated()) {
throw new InsufficientSetupException('Operation disallowed prior to login()');
}
$this->window_size_server_to_client[self::CHANNEL_SHELL] = $this->window_size;
@ -2608,29 +2613,41 @@ class SSH2
$this->channel_status[self::CHANNEL_SHELL] = MessageType::CHANNEL_DATA;
$this->channel_id_last_interactive = self::CHANNEL_SHELL;
$this->bitmap |= self::MASK_SHELL;
return true;
}
/**
* Return the channel to be used with read() / write()
*
* Return the channel to be used with read(), write(), and reset(), if none were specified
* @deprecated for lack of transparency in intended channel target, to be potentially replaced
* with method which guarantees open-ness of all yielded channels and throws
* error for multiple open channels
* @see self::read()
* @see self::write()
*/
private function get_interactive_channel(): int
{
switch (true) {
case $this->in_subsystem:
case $this->is_channel_status_data(self::CHANNEL_SUBSYSTEM):
return self::CHANNEL_SUBSYSTEM;
case $this->in_request_pty_exec:
case $this->is_channel_status_data(self::CHANNEL_EXEC):
return self::CHANNEL_EXEC;
default:
return self::CHANNEL_SHELL;
}
}
/**
* Indicates the DATA status on the given channel
*/
private function is_channel_status_data(int $channel): bool
{
return isset($this->channel_status[$channel]) && $this->channel_status[$channel] == MessageType::CHANNEL_DATA;
}
/**
* Return an available open channel
*
@ -2685,24 +2702,36 @@ class SSH2
* Returns when there's a match for $expect, which can take the form of a string literal or,
* if $mode == self::READ_REGEX, a regular expression.
*
* If not specifying a channel, an open interactive channel will be selected, or, if there are
* no open channels, an interactive shell will be created. If there are multiple open
* interactive channels, a legacy behavior will apply in which channel selection prioritizes
* an active subsystem, the exec pty, and, lastly, the shell. If using multiple interactive
* channels, callers are discouraged from relying on this legacy behavior and should specify
* the intended channel.
*
* @param int $mode One of the self::READ_* constants
* @param int|null $channel Channel id returned by self::getInteractiveChannelId()
* @return string|bool|null
* @throws RuntimeException on connection error
* @throws InsufficientSetupException on unexpected channel status, possibly due to closure
* @see self::write()
*/
public function read(string $expect = '', int $mode = self::READ_SIMPLE)
public function read(string $expect = '', int $mode = self::READ_SIMPLE, int $channel = null)
{
$this->curTimeout = $this->timeout;
$this->is_timeout = false;
if (!$this->isAuthenticated()) {
throw new InsufficientSetupException('Operation disallowed prior to login()');
if ($channel === null) {
$channel = $this->get_interactive_channel();
}
if (!($this->bitmap & self::MASK_SHELL) && !$this->initShell()) {
if (!$this->isInteractiveChannelOpen($channel)) {
if ($channel != self::CHANNEL_SHELL) {
throw new InsufficientSetupException('Data is not available on channel');
} elseif (!$this->openShell()) {
throw new RuntimeException('Unable to initiate an interactive shell session');
}
$channel = $this->get_interactive_channel();
}
if ($mode == self::READ_NEXT) {
return $this->get_channel_packet($channel);
@ -2720,7 +2749,6 @@ class SSH2
}
$response = $this->get_channel_packet($channel);
if ($response === true) {
$this->in_request_pty_exec = false;
return Strings::shift($this->interactiveBuffer, strlen($this->interactiveBuffer));
}
@ -2731,20 +2759,33 @@ class SSH2
/**
* Inputs a command into an interactive shell.
*
* If not specifying a channel, an open interactive channel will be selected, or, if there are
* no open channels, an interactive shell will be created. If there are multiple open
* interactive channels, a legacy behavior will apply in which channel selection prioritizes
* an active subsystem, the exec pty, and, lastly, the shell. If using multiple interactive
* channels, callers are discouraged from relying on this legacy behavior and should specify
* the intended channel.
*
* @param int|null $channel Channel id returned by self::getInteractiveChannelId()
* @throws RuntimeException on connection error
* @throws InsufficientSetupException on unexpected channel status, possibly due to closure
* @see SSH2::read()
*/
public function write(string $cmd): void
public function write(string $cmd, int $channel = null): void
{
if (!$this->isAuthenticated()) {
throw new InsufficientSetupException('Operation disallowed prior to login()');
if ($channel === null) {
$channel = $this->get_interactive_channel();
}
if (!($this->bitmap & self::MASK_SHELL) && !$this->initShell()) {
if (!$this->isInteractiveChannelOpen($channel)) {
if ($channel != self::CHANNEL_SHELL) {
throw new InsufficientSetupException('Data is not available on channel');
} elseif (!$this->openShell()) {
throw new RuntimeException('Unable to initiate an interactive shell session');
}
}
$this->send_channel_packet($this->get_interactive_channel(), $cmd);
$this->send_channel_packet($channel, $cmd);
}
/**
@ -2795,8 +2836,7 @@ class SSH2
$this->channel_status[self::CHANNEL_SUBSYSTEM] = MessageType::CHANNEL_DATA;
$this->bitmap |= self::MASK_SHELL;
$this->in_subsystem = true;
$this->channel_id_last_interactive = self::CHANNEL_SUBSYSTEM;
return true;
}
@ -2808,8 +2848,9 @@ class SSH2
*/
public function stopSubsystem(): bool
{
$this->in_subsystem = false;
if ($this->isInteractiveChannelOpen(self::CHANNEL_SUBSYSTEM)) {
$this->close_channel(self::CHANNEL_SUBSYSTEM);
}
return true;
}
@ -2817,10 +2858,23 @@ class SSH2
* Closes a channel
*
* If read() timed out you might want to just close the channel and have it auto-restart on the next read() call
*
* If not specifying a channel, an open interactive channel will be selected. If there are
* multiple open interactive channels, a legacy behavior will apply in which channel selection
* prioritizes an active subsystem, the exec pty, and, lastly, the shell. If using multiple
* interactive channels, callers are discouraged from relying on this legacy behavior and
* should specify the intended channel.
*
* @param int|null $channel Channel id returned by self::getInteractiveChannelId()
*/
public function reset(): void
public function reset(int $channel = null): void
{
$this->close_channel($this->get_interactive_channel());
if ($channel === null) {
$channel = $this->get_interactive_channel();
}
if ($this->isInteractiveChannelOpen($channel)) {
$this->close_channel($channel);
}
}
/**
@ -2872,6 +2926,43 @@ class SSH2
return (bool) ($this->bitmap & self::MASK_LOGIN);
}
/**
* Is the interactive shell active?
*/
public function isShellOpen(): bool
{
return $this->isInteractiveChannelOpen(self::CHANNEL_SHELL);
}
/**
* Is the exec pty active?
*/
public function isPTYOpen(): bool
{
return $this->isInteractiveChannelOpen(self::CHANNEL_EXEC);
}
/**
* Is the given interactive channel active?
*
* @param int $channel Channel id returned by self::getInteractiveChannelId()
*/
public function isInteractiveChannelOpen(int $channel): bool
{
return $this->isAuthenticated() && $this->is_channel_status_data($channel);
}
/**
* Returns a channel identifier, presently of the last interactive channel opened, regardless of current status.
* Returns 0 if no interactive channel has been opened.
*
* @see self::isInteractiveChannelOpen()
*/
public function getInteractiveChannelId(): int
{
return $this->channel_id_last_interactive;
}
/**
* Pings a server connection, or tries to reconnect if the connection has gone down
*
@ -3448,9 +3539,8 @@ class SSH2
*/
public function disablePTY(): void
{
if ($this->in_request_pty_exec) {
if ($this->isPTYOpen()) {
$this->close_channel(self::CHANNEL_EXEC);
$this->in_request_pty_exec = false;
}
$this->request_pty = false;
}
@ -3475,6 +3565,7 @@ class SSH2
* - if the connection times out
* - if the channel status is CHANNEL_OPEN and the response was CHANNEL_OPEN_CONFIRMATION
* - if the channel status is CHANNEL_REQUEST and the response was CHANNEL_SUCCESS
* - if the channel status is CHANNEL_CLOSE and the response was CHANNEL_CLOSE
*
* bool(false) is returned if:
*
@ -3639,7 +3730,10 @@ class SSH2
throw new RuntimeException('Unable to fulfill channel request');
}
case MessageType::CHANNEL_CLOSE:
return $type == MessageType::CHANNEL_CLOSE ? true : $this->get_channel_packet($client_channel, $skip_extended);
if ($client_channel == $channel && $type == MessageType::CHANNEL_CLOSE) {
return true;
}
return $this->get_channel_packet($client_channel, $skip_extended);
}
}
@ -3674,9 +3768,8 @@ class SSH2
case MessageType::CHANNEL_CLOSE:
$this->curTimeout = 5;
if ($this->bitmap & self::MASK_SHELL) {
$this->bitmap &= ~self::MASK_SHELL;
}
$this->close_channel_bitmap($channel);
if ($this->channel_status[$channel] != MessageType::CHANNEL_EOF) {
$this->send_binary_packet(pack('CN', MessageType::CHANNEL_CLOSE, $this->server_channels[$channel]));
}
@ -4002,17 +4095,27 @@ class SSH2
while (!is_bool($this->get_channel_packet($client_channel))) {
}
if ($this->is_timeout) {
$this->disconnect();
}
if ($want_reply) {
$this->send_binary_packet(pack('CN', MessageType::CHANNEL_CLOSE, $this->server_channels[$client_channel]));
}
$this->close_channel_bitmap($client_channel);
}
/**
* Maintains execution state bitmap in response to channel closure
*/
private function close_channel_bitmap(int $client_channel): void
{
switch ($client_channel) {
case self::CHANNEL_SHELL:
// Shell status has been maintained in the bitmap for backwards
// compatibility sake, but can be removed going forward
if ($this->bitmap & self::MASK_SHELL) {
$this->bitmap &= ~self::MASK_SHELL;
}
break;
}
}
/**

View File

@ -15,9 +15,28 @@ use phpseclib3\Tests\PhpseclibFunctionalTestCase;
class SSH2Test extends PhpseclibFunctionalTestCase
{
public function getSSH2(): SSH2
{
return new SSH2($this->getEnv('SSH_HOSTNAME'), 22);
}
public function getSSH2Login(): SSH2
{
$ssh = $this->getSSH2();
$username = $this->getEnv('SSH_USERNAME');
$password = $this->getEnv('SSH_PASSWORD');
$this->assertTrue(
$ssh->login($username, $password),
'SSH2 login using password failed.'
);
return $ssh;
}
public function testConstructor(): SSH2
{
$ssh = new SSH2($this->getEnv('SSH_HOSTNAME'));
$ssh = $this->getSSH2();
$this->assertIsObject(
$ssh,
@ -44,6 +63,17 @@ class SSH2Test extends PhpseclibFunctionalTestCase
'Failed asserting that SSH2 is not authenticated after construction.'
);
$this->assertFalse(
$ssh->isShellOpen(),
'Failed asserting that SSH2 does not have open shell after construction.'
);
$this->assertEquals(
0,
$ssh->getInteractiveChannelId(),
'Failed asserting that channel identifier 0 is returned.'
);
$this->assertNotEmpty(
$ssh->getServerPublicHostKey(),
'Failed asserting that a non-empty public host key was fetched.'
@ -84,6 +114,11 @@ class SSH2Test extends PhpseclibFunctionalTestCase
'Failed asserting that SSH2 is not authenticated after bad login attempt.'
);
$this->assertFalse(
$ssh->isShellOpen(),
'Failed asserting that SSH2 does not have open shell after bad login attempt.'
);
return $ssh;
}
@ -104,6 +139,11 @@ class SSH2Test extends PhpseclibFunctionalTestCase
'Failed asserting that SSH2 is authenticated after good login attempt.'
);
$this->assertFalse(
$ssh->isShellOpen(),
'Failed asserting that SSH2 does not have open shell after good login attempt.'
);
return $ssh;
}
@ -123,12 +163,28 @@ class SSH2Test extends PhpseclibFunctionalTestCase
->will($this->returnValue(true));
$ssh->exec('pwd', [$callbackObject, 'callbackMethod']);
$this->assertFalse(
$ssh->isPTYOpen(),
'Failed asserting that SSH2 does not have open exec channel after exec.'
);
$this->assertFalse(
$ssh->isShellOpen(),
'Failed asserting that SSH2 does not have open shell channel after exec.'
);
$this->assertEquals(
0,
$ssh->getInteractiveChannelId(),
'Failed asserting that channel identifier 0 is returned after exec.'
);
return $ssh;
}
public function testGetServerPublicHostKey(): void
{
$ssh = new SSH2($this->getEnv('SSH_HOSTNAME'));
$ssh = $this->getSSH2();
$this->assertIsString($ssh->getServerPublicHostKey());
}
@ -153,11 +209,65 @@ class SSH2Test extends PhpseclibFunctionalTestCase
public function testDisablePTY(SSH2 $ssh): SSH2
{
$ssh->enablePTY();
$this->assertTrue(
$ssh->isPTYEnabled(),
'Failed asserting that pty was enabled.'
);
$this->assertFalse(
$ssh->isPTYOpen(),
'Failed asserting that pty was not open after enable.'
);
$this->assertEquals(
0,
$ssh->getInteractiveChannelId(),
'Failed asserting that 0 channel identifier is returned prior to opening.'
);
$ssh->exec('ls -latr');
$this->assertTrue(
$ssh->isPTYOpen(),
'Failed asserting that pty was open.'
);
$this->assertFalse(
$ssh->isShellOpen(),
'Failed asserting that shell was not open after pty exec.'
);
$this->assertEquals(
SSH2::CHANNEL_EXEC,
$ssh->getInteractiveChannelId(),
'Failed asserting that exec channel identifier is returned after exec.'
);
$ssh->disablePTY();
$this->assertFalse(
$ssh->isPTYEnabled(),
'Failed asserting that pty was disabled.'
);
$this->assertFalse(
$ssh->isPTYOpen(),
'Failed asserting that pty was not open after disable.'
);
$this->assertEquals(
SSH2::CHANNEL_EXEC,
$ssh->getInteractiveChannelId(),
'Failed asserting that exec channel identifier is returned after pty exec close.'
);
$ssh->exec('pwd');
$this->assertTrue(true);
$this->assertFalse(
$ssh->isPTYOpen(),
'Failed asserting that pty was not open after exec.'
);
return $ssh;
}
@ -184,13 +294,248 @@ class SSH2Test extends PhpseclibFunctionalTestCase
$ssh->write("ping 127.0.0.1\n");
$this->assertTrue(
$ssh->isShellOpen(),
'Failed asserting that SSH2 has open shell after shell read/write.'
);
$this->assertFalse(
$ssh->isPTYOpen(),
'Failed asserting that pty was not open after shell read/write.'
);
$this->assertEquals(
SSH2::CHANNEL_SHELL,
$ssh->getInteractiveChannelId(),
'Failed asserting that shell channel identifier is returned after shell read/write.'
);
$ssh->enablePTY();
$ssh->exec('bash');
$this->assertTrue(
$ssh->exec('bash'),
'Failed asserting exec command succeeded.'
);
$this->assertTrue(
$ssh->isShellOpen(),
'Failed asserting that SSH2 has open shell after pty exec.'
);
$this->assertTrue(
$ssh->isPTYOpen(),
'Failed asserting that pty was not open after exec.'
);
$this->assertEquals(
SSH2::CHANNEL_EXEC,
$ssh->getInteractiveChannelId(),
'Failed asserting that exec channel identifier is returned after pty exec.'
);
$ssh->write("ls -latr\n");
$ssh->setTimeout(1);
$this->assertIsString($ssh->read());
$this->assertTrue(
$ssh->isTimeout(),
'Failed asserting that pty exec read timed out'
);
$this->assertTrue(
$ssh->isShellOpen(),
'Failed asserting that SSH2 shell remains open across pty exec read/write.'
);
$this->assertTrue(
$ssh->isPTYOpen(),
'Failed asserting that pty was open after read timeout.'
);
}
public function testOpenShell(): SSH2
{
$ssh = $this->getSSH2Login();
$this->assertTrue(
$ssh->openShell(),
'SSH2 shell initialization failed.'
);
$this->assertTrue(
$ssh->isShellOpen(),
'Failed asserting that SSH2 has open shell after init.'
);
$this->assertNotFalse(
$ssh->read(),
'Failed asserting that read succeeds.'
);
$ssh->write('hello');
$this->assertTrue(
$ssh->isShellOpen(),
'Failed asserting that SSH2 has open shell after read/write.'
);
$this->assertEquals(
SSH2::CHANNEL_SHELL,
$ssh->getInteractiveChannelId(),
'Failed asserting that shell channel identifier is returned after read/write.'
);
return $ssh;
}
/**
* @depends testOpenShell
*/
public function testResetOpenShell(SSH2 $ssh): void
{
$ssh->reset();
$this->assertFalse(
$ssh->isShellOpen(),
'Failed asserting that SSH2 has open shell after reset.'
);
$this->assertEquals(
SSH2::CHANNEL_SHELL,
$ssh->getInteractiveChannelId(),
'Failed asserting that shell channel identifier is returned after reset.'
);
}
public function testMultipleExecPty(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('If you want to run multiple exec()\'s you will need to disable (and re-enable if appropriate) a PTY for each one.');
$ssh = $this->getSSH2Login();
$ssh->enablePTY();
$ssh->exec('bash');
$ssh->exec('bash');
}
public function testMultipleInteractiveChannels(): void
{
$ssh = $this->getSSH2Login();
$this->assertTrue(
$ssh->openShell(),
'SSH2 shell initialization failed.'
);
$this->assertEquals(
SSH2::CHANNEL_SHELL,
$ssh->getInteractiveChannelId(),
'Failed asserting that shell channel identifier is returned after open shell.'
);
$ssh->setTimeout(1);
$this->assertIsString(
$ssh->read(),
'Failed asserting that read succeeds after shell init'
);
$directory = $ssh->exec('pwd');
$this->assertFalse(
$ssh->isTimeout(),
'failed'
);
$this->assertIsString(
$directory,
'Failed asserting that exec succeeds after shell read/write'
);
$ssh->write("pwd\n");
$this->assertStringContainsString(
trim($directory),
$ssh->read(),
'Failed asserting that current directory can be read from shell after exec'
);
$ssh->enablePTY();
$this->assertTrue(
$ssh->exec('bash'),
'Failed asserting that pty exec succeeds'
);
$this->assertTrue(
$ssh->isShellOpen(),
'Failed asserting that SSH2 has open shell after pty exec.'
);
$this->assertEquals(
SSH2::CHANNEL_EXEC,
$ssh->getInteractiveChannelId(),
'Failed asserting that exec channel identifier is returned after pty exec.'
);
$ssh->write("pwd\n", SSH2::CHANNEL_SHELL);
$this->assertStringContainsString(
trim($directory),
$ssh->read('', SSH2::READ_SIMPLE, SSH2::CHANNEL_SHELL),
'Failed asserting that current directory can be read from shell after pty exec'
);
$this->assertTrue(
$ssh->isPTYOpen(),
'Failed asserting that SSH2 has open pty exec after shell read/write.'
);
$ssh->write("pwd\n", SSH2::CHANNEL_EXEC);
$this->assertIsString(
$ssh->read('', SSH2::READ_SIMPLE, SSH2::CHANNEL_EXEC),
'Failed asserting that pty exec read succeeds'
);
$ssh->reset(SSH2::CHANNEL_EXEC);
$this->assertFalse(
$ssh->isPTYOpen(),
'Failed asserting that SSH2 has closed pty exec after reset.'
);
$ssh->disablePTY();
$this->assertTrue(
$ssh->isShellOpen(),
'Failed asserting that SSH2 has open shell after pty exec.'
);
$ssh->write("pwd\n", SSH2::CHANNEL_SHELL);
$this->assertStringContainsString(
trim($directory),
$ssh->read('', SSH2::READ_SIMPLE, SSH2::CHANNEL_SHELL),
'Failed asserting that current directory can be read from shell after pty exec'
);
$ssh->reset(SSH2::CHANNEL_SHELL);
$this->assertFalse(
$ssh->isShellOpen(),
'Failed asserting that SSH2 has closed shell after reset.'
);
$this->assertEquals(
SSH2::CHANNEL_EXEC,
$ssh->getInteractiveChannelId(),
'Failed asserting that exec channel identifier is maintained as last opened channel.'
);
}
}

View File

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace phpseclib3\Tests\Unit\Net;
use phpseclib3\Exception\InsufficientSetupException;
use phpseclib3\Net\SSH2;
use phpseclib3\Tests\PhpseclibTestCase;
@ -143,6 +144,71 @@ class SSH2UnitTest extends PhpseclibTestCase
$this->assertSame('{' . spl_object_hash($ssh) . '}', $ssh->getResourceId());
}
/**
* @requires PHPUnit < 10
*/
public function testReadUnauthenticated(): void
{
$this->expectException(InsufficientSetupException::class);
$this->expectExceptionMessage('Operation disallowed prior to login()');
$ssh = $this->createSSHMock();
$ssh->read();
}
/**
* @requires PHPUnit < 10
*/
public function testWriteUnauthenticated(): void
{
$this->expectException(InsufficientSetupException::class);
$this->expectExceptionMessage('Operation disallowed prior to login()');
$ssh = $this->createSSHMock();
$ssh->write('');
}
/**
* @requires PHPUnit < 10
*/
public function testWriteOpensShell(): void
{
$ssh = $this->getMockBuilder(SSH2::class)
->disableOriginalConstructor()
->setMethods(['__destruct', 'isAuthenticated', 'openShell', 'send_channel_packet'])
->getMock();
$ssh->expects($this->once())
->method('isAuthenticated')
->willReturn(true);
$ssh->expects($this->once())
->method('openShell')
->willReturn(true);
$ssh->expects($this->once())
->method('send_channel_packet')
->with(SSH2::CHANNEL_SHELL, 'hello');
$ssh->write('hello');
}
/**
* @requires PHPUnit < 10
*/
public function testOpenShellWhenOpen(): void
{
$ssh = $this->getMockBuilder(SSH2::class)
->disableOriginalConstructor()
->setMethods(['__destruct', 'isShellOpen'])
->getMock();
$ssh->expects($this->once())
->method('isShellOpen')
->willReturn(true);
$this->assertFalse($ssh->openShell());
}
public function testGetTimeout(): void
{
$ssh = new SSH2('localhost');