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 = []; 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 * Packet Size
* *
@ -721,16 +728,6 @@ class SSH2
*/ */
private bool $request_pty = false; 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 * Contents of stdError
*/ */
@ -2431,7 +2428,7 @@ class SSH2
return false; 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.'); 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); $this->disconnect_helper(DisconnectReason::BY_APPLICATION);
throw new RuntimeException('Unable to request pseudo-terminal'); 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 // 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; $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; return true;
} }
@ -2540,15 +2536,24 @@ class SSH2
/** /**
* Creates an interactive shell * 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 UnexpectedValueException on receipt of unexpected packets
* @throws RuntimeException on other errors * @throws RuntimeException on other errors
*@see self::read() * @see self::isShellOpen()
* @see self::read()
* @see self::write() * @see self::write()
*/ */
private function initShell(): bool public function openShell(): bool
{ {
if ($this->in_request_pty_exec === true) { if ($this->isShellOpen()) {
return true; 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; $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_status[self::CHANNEL_SHELL] = MessageType::CHANNEL_DATA;
$this->channel_id_last_interactive = self::CHANNEL_SHELL;
$this->bitmap |= self::MASK_SHELL; $this->bitmap |= self::MASK_SHELL;
return true; 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::read()
* @see self::write() * @see self::write()
*/ */
private function get_interactive_channel(): int private function get_interactive_channel(): int
{ {
switch (true) { switch (true) {
case $this->in_subsystem: case $this->is_channel_status_data(self::CHANNEL_SUBSYSTEM):
return 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; return self::CHANNEL_EXEC;
default: default:
return self::CHANNEL_SHELL; 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 * Return an available open channel
* *
@ -2685,25 +2702,37 @@ class SSH2
* Returns when there's a match for $expect, which can take the form of a string literal or, * 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 $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 * @return string|bool|null
* @throws RuntimeException on connection error * @throws RuntimeException on connection error
* @throws InsufficientSetupException on unexpected channel status, possibly due to closure
* @see self::write() * @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->curTimeout = $this->timeout;
$this->is_timeout = false; $this->is_timeout = false;
if (!$this->isAuthenticated()) { if ($channel === null) {
throw new InsufficientSetupException('Operation disallowed prior to login()'); $channel = $this->get_interactive_channel();
} }
if (!($this->bitmap & self::MASK_SHELL) && !$this->initShell()) { if (!$this->isInteractiveChannelOpen($channel)) {
throw new RuntimeException('Unable to initiate an interactive shell session'); 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) { if ($mode == self::READ_NEXT) {
return $this->get_channel_packet($channel); return $this->get_channel_packet($channel);
} }
@ -2720,7 +2749,6 @@ class SSH2
} }
$response = $this->get_channel_packet($channel); $response = $this->get_channel_packet($channel);
if ($response === true) { if ($response === true) {
$this->in_request_pty_exec = false;
return Strings::shift($this->interactiveBuffer, strlen($this->interactiveBuffer)); return Strings::shift($this->interactiveBuffer, strlen($this->interactiveBuffer));
} }
@ -2731,20 +2759,33 @@ class SSH2
/** /**
* Inputs a command into an interactive shell. * 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 RuntimeException on connection error
* @throws InsufficientSetupException on unexpected channel status, possibly due to closure
* @see SSH2::read() * @see SSH2::read()
*/ */
public function write(string $cmd): void public function write(string $cmd, int $channel = null): void
{ {
if (!$this->isAuthenticated()) { if ($channel === null) {
throw new InsufficientSetupException('Operation disallowed prior to login()'); $channel = $this->get_interactive_channel();
} }
if (!($this->bitmap & self::MASK_SHELL) && !$this->initShell()) { if (!$this->isInteractiveChannelOpen($channel)) {
throw new RuntimeException('Unable to initiate an interactive shell session'); 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->channel_status[self::CHANNEL_SUBSYSTEM] = MessageType::CHANNEL_DATA;
$this->bitmap |= self::MASK_SHELL; $this->channel_id_last_interactive = self::CHANNEL_SUBSYSTEM;
$this->in_subsystem = true;
return true; return true;
} }
@ -2808,8 +2848,9 @@ class SSH2
*/ */
public function stopSubsystem(): bool public function stopSubsystem(): bool
{ {
$this->in_subsystem = false; if ($this->isInteractiveChannelOpen(self::CHANNEL_SUBSYSTEM)) {
$this->close_channel(self::CHANNEL_SUBSYSTEM); $this->close_channel(self::CHANNEL_SUBSYSTEM);
}
return true; return true;
} }
@ -2817,10 +2858,23 @@ class SSH2
* Closes a channel * 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 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); 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 * Pings a server connection, or tries to reconnect if the connection has gone down
* *
@ -3448,9 +3539,8 @@ class SSH2
*/ */
public function disablePTY(): void public function disablePTY(): void
{ {
if ($this->in_request_pty_exec) { if ($this->isPTYOpen()) {
$this->close_channel(self::CHANNEL_EXEC); $this->close_channel(self::CHANNEL_EXEC);
$this->in_request_pty_exec = false;
} }
$this->request_pty = false; $this->request_pty = false;
} }
@ -3475,6 +3565,7 @@ class SSH2
* - if the connection times out * - 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_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_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: * bool(false) is returned if:
* *
@ -3639,7 +3730,10 @@ class SSH2
throw new RuntimeException('Unable to fulfill channel request'); throw new RuntimeException('Unable to fulfill channel request');
} }
case MessageType::CHANNEL_CLOSE: 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: case MessageType::CHANNEL_CLOSE:
$this->curTimeout = 5; $this->curTimeout = 5;
if ($this->bitmap & self::MASK_SHELL) { $this->close_channel_bitmap($channel);
$this->bitmap &= ~self::MASK_SHELL;
}
if ($this->channel_status[$channel] != MessageType::CHANNEL_EOF) { if ($this->channel_status[$channel] != MessageType::CHANNEL_EOF) {
$this->send_binary_packet(pack('CN', MessageType::CHANNEL_CLOSE, $this->server_channels[$channel])); $this->send_binary_packet(pack('CN', MessageType::CHANNEL_CLOSE, $this->server_channels[$channel]));
} }
@ -4002,16 +4095,26 @@ class SSH2
while (!is_bool($this->get_channel_packet($client_channel))) { while (!is_bool($this->get_channel_packet($client_channel))) {
} }
if ($this->is_timeout) {
$this->disconnect();
}
if ($want_reply) { if ($want_reply) {
$this->send_binary_packet(pack('CN', MessageType::CHANNEL_CLOSE, $this->server_channels[$client_channel])); $this->send_binary_packet(pack('CN', MessageType::CHANNEL_CLOSE, $this->server_channels[$client_channel]));
} }
if ($this->bitmap & self::MASK_SHELL) { $this->close_channel_bitmap($client_channel);
$this->bitmap &= ~self::MASK_SHELL; }
/**
* 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 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 public function testConstructor(): SSH2
{ {
$ssh = new SSH2($this->getEnv('SSH_HOSTNAME')); $ssh = $this->getSSH2();
$this->assertIsObject( $this->assertIsObject(
$ssh, $ssh,
@ -44,6 +63,17 @@ class SSH2Test extends PhpseclibFunctionalTestCase
'Failed asserting that SSH2 is not authenticated after construction.' '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( $this->assertNotEmpty(
$ssh->getServerPublicHostKey(), $ssh->getServerPublicHostKey(),
'Failed asserting that a non-empty public host key was fetched.' '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.' '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; return $ssh;
} }
@ -104,6 +139,11 @@ class SSH2Test extends PhpseclibFunctionalTestCase
'Failed asserting that SSH2 is authenticated after good login attempt.' '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; return $ssh;
} }
@ -123,12 +163,28 @@ class SSH2Test extends PhpseclibFunctionalTestCase
->will($this->returnValue(true)); ->will($this->returnValue(true));
$ssh->exec('pwd', [$callbackObject, 'callbackMethod']); $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; return $ssh;
} }
public function testGetServerPublicHostKey(): void public function testGetServerPublicHostKey(): void
{ {
$ssh = new SSH2($this->getEnv('SSH_HOSTNAME')); $ssh = $this->getSSH2();
$this->assertIsString($ssh->getServerPublicHostKey()); $this->assertIsString($ssh->getServerPublicHostKey());
} }
@ -153,11 +209,65 @@ class SSH2Test extends PhpseclibFunctionalTestCase
public function testDisablePTY(SSH2 $ssh): SSH2 public function testDisablePTY(SSH2 $ssh): SSH2
{ {
$ssh->enablePTY(); $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'); $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(); $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'); $ssh->exec('pwd');
$this->assertTrue(true); $this->assertFalse(
$ssh->isPTYOpen(),
'Failed asserting that pty was not open after exec.'
);
return $ssh; return $ssh;
} }
@ -184,13 +294,248 @@ class SSH2Test extends PhpseclibFunctionalTestCase
$ssh->write("ping 127.0.0.1\n"); $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->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->write("ls -latr\n");
$ssh->setTimeout(1); $ssh->setTimeout(1);
$this->assertIsString($ssh->read()); $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; namespace phpseclib3\Tests\Unit\Net;
use phpseclib3\Exception\InsufficientSetupException;
use phpseclib3\Net\SSH2; use phpseclib3\Net\SSH2;
use phpseclib3\Tests\PhpseclibTestCase; use phpseclib3\Tests\PhpseclibTestCase;
@ -143,6 +144,71 @@ class SSH2UnitTest extends PhpseclibTestCase
$this->assertSame('{' . spl_object_hash($ssh) . '}', $ssh->getResourceId()); $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 public function testGetTimeout(): void
{ {
$ssh = new SSH2('localhost'); $ssh = new SSH2('localhost');