Skip to content

Commit

Permalink
Merge pull request #1898 from rposky/master
Browse files Browse the repository at this point in the history
SSH2: Better support for multiple interactive channels & expose shell functions: 3.0 Backport Master Merge
  • Loading branch information
terrafrost committed Mar 23, 2023
2 parents 5761a0b + 53fe071 commit 5a20826
Show file tree
Hide file tree
Showing 3 changed files with 572 additions and 58 deletions.
211 changes: 157 additions & 54 deletions phpseclib/Net/SSH2.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -2431,7 +2428,7 @@ public function exec(string $command, callable $callback = null)
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.');
}

Expand Down Expand Up @@ -2481,8 +2478,6 @@ public function exec(string $command, callable $callback = null)
$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
Expand Down Expand Up @@ -2512,7 +2507,8 @@ public function exec(string $command, callable $callback = null)

$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;
}

Expand Down Expand Up @@ -2540,15 +2536,24 @@ public function exec(string $command, callable $callback = null)
/**
* 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;
Expand Down Expand Up @@ -2608,29 +2613,41 @@ private function initShell(): bool

$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
*
Expand Down Expand Up @@ -2685,25 +2702,37 @@ public function requestAgentForwarding(): bool
* 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()) {
throw new RuntimeException('Unable to initiate an interactive shell session');
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);
}
Expand All @@ -2720,7 +2749,6 @@ public function read(string $expect = '', int $mode = self::READ_SIMPLE)
}
$response = $this->get_channel_packet($channel);
if ($response === true) {
$this->in_request_pty_exec = false;
return Strings::shift($this->interactiveBuffer, strlen($this->interactiveBuffer));
}

Expand All @@ -2731,20 +2759,33 @@ public function read(string $expect = '', int $mode = self::READ_SIMPLE)
/**
* 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()) {
throw new RuntimeException('Unable to initiate an interactive shell session');
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);
}

/**
Expand Down Expand Up @@ -2795,8 +2836,7 @@ public function startSubsystem(string $subsystem): bool

$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;
}
Expand All @@ -2808,19 +2848,33 @@ public function startSubsystem(string $subsystem): bool
*/
public function stopSubsystem(): bool
{
$this->in_subsystem = false;
$this->close_channel(self::CHANNEL_SUBSYSTEM);
if ($this->isInteractiveChannelOpen(self::CHANNEL_SUBSYSTEM)) {
$this->close_channel(self::CHANNEL_SUBSYSTEM);
}
return true;
}

/**
* 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);
}
}

/**
Expand Down Expand Up @@ -2872,6 +2926,43 @@ public function isAuthenticated(): bool
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
*
Expand Down Expand Up @@ -3448,9 +3539,8 @@ public function enablePTY(): void
*/
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;
}
Expand All @@ -3475,6 +3565,7 @@ public function isPTYEnabled(): bool
* - 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:
*
Expand Down Expand Up @@ -3639,7 +3730,10 @@ protected function get_channel_packet(int $client_channel, bool $skip_extended =
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);
}
}

Expand Down Expand Up @@ -3674,9 +3768,8 @@ protected function get_channel_packet(int $client_channel, bool $skip_extended =
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]));
}
Expand Down Expand Up @@ -4002,16 +4095,26 @@ private function close_channel(int $client_channel, bool $want_reply = false): v
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]));
}

if ($this->bitmap & self::MASK_SHELL) {
$this->bitmap &= ~self::MASK_SHELL;
$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;
}
}

Expand Down

0 comments on commit 5a20826

Please sign in to comment.