22

I want to write a program (in Python 3.x on Windows 7) that executes multiple commands on a remote shell via ssh. After looking at paramikos' exec_command() function, I realized it's not suitable for my use case (because the channel gets closed after the command is executed), as the commands depend on environment variables (set by prior commands) and can't be concatenated into one exec_command() call as they are to be executed at different times in the program.

Thus, I want to execute commands in the same channel. The next option I looked into was implementing an interactive shell using paramikos' invoke_shell() function:

ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(host, username=user, password=psw, port=22)

channel = ssh.invoke_shell()

out = channel.recv(9999)

channel.send('cd mivne_final\n')
channel.send('ls\n')

while not channel.recv_ready():
    time.sleep(3)

out = channel.recv(9999)
print(out.decode("ascii"))

channel.send('cd ..\n')
channel.send('cd or_fail\n')
channel.send('ls\n')

while not channel.recv_ready():
    time.sleep(3)

out = channel.recv(9999)
print(out.decode("ascii"))

channel.send('cd ..\n')
channel.send('cd simulator\n')
channel.send('ls\n')

while not channel.recv_ready():
    time.sleep(3)

out = channel.recv(9999)
print(out.decode("ascii"))

ssh.close() 

There are some problems with this code:

  1. The first print doesn't always print the ls output (sometimes it is only printed on the second print).
  2. The first cd and ls commands are always present in the output (I get them via the recv command, as part of the output), while all the following cd and ls commands are printed sometimes, and sometimes they aren't.
  3. The second and third cd and ls commands (when printed) always appear before the first ls output.

I'm confused with this "non-determinism" and would very much appreciate your help.

Guy Avraham
  • 3,482
  • 3
  • 38
  • 50
misha
  • 777
  • 1
  • 9
  • 21
  • 1
    you'll get more help if replace the tag with the fewest followers with a python tag, assuming that this is really python code. good luck. – shellter Mar 06 '16 at 01:38
  • Do you have to use `paramiko`? I found it much easier to work with `fabric`. You just set up `env` variables like `user`, `password` and `host_string` and then you can do various stuff like use: `get` to download files from remote host, `put` to send files and `run` to issue commands. You can chain commands like this for example: `run('cd .. && cd simulator && ls')`. – kchomski Mar 06 '16 at 10:51
  • @kchomski unfortunately fabric is not compatible with python 3.x so it's not an option. Anyway, from what i saw, Fabric is just a wrapper to paramiko and doesn't let me run 'non-chained' commands in the same channel. There is a lot of logic that i ultimately want to run between the shell commands. – misha Mar 06 '16 at 12:12
  • @misha: sorry, I overlooked that you're working with Python 3.x – kchomski Mar 06 '16 at 12:40
  • check out [netmiko](https://github.com/ktbyers/netmiko) It's specialized for network devices, but you can also use it with Linux. It works on Python 3 and is built on Paramiko, but handles a lot of the buffering for you – Ben May 03 '17 at 19:59
  • Be aware that using `invoke_shell` means the remote server probably will send character sequences that change the font color etc in a user's terminal, that cruft is a hassle to remove. – chrisinmtown Jul 14 '23 at 19:22

3 Answers3

33
import paramiko
import re


class ShellHandler:

    def __init__(self, host, user, psw):
        self.ssh = paramiko.SSHClient()
        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        self.ssh.connect(host, username=user, password=psw, port=22)

        channel = self.ssh.invoke_shell()
        self.stdin = channel.makefile('wb')
        self.stdout = channel.makefile('r')

    def __del__(self):
        self.ssh.close()

    def execute(self, cmd):
        """

        :param cmd: the command to be executed on the remote computer
        :examples:  execute('ls')
                    execute('finger')
                    execute('cd folder_name')
        """
        cmd = cmd.strip('\n')
        self.stdin.write(cmd + '\n')
        finish = 'end of stdOUT buffer. finished with exit status'
        echo_cmd = 'echo {} $?'.format(finish)
        self.stdin.write(echo_cmd + '\n')
        shin = self.stdin
        self.stdin.flush()

        shout = []
        sherr = []
        exit_status = 0
        for line in self.stdout:
            if str(line).startswith(cmd) or str(line).startswith(echo_cmd):
                # up for now filled with shell junk from stdin
                shout = []
            elif str(line).startswith(finish):
                # our finish command ends with the exit status
                exit_status = int(str(line).rsplit(maxsplit=1)[1])
                if exit_status:
                    # stderr is combined with stdout.
                    # thus, swap sherr with shout in a case of failure.
                    sherr = shout
                    shout = []
                break
            else:
                # get rid of 'coloring and formatting' special characters
                shout.append(re.compile(r'(\x9B|\x1B\[)[0-?]*[ -/]*[@-~]').sub('', line).
                             replace('\b', '').replace('\r', ''))

        # first and last lines of shout/sherr contain a prompt
        if shout and echo_cmd in shout[-1]:
            shout.pop()
        if shout and cmd in shout[0]:
            shout.pop(0)
        if sherr and echo_cmd in sherr[-1]:
            sherr.pop()
        if sherr and cmd in sherr[0]:
            sherr.pop(0)

        return shin, shout, sherr
misha
  • 777
  • 1
  • 9
  • 21
  • How can I send multiple commands to the execute()? I've tried to do a for loop : for command in commands: object.execute(command) for a list of commands but it only executes 2 commands then I have to re-start the shell. – magicsword Jun 15 '17 at 17:10
  • What if I my command produces both stdout and stderr, and I want them as separate files? – Yaroslav Bulatov Nov 12 '17 at 22:04
  • 1
    @YaroslavBulatov I didn't try it, but I think you could declare self.stderr = channel.makefile_stderr('r'), in a similar fashion to how stdin and stdout are declared (pay attention to the makefile_stderr method). Then, supposedly you could access stderr as the file-like object should be associated with the stderr of this channel. – misha Nov 12 '17 at 22:30
  • 1
    you can avoid must of the stdout cleanup by: - removing the command prompt by sending cmd "export PS1="\n"" - avoiding echoing stdin by sending cmd "stty -echo" – Strudle Jan 14 '19 at 14:24
  • 1
    Classy!, I added `self.stdin.write("sudo su " + '\n')` below `cmd.strip('\n')` to change user to root. Thanks – Anum Sheraz Dec 05 '19 at 21:45
  • @AnumSheraz i tried that, but the code never executes. checking the log file, it gets stuck on "DEBUG - [chan 0] Unhandled channel request "keepalive@openssh.com"" –  Apr 20 '22 at 15:08
  • @misha how can I make it change user to root. Thanks! –  Apr 20 '22 at 15:15
  • The big challenge seems to be detecting the end of the command output so you know when to stop reading from the router's stdout object. Otherwise the read loop just hangs. Sending two commands every time and using the second command as a sentinel is kind of a clever hack! – chrisinmtown Jul 13 '23 at 13:49
1

I needed this for a Cisco router, which is rather different from a Linux machine. Credit to @misha for identifying the big challenge here, which is detecting the end of the command output so you know when to stop reading from the router's stdout object. If you don't detect that, the read loop just hangs. Sending two commands every time and using the second command as a sentinel is kind of a clever hack, so I copied it! This uses a well-known error response from the IOS command prompt as a sentinel.

import logging
import paramiko
import socket

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)

# not provided here: inter_handler
# a custom authentication handler with proprietary deteails


def ssh_run_cmds(
        host: str,
        port: int,
        user: str,
        commands: list) -> None:
    """
    Connect to the router, authenticate by computing a challenge
    response, and run commands. A major challenge is detecting
    the end of the command output, to know when to stop reading
    from the router session. This code uses an ugly hack of
    sending an invalid command and checking for a well-known
    error message.
    """
    # Create a socket and connect it to port 22 on the remote host
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # the argument must be a tuple
    sock.connect((host, port))
    # Wrap the socket in a paramiko Transport object
    ts = paramiko.Transport(sock)
    # Tell Paramiko that the Transport is going to be used as a client
    ts.start_client(timeout=10)
    # Authenticate the specified user via the handler
    ts.auth_interactive(user, inter_handler)
    # Open a channel
    chan = ts.open_channel(kind='session', timeout=10)
    # Associate a pseudo tty
    chan.get_pty()
    # Request an interactive shell session
    chan.invoke_shell()
    # Create writer/reader file-like objects
    stdin = chan.makefile('wb')
    stdout = chan.makefile('r')
    # Use the output from this invalid command as a sentinel
    bogus_cmd = 'show bogus'
    for cmd in commands:
        # Send the command AND a bogus command to detect end of output
        cmds = f'{cmd}\n{bogus_cmd}\n'
        logger.debug('Send commands: %s', cmds)
        stdin.write(cmds)
        stdin.flush()

        # Read the response
        for line in stdout:
            line = line.strip()
            logger.debug('Output line: %s', line)
            # the response from the bogus command is the last line
            if line.startswith("% Invalid input detected at '^' marker."):
                break
        # for line
    # for cmd

    stdin.close()
    stdout.close()
    chan.close()
    ts.close()
    sock.close()
chrisinmtown
  • 3,571
  • 3
  • 34
  • 43
0

I tried the answer above, and it didn't work because ECHO command returned error in Python CLI, which I was using with SSH.

So I wrote another code that is applicable for Python CLI, assuming that the output is in one line.

And I also think something like f"print('{finish}')" can do same thing (terminator??) as ECHO in the answer above. But I didn't make use of it because my output always has to be in one line.

class MusicPlayer:
def __init__(self, host='', username='pi', password=''):
    self.ssh = paramiko.SSHClient()
    self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    self.ssh.connect(host, username=username, password=password)
    channel = self.ssh.invoke_shell()
    self.stdin = channel.makefile('wb')
    self.stdout = channel.makefile('r')
    self.in_history = []
    self.out_history = []
    self.init_vlc()
    self.print()
    # atexit.register(self.__del__)

def __del__(self):
    self.ssh.close()

def execute(self, cmd):
    self.in_history.append(cmd)
    self.stdin.write(cmd + '\n')

def print(self, lines=1):
    for line in self.stdout:
        lined = line.strip()
        print(lined)
        self.out_history.append(lined)
        if self.in_history[-1] in lined:
            next_one = self.stdout.__next__().strip()
            print(next_one)
            self.out_history.append(next_one)
            return next_one

def init_vlc(self):
    for command in ['python', 'import vlc', 'import time', 'media_player = vlc.MediaPlayer()']:
        self.execute(command)