Remote command execution in python using paramiko that supports arbitrary input

I recently decided to use paramiko to develop a remote command execution tool. It was very easy to setup initially and ran much faster than my existing pexpect implementation but it had a problem with sudo commands because they required the password to be provided as input. I solved the problem by using a pseudo-terminal and by creating my own ChannelFile objects for stdin and stdout/stderr. The solution should be general enough to handle any case that requires simple input but it is not as flexible as pexpect. I hope that you find it useful.

Current Version

This is similar to the older version below but it handles more than 64KiB by eliminating the stdout buffer, the stdin buffer and using recv polling. It even does a silly check for input. That check could be expanded to make it behave more like pexpect but that is for another day.
#!/usr/bin/env python
'''
This class allows you to run commands on a remote host and provide
input if necessary.

VERSION 1.2
'''
import paramiko
import logging
import socket
import time
import datetime


# ================================================================
# class MySSH
# ================================================================
class MySSH:
    '''
    Create an SSH connection to a server and execute commands.
    Here is a typical usage:

        ssh = MySSH()
        ssh.connect('host', 'user', 'password', port=22)
        if ssh.connected() is False:
            sys.exit('Connection failed')

        # Run a command that does not require input.
        status, output = ssh.run('uname -a')
        print 'status = %d' % (status)
        print 'output (%d):' % (len(output))
        print '%s' % (output)

        # Run a command that does requires input.
        status, output = ssh.run('sudo uname -a', 'sudo-password')
        print 'status = %d' % (status)
        print 'output (%d):' % (len(output))
        print '%s' % (output)
    '''
    def __init__(self, compress=True, verbose=False):
        '''
        Setup the initial verbosity level and the logger.

        @param compress  Enable/disable compression.
        @param verbose   Enable/disable verbose messages.
        '''
        self.ssh = None
        self.transport = None
        self.compress = compress
        self.bufsize = 65536

        # Setup the logger
        self.logger = logging.getLogger('MySSH')
        self.set_verbosity(verbose)

        fmt = '%(asctime)s MySSH:%(funcName)s:%(lineno)d %(message)s'
        format = logging.Formatter(fmt)
        handler = logging.StreamHandler()
        handler.setFormatter(format)
        self.logger.addHandler(handler)
        self.info = self.logger.info

    def __del__(self):
        if self.transport is not None:
            self.transport.close()
            self.transport = None

    def connect(self, hostname, username, password, port=22):
        '''
        Connect to the host.

        @param hostname  The hostname.
        @param username  The username.
        @param password  The password.
        @param port      The port (default=22).

        @returns True if the connection succeeded or false otherwise.
        '''
        self.info('connecting %s@%s:%d' % (username, hostname, port))
        self.hostname = hostname
        self.username = username
        self.port = port
        self.ssh = paramiko.SSHClient()
        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        try:
            self.ssh.connect(hostname=hostname,
                             port=port,
                             username=username,
                             password=password)
            self.transport = self.ssh.get_transport()
            self.transport.use_compression(self.compress)
            self.info('succeeded: %s@%s:%d' % (username,
                                               hostname,
                                               port))
        except socket.error as e:
            self.transport = None
            self.info('failed: %s@%s:%d: %s' % (username,
                                                hostname,
                                                port,
                                                str(e)))
        except paramiko.BadAuthenticationType as e:
            self.transport = None
            self.info('failed: %s@%s:%d: %s' % (username,
                                                hostname,
                                                port,
                                                str(e)))

        return self.transport is not None

    def run(self, cmd, input_data=None, timeout=10):
        '''
        Run a command with optional input data.

        Here is an example that shows how to run commands with no input:

            ssh = MySSH()
            ssh.connect('host', 'user', 'password')
            status, output = ssh.run('uname -a')
            status, output = ssh.run('uptime')

        Here is an example that shows how to run commands that require input:

            ssh = MySSH()
            ssh.connect('host', 'user', 'password')
            status, output = ssh.run('sudo uname -a', '<sudo-password>')

        @param cmd         The command to run.
        @param input_data  The input data (default is None).
        @param timeout     The timeout in seconds (default is 10 seconds).
        @returns The status and the output (stdout and stderr combined).
        '''
        self.info('running command: (%d) %s' % (timeout, cmd))

        if self.transport is None:
            self.info('no connection to %s@%s:%s' % (str(self.username),
                                                     str(self.hostname),
                                                     str(self.port)))
            return -1, 'ERROR: connection not established\n'

        # Fix the input data.
        input_data = self._run_fix_input_data(input_data)

        # Initialize the session.
        self.info('initializing the session')
        session = self.transport.open_session()
        session.set_combine_stderr(True)
        session.get_pty()
        session.exec_command(cmd)
        output = self._run_poll(session, timeout, input_data)
        status = session.recv_exit_status()
        self.info('output size %d' % (len(output)))
        self.info('status %d' % (status))
        return status, output

    def connected(self):
        '''
        Am I connected to a host?

        @returns True if connected or false otherwise.
        '''
        return self.transport is not None

    def set_verbosity(self, verbose):
        '''
        Turn verbose messages on or off.

        @param verbose  Enable/disable verbose messages.
        '''
        if verbose > 0:
            self.logger.setLevel(logging.INFO)
        else:
            self.logger.setLevel(logging.ERROR)

    def _run_fix_input_data(self, input_data):
        '''
        Fix the input data supplied by the user for a command.

        @param input_data  The input data (default is None).
        @returns the fixed input data.
        '''
        if input_data is not None:
            if len(input_data) > 0:
                if '\\n' in input_data:
                    # Convert \n in the input into new lines.
                    lines = input_data.split('\\n')
                    input_data = '\n'.join(lines)
            return input_data.split('\n')
        return []

    def _run_send_input(self, session, stdin, input_data):
        '''
        Send the input data.

        @param session     The session.
        @param stdin       The stdin stream for the session.
        @param input_data  The input data (default is None).
        '''
        if input_data is not None:
            self.info('session.exit_status_ready() %s' % str(session.exit_status_ready()))
            self.info('stdin.channel.closed %s' % str(stdin.channel.closed))
            if stdin.channel.closed is False:
                self.info('sending input data')
                stdin.write(input_data)

    def _run_poll(self, session, timeout, input_data):
        '''
        Poll until the command completes.

        @param session     The session.
        @param timeout     The timeout in seconds.
        @param input_data  The input data.
        @returns the output
        '''
        interval = 0.1
        maxseconds = timeout
        maxcount = maxseconds / interval

        # Poll until completion or timeout
        # Note that we cannot directly use the stdout file descriptor
        # because it stalls at 64K bytes (65536).
        input_idx = 0
        timeout_flag = False
        self.info('polling (%d, %d)' % (maxseconds, maxcount))
        start = datetime.datetime.now()
        start_secs = time.mktime(start.timetuple())
        output = ''
        session.setblocking(0)
        while True:
            if session.recv_ready():
                data = session.recv(self.bufsize)
                output += data
                self.info('read %d bytes, total %d' % (len(data), len(output)))

                if session.send_ready():
                    # We received a potential prompt.
                    # In the future this could be made to work more like
                    # pexpect with pattern matching.
                    if input_idx < len(input_data):
                        data = input_data[input_idx] + '\n'
                        input_idx += 1
                        self.info('sending input data %d' % (len(data)))
                        session.send(data)

            self.info('session.exit_status_ready() = %s' % (str(session.exit_status_ready())))
            if session.exit_status_ready():
                break

            # Timeout check
            now = datetime.datetime.now()
            now_secs = time.mktime(now.timetuple()) 
            et_secs = now_secs - start_secs
            self.info('timeout check %d %d' % (et_secs, maxseconds))
            if et_secs > maxseconds:
                self.info('polling finished - timeout')
                timeout_flag = True
                break
            time.sleep(0.200)

        self.info('polling loop ended')
        if session.recv_ready():
            data = session.recv(self.bufsize)
            output += data
            self.info('read %d bytes, total %d' % (len(data), len(output)))

        self.info('polling finished - %d output bytes' % (len(output)))
        if timeout_flag:
            self.info('appending timeout message')
            output += '\nERROR: timeout after %d seconds\n' % (timeout)
            session.close()

        return output


# ================================================================
# MAIN
# ================================================================
if __name__ == '__main__':
    import sys

    # Access variables.
    hostname = 'hostname'
    port = 22
    username = 'username'
    password = 'password'
    sudo_password = password  # assume that it is the same password

    # Create the SSH connection
    ssh = MySSH()
    ssh.set_verbosity(False)
    ssh.connect(hostname=hostname,
                username=username,
                password=password,
                port=port)
    if ssh.connected() is False:
        print 'ERROR: connection failed.'
        sys.exit(1)

    def run_cmd(cmd, indata=None):
        '''
        Run a command with optional input.

        @param cmd    The command to execute.
        @param indata The input data.
        @returns The command exit status and output.
                 Stdout and stderr are combined.
        '''
        print
        print '=' * 64
        print 'command: %s' % (cmd)
        status, output = ssh.run(cmd, indata)
        print 'status : %d' % (status)
        print 'output : %d bytes' % (len(output))
        print '=' * 64
        print '%s' % (output)

    run_cmd('uname -a')
    run_cmd('sudo ls -ltrh /var/log | tail', sudo_password)  # sudo command

Older Version

This version will not handle more than 64KiB of output. I am not sure why.
#!/usr/bin/env python
'''
This class allows you to run commands on a remote host and provide
input if necessary.
'''
import paramiko
import logging
import socket
import time


# ================================================================
# class MySSH
# ================================================================
class MySSH:
    '''
    Create an SSH connection to a server and execute commands.
    Here is a typical usage:

        ssh = MySSH()
        ssh.connect('host', 'user', 'password', port=22)
        if ssh.connected() is False:
            sys.exit('Connection failed')

        # Run a command that does not require input.
        status, output = ssh.run('uname -a')
        print 'status = %d' % (status)
        print 'output (%d):' % (len(output))
        print '%s' % (output)

        # Run a command that does requires input.
        status, output = ssh.run('sudo uname -a', 'sudo-password')
        print 'status = %d' % (status)
        print 'output (%d):' % (len(output))
        print '%s' % (output)
    '''
    def __init__(self, compress=True, verbose=False):
        '''
        Setup the initial verbosity level and the logger.

        @param compress  Enable/disable compression.
        @param verbose   Enable/disable verbose messages.
        '''
        self.ssh = None
        self.transport = None
        self.compress = compress

        # Setup the logger
        self.logger = logging.getLogger('MySSH')
        self.set_verbosity(verbose)

        fmt = '%(asctime)s MySSH:%(funcName)s:%(lineno)d %(message)s'
        format = logging.Formatter(fmt)
        handler = logging.StreamHandler()
        handler.setFormatter(format)
        self.logger.addHandler(handler)
        self.info = self.logger.info

    def __del__(self):
        if self.transport is not None:
            self.transport.close()
            self.transport = None

    def connect(self, hostname, username, password, port=22):
        '''
        Connect to the host.

        @param hostname  The hostname.
        @param username  The username.
        @param password  The password.
        @param port      The port (default=22).

        @returns True if the connection succeeded or false otherwise.
        '''
        self.info('connecting %s@%s:%d' % (username, hostname, port))
        self.hostname = hostname
        self.username = username
        self.port = port
        self.ssh = paramiko.SSHClient()
        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        try:
            self.ssh.connect(hostname=hostname,
                             port=port,
                             username=username,
                             password=password)
            self.transport = self.ssh.get_transport()
            self.transport.use_compression(self.compress)
            self.info('succeeded: %s@%s:%d' % (username,
                                               hostname,
                                               port))
        except socket.error:
            self.transport = None
            self.info('failed: %s@%s:%d: %s' % (username,
                                                hostname,
                                                port,
                                                str(e)))
        except paramiko.BadAuthenticationType as e:
            self.transport = None
            self.info('failed: %s@%s:%d: %s' % (username,
                                                hostname,
                                                port,
                                                str(e)))

        return self.transport is not None

    def run(self, cmd, input_data=None, timeout=10):
        '''
        Run a command with optional input data.

        Here is an example that shows how to run commands with no input:

            ssh = MySSH()
            ssh.connect('host', 'user', 'password')
            status, output = ssh.run('uname -a')
            status, output = ssh.run('uptime')

        Here is an example that shows how to run commands that require input:

            ssh = MySSH()
            ssh.connect('host', 'user', 'password')
            status, output = ssh.run('sudo uname -a', '<sudo-password>')

        @param cmd         The command to run.
        @param input_data  The input data (default is None).
        @param timeout     The timeout in seconds (default is 10 seconds).
        @returns The status and the output (stdout and stderr combined).
        '''
        self.info('running command: (%d) %s' % (timeout, cmd))

        if self.transport is None:
            self.info('no connection to %s@%s:%s' % (str(self.username),
                                                     str(self.hostname),
                                                     str(self.port)))
            return -1, 'ERROR: connection not established\n'

        # Fix the input data.
        input_data = self._run_fix_input_data(input_data)

        # Initialize the session.
        self.info('initializing the session')
        session = self.transport.open_session()
        session.set_combine_stderr(True)
        session.get_pty()
        session.exec_command(cmd)
        stdin = session.makefile('wb', -1)
        stdout = session.makefile('rb', -1)

        self._run_send_input(stdout, stdin, input_data)
        output = self._run_poll(stdout, timeout, input_data)
        status = stdout.channel.recv_exit_status()
        self.info('output size %d' % (len(output)))
        self.info('status %d' % (status))
        return status, output

    def connected(self):
        '''
        Am I connected to a host?

        @returns True if connected or false otherwise.
        '''
        return self.transport is not None

    def set_verbosity(self, verbose):
        '''
        Turn verbose messages on or off.

        @param verbose  Enable/disable verbose messages.
        '''
        if verbose is True:
            self.logger.setLevel(logging.INFO)
        else:
            self.logger.setLevel(logging.ERROR)

    def _run_fix_input_data(self, input_data):
        '''
        Fix the input data supplied by the user for a command.

        @param input_data  The input data (default is None).
        @returns the fixed input data.
        '''
        if input_data is not None:
            if len(input_data) > 0:
                if '\\n' in input_data:
                    # Convert \n in the input into new lines.
                    lines = input_data.split('\\n')
                    input_data = '\n'.join(lines)
                if input_data[-1] != '\n':
                    input_data += '\n'
        return input_data

    def _run_send_input(self, stdout, stdin, input_data):
        '''
        Send the input data.

        @param stdout      The stdout stream for the session.
        @param stdin       The stdin stream for the session.
        @param input_data  The input data (default is None).
        '''
        if  stdout.channel.closed is False:
            if input_data is not None:
                self.info('sending input data')
                stdin.write(input_data)

    def _run_poll(self, stdout, timeout, input_data):
        '''
        Poll until the command completes.

        @param timeout     The timeout in seconds.
        @param input_data  The input data.
        @returns the output
        '''
        interval = 0.1
        maxseconds = timeout
        maxcount = maxseconds / interval

        # Poll until completion or timeout
        self.info('polling (%d, %d)' % (maxseconds, maxcount))
        count = 0
        while stdout.channel.closed is False and count <= maxcount:
            count += 1
            time.sleep(interval)

        # Polling finished.
        if stdout.channel.closed is False:
            # Some sort of error occurred, assume a timeout.
            self.info('polling finished - timeout')
            stdout.channel.close()
            output = stdout.read()
            output += '\nERROR: timeout after %d seconds\n' % (timeout)
        else:
            output = stdout.read()
            self.info('polling finished - %d output bytes' % (len(output)))

        if input_data is not None:
            # Strip out the input data.
            output = output[len(input_data):]
            self.info('stripped %d input bytes' % (len(input_data)))

        return output


# ================================================================
# MAIN
# ================================================================
if __name__ == '__main__':
    import sys

    # Access variables.
    hostname = 'hostname'
    port = 22
    username = 'username'
    password = 'password'
    sudo_password = password  # assume that it is the same password

    # Create the SSH connection
    ssh = MySSH()
    ssh.set_verbosity(False)
    ssh.connect(hostname=hostname,
                username=username,
                password=password,
                port=port)
    if ssh.connected() is False:
        print 'ERROR: connection failed.'
        sys.exit(1)

    def run_cmd(cmd, indata=None):
        '''
        Run a command with optional input.

        @param cmd    The command to execute.
        @param indata The input data.
        @returns The command exit status and output.
                 Stdout and stderr are combined.
        '''
        print
        print '=' * 64
        print 'command: %s' % (cmd)
        status, output = ssh.run(cmd, indata)
        print 'status : %d' % (status)
        print 'output : %d bytes' % (len(output))
        print '=' * 64
        print '%s' % (output)

    run_cmd('uname -a')
    run_cmd('sudo ls -ltrh /var/log | tail', sudo_password)  # sudo command

13 thoughts on “Remote command execution in python using paramiko that supports arbitrary input”

  1. Hello Joe!

    Thanks a lot for you code.
    I was debugging my code for the last two days untill I ran into your page…

    My code was missing the .get_pty() call

    Anyhow, thanks a lot for your help.

     

     

    1. It is not clear from your description what is happening. How does it fail? Are you using a cygwin environment? Which version of windows are you using?

  2. Hi Joe!

    Great post! I am new to development and have been spending considerable time understanding the instantiation of classes and functions in your script to tune it for my needs.

    Had a couple questions:

    session.send(data) on line 242, how is that working? I understand it pushes the sudo password but couldn’t find a method “send” that was executing this?
    How is _run_send_input being used? I don’t see it referenced anywhere?

    What I am doing with this:

    I am trying to tune this and combine it with peexpect potentially to login to a Linux System that is connected to a Fortinet FW CLI and issue certain commands recording the output

    Feel free to suggest best ways to do this and very much appreciate the write up and reading of my questions above!

  3. Hi Joe,

    Thanks for the script. I used your script it works fine but at the last I get timeout error. Even if i set timeout to 100 it wait till that time and gives timeout error.

    following is the output of the script:

    Resolving deltas:  98% (8714/8889)
    Resolving deltas:  99% (8806/8889)
    Resolving deltas: 100% (8889/8889)
    Resolving deltas: 100% (8889/8889), done.

    ERROR: timeout after 100 seconds
    I  am tryign to clone a repo on a remote machine. AFter the above output it check for the connection and then print done but the script is not waiting for that line and it always timeout. I increased the timeout to very high value but then also it will give same error. this is not time -out error but its failing to read the last lines and giving this error.

    please sugges.

     

    thanks,

    Madhusudan

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.