Exposed publically open shell method as well as methods to query interactive channel open statuses.

Removed in_request_pty_exec and in_subsystem flags, and removed uses of MASK_SHELL in bitmap, replacing with open channel status queries.
Adding channel argument to read, write, and reset allowing callers to select among multiple open interactive channels.
Adding interactive channel identifier interface as sanctioned path for users to obtain channels ids instead of using channel constants.
Deprecating get_interactive_channel helper and documenting its "legacy" behavior in read, write, and reset doc blocks.
Removing disconnect on timeout in channel close for lack of clarity around timeout origin.
Check for open channel prior to closing in stopSubsystem and reset.
This commit is contained in:
Robert 2023-02-23 14:04:30 -06:00
parent abbc1ab7c7
commit 7ec36fb5d5
3 changed files with 590 additions and 62 deletions

View File

@ -646,6 +646,14 @@ class SSH2
*/ */
protected $channel_status = []; protected $channel_status = [];
/**
* The identifier of the interactive channel which was opened most recently
*
* @see self::getInteractiveChannelId()
* @var int
*/
private $channel_id_last_interactive = 0;
/** /**
* Packet Size * Packet Size
* *
@ -837,20 +845,6 @@ class SSH2
*/ */
private $request_pty = false; private $request_pty = false;
/**
* Flag set while exec() is running when using enablePTY()
*
* @var bool
*/
private $in_request_pty_exec = false;
/**
* Flag set after startSubsystem() is called
*
* @var bool
*/
private $in_subsystem;
/** /**
* Contents of stdError * Contents of stdError
* *
@ -2729,7 +2723,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.');
} }
@ -2779,8 +2773,6 @@ class SSH2
$this->disconnect_helper(NET_SSH2_DISCONNECT_BY_APPLICATION); $this->disconnect_helper(NET_SSH2_DISCONNECT_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
@ -2810,7 +2802,8 @@ class SSH2
$this->channel_status[self::CHANNEL_EXEC] = NET_SSH2_MSG_CHANNEL_DATA; $this->channel_status[self::CHANNEL_EXEC] = NET_SSH2_MSG_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;
} }
@ -2838,16 +2831,25 @@ 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.
*
* @see self::isShellOpen()
* @see self::read() * @see self::read()
* @see self::write() * @see self::write()
* @return bool * @return bool
* @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
*/ */
private function initShell() public function openShell()
{ {
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;
@ -2907,14 +2909,18 @@ class SSH2
$this->channel_status[self::CHANNEL_SHELL] = NET_SSH2_MSG_CHANNEL_DATA; $this->channel_status[self::CHANNEL_SHELL] = NET_SSH2_MSG_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()
* @return int * @return int
@ -2922,15 +2928,26 @@ class SSH2
private function get_interactive_channel() private function get_interactive_channel()
{ {
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
*
* @param int $channel The channel number to evaluate
* @return bool
*/
private function is_channel_status_data($channel)
{
return isset($this->channel_status[$channel]) && $this->channel_status[$channel] == NET_SSH2_MSG_CHANNEL_DATA;
}
/** /**
* Return an available open channel * Return an available open channel
* *
@ -2987,27 +3004,38 @@ 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.
*
* @see self::write() * @see self::write()
* @param string $expect * @param string $expect
* @param int $mode * @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
*/ */
public function read($expect = '', $mode = self::READ_SIMPLE) public function read($expect = '', $mode = self::READ_SIMPLE, $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);
} }
@ -3024,7 +3052,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));
} }
@ -3035,22 +3062,35 @@ 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.
*
* @see SSH2::read() * @see SSH2::read()
* @param string $cmd * @param string $cmd
* @param int|null $channel Channel id returned by self::getInteractiveChannelId()
* @return void * @return void
* @throws \RuntimeException on connection error * @throws \RuntimeException on connection error
* @throws InsufficientSetupException on unexpected channel status, possibly due to closure
*/ */
public function write($cmd) public function write($cmd, $channel = null)
{ {
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);
} }
/** /**
@ -3103,8 +3143,7 @@ class SSH2
$this->channel_status[self::CHANNEL_SUBSYSTEM] = NET_SSH2_MSG_CHANNEL_DATA; $this->channel_status[self::CHANNEL_SUBSYSTEM] = NET_SSH2_MSG_CHANNEL_DATA;
$this->bitmap |= self::MASK_SHELL; $this->channel_id_last_interactive = self::CHANNEL_SUBSYSTEM;
$this->in_subsystem = true;
return true; return true;
} }
@ -3117,8 +3156,9 @@ class SSH2
*/ */
public function stopSubsystem() public function stopSubsystem()
{ {
$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;
} }
@ -3127,10 +3167,23 @@ class SSH2
* *
* 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()
* @return void
*/ */
public function reset() public function reset($channel = null)
{ {
$this->close_channel($this->get_interactive_channel()); if ($channel === null) {
$channel = $this->get_interactive_channel();
}
if ($this->isInteractiveChannelOpen($channel)) {
$this->close_channel($channel);
}
} }
/** /**
@ -3189,6 +3242,49 @@ class SSH2
return (bool) ($this->bitmap & self::MASK_LOGIN); return (bool) ($this->bitmap & self::MASK_LOGIN);
} }
/**
* Is the interactive shell active?
*
* @return bool
*/
public function isShellOpen()
{
return $this->isInteractiveChannelOpen(self::CHANNEL_SHELL);
}
/**
* Is the exec pty active?
*
* @return bool
*/
public function isPTYOpen()
{
return $this->isInteractiveChannelOpen(self::CHANNEL_EXEC);
}
/**
* Is the given interactive channel active?
*
* @param int $channel Channel id returned by self::getInteractiveChannelId()
* @return bool
*/
public function isInteractiveChannelOpen($channel)
{
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()
* @return int
*/
public function getInteractiveChannelId()
{
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
* *
@ -3773,9 +3869,8 @@ class SSH2
*/ */
public function disablePTY() public function disablePTY()
{ {
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;
} }
@ -3801,6 +3896,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:
* *
@ -3968,7 +4064,10 @@ class SSH2
throw new \RuntimeException('Unable to fulfill channel request'); throw new \RuntimeException('Unable to fulfill channel request');
} }
case NET_SSH2_MSG_CHANNEL_CLOSE: case NET_SSH2_MSG_CHANNEL_CLOSE:
return $type == NET_SSH2_MSG_CHANNEL_CLOSE ? true : $this->get_channel_packet($client_channel, $skip_extended); if ($client_channel == $channel && $type == NET_SSH2_MSG_CHANNEL_CLOSE) {
return true;
}
return $this->get_channel_packet($client_channel, $skip_extended);
} }
} }
@ -4003,9 +4102,8 @@ class SSH2
case NET_SSH2_MSG_CHANNEL_CLOSE: case NET_SSH2_MSG_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] != NET_SSH2_MSG_CHANNEL_EOF) { if ($this->channel_status[$channel] != NET_SSH2_MSG_CHANNEL_EOF) {
$this->send_binary_packet(pack('CN', NET_SSH2_MSG_CHANNEL_CLOSE, $this->server_channels[$channel])); $this->send_binary_packet(pack('CN', NET_SSH2_MSG_CHANNEL_CLOSE, $this->server_channels[$channel]));
} }
@ -4348,16 +4446,29 @@ 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', NET_SSH2_MSG_CHANNEL_CLOSE, $this->server_channels[$client_channel])); $this->send_binary_packet(pack('CN', NET_SSH2_MSG_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
*
* @param int $client_channel The channel number to maintain closure status of
* @return void
*/
private function close_channel_bitmap($client_channel)
{
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

@ -13,9 +13,34 @@ use phpseclib3\Tests\PhpseclibFunctionalTestCase;
class SSH2Test extends PhpseclibFunctionalTestCase class SSH2Test extends PhpseclibFunctionalTestCase
{ {
/**
* @return SSH2
*/
public function getSSH2()
{
return new SSH2($this->getEnv('SSH_HOSTNAME'), 22);
}
/**
* @return SSH2
*/
public function getSSH2Login()
{
$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() public function testConstructor()
{ {
$ssh = new SSH2($this->getEnv('SSH_HOSTNAME')); $ssh = $this->getSSH2();
$this->assertIsObject( $this->assertIsObject(
$ssh, $ssh,
@ -42,6 +67,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.'
@ -82,6 +118,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;
} }
@ -102,6 +143,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;
} }
@ -121,12 +167,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() public function testGetServerPublicHostKey()
{ {
$ssh = new SSH2($this->getEnv('SSH_HOSTNAME')); $ssh = $this->getSSH2();
$this->assertIsString($ssh->getServerPublicHostKey()); $this->assertIsString($ssh->getServerPublicHostKey());
} }
@ -151,11 +213,65 @@ class SSH2Test extends PhpseclibFunctionalTestCase
public function testDisablePTY(SSH2 $ssh) public function testDisablePTY(SSH2 $ssh)
{ {
$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;
} }
@ -182,13 +298,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()
{
$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)
{
$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()
{
$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()
{
$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

@ -8,6 +8,7 @@
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;
@ -146,6 +147,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()
{
$this->expectException(InsufficientSetupException::class);
$this->expectExceptionMessage('Operation disallowed prior to login()');
$ssh = $this->createSSHMock();
$ssh->read();
}
/**
* @requires PHPUnit < 10
*/
public function testWriteUnauthenticated()
{
$this->expectException(InsufficientSetupException::class);
$this->expectExceptionMessage('Operation disallowed prior to login()');
$ssh = $this->createSSHMock();
$ssh->write('');
}
/**
* @requires PHPUnit < 10
*/
public function testWriteOpensShell()
{
$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()
{
$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() public function testGetTimeout()
{ {
$ssh = new SSH2('localhost'); $ssh = new SSH2('localhost');