Scripting SSH to network devices

This blog entry is a follow up to a previous post called Scripting the WLC.

As a summary, the original post addressed programmatic access to a Cisco AireOS WLC, using Python to log in to the WLC and collect the output of a given command.

A few things have changed since then, which is the reason for this update, specifically:

  • Older AireOS versions had an issue (CSCve45024) affecting the login process which is resolved in newer AireOS versions.
  • Current generation Cisco WLCs run IOS-XE which does not have any login issues, meaning no creative workarounds are required.
  • As it turns out, there are better ways to do certain things, so some limitations of the original script no longer apply.

The revised script presented here will still log in to a device, and still return the output of a command, but is no longer limited to AireOS. The script has been tested on an AireOS WLC (virtual), Catalyst switch (2960), Catalyst WLC (9800), and Raspberry Pi 4B, so it should cover Linux devices also, and possibly any other network device with minor tweaks.

Firstly, the login issue. As it is no longer necessary to enter login credentials twice, the workaround of passing null credentials, and then the real credentials, is no longer required. We can simply pass the correct credentials using Paramiko from the start. Like you would expect from a normal SSH session.

The second improvement has to do with reading the returning output from the device. In the original script there was a requirement to set the amount of time the script waited for the output, and also set the buffer size according to the expected output size. This was a clunky approach and didn’t scale, as the script had no knowledge of either how large the output of the command would be, or how long it would take to receive. Larger outputs can take a few seconds while smaller outputs are instant, it doesn’t make sense to wait any longer than is needed. Similarly, certain device outputs (e.g. show tech-support) can be a few megabytes in size on larger devices, and it is not practical (or elegant) to have such large receive buffers ‘just in case’.

The script below streams data from the device and stops listening once complete, no specific configuration is required for larger outputs. As most network devices return to the prompt once an output is provided, we use the network prompt as a marker to stop the receiving of data.

Script download on GitHub: device.ssh.adaptive.py

Additional technical details.

First, we set up the required variables, including device IP and login credentials. Note, the enable password will only be used in case a ‘>’ prompt is detected. Wait time is required to allow the device to respond with some output, during testing all devices were LAN connected so 0.2 seconds per input was sufficient. Static wait times are only used in the do_login function (described below). my-command is the command you wish to run and return the output of.

device_ip = '192.168.1.1'

login_username = 'admin'
login_password = 'password'
enable_password = 'password' #If required

receive_buffer = 256 #No need to change this
wait_time = 0.2 #Increase this for slow devices

my_command = 'show ip interface brief'

There are two functions defined, one to handle the login, and a second for running the requested command(s).

def do_login():
    device_session = paramiko.SSHClient()
    device_session.set_missing_host_key_policy(paramiko.AutoAddPolicy()) #Accept self-signed certificates
    device_session.connect(device_ip, port=22, username=login_username, password=login_password) #Initiate SSH connection
    device_ssh = device_session.invoke_shell() #Start interactive shell
    device_ssh.settimeout(3) #Set socket timeout
    device_ssh.keep_this = device_session #Prevents closed socket after function returns
    time.sleep(wait_time) #Pause for device output
    device_ssh.recv(16384).decode('utf-8', 'backslashreplace').strip() #Clear login banner(s)
    device_ssh.send('\n') #Send new line
    time.sleep(wait_time) #Pause for device output
    device_prompt = device_ssh.recv(receive_buffer).decode('utf-8', 'backslashreplace').strip() #Read device prompt
    if device_prompt.endswith('(Cisco Controller) >'): #If AireOS, set config paging Disable
        device_ssh.send('config paging disable' + '\n')
        time.sleep(wait_time) #Pause for device output
        device_ssh.recv(receive_buffer).decode('utf-8', 'backslashreplace').strip() #Prevent config paging disable in output
    elif device_prompt.endswith('>'): #If in Disable mode, enter Enable mode
        device_ssh.send('enable' + '\n')
        time.sleep(wait_time) #Pause for device output
        device_ssh.recv(receive_buffer).decode('utf-8', 'backslashreplace') #Read password prompt
        device_ssh.send(enable_password + '\n') #Send enable mode password
        time.sleep(wait_time) #Pause for device output
        device_prompt = device_ssh.recv(receive_buffer).decode('utf-8', 'backslashreplace').strip() #Set new device prompt - enable mode
    if device_prompt.endswith('#'): #If enable mode, set terminal length 0
        device_ssh.send('terminal length 0' + '\n')
        time.sleep(wait_time) #Pause for device output
        device_ssh.recv(receive_buffer).decode('utf-8', 'backslashreplace').strip() #Prevent terminal length 0 in output
return(device_ssh, device_prompt) #Return Paramiko channel and device prompt

The do_login function starts the SSH session and passes the login credentials, then clears any login banners and passes a single new line. The output is read again to determine the network device prompt. Specific to AireOS, we check if the prompt ends with ‘(Cisco Controller) >’, in which case we pass config paging disable. Specific to Cisco IOS, we check if the prompt ends in either ‘>’ for Disable mode, or ‘#’ for Enable mode. If the disable mode prompt is detected we enter Enable along with the enable password. When the enable mode prompt is detected, we pass the terminal length 0 command. For devices that do not have a prompt ending in either ‘>’ or ‘#’ these actions will be skipped. Once complete, the function returns the open channel and device prompt to the main script.

def do_command(device_ssh, device_prompt, device_command):
    device_ssh.send(device_command + '\n') #Send command to device
output = ''
    while not output.rstrip().endswith(device_prompt): #Read and append output until device prompt is matched
    try:
        output += device_ssh.recv(receive_buffer).decode('utf-8', 'backslashreplace')
    except socket.timeout: #Stop if no data returned for socket timeout duration
    print('Socket timeout on command')
    sys.exit()
return(output)

The do_command function passes the required command to the device. The device prompt derived during login is used to match the end of the output. A small receive buffer is used to continuously read from the channel and append the output variable. A socket timeout is returned if no data is received for the duration of the timeout value, or if the output ends and the prompt is not matched. No static wait times are used in this function, the function stops immediately when the output ends with the device prompt, and returns the device output to the main script.

if name == 'main':
    session, prompt = do_login()
    my_output = do_command(session, prompt, my_command)
    print(my_output)
    session.close()

The main script calls the do_login function, and passes the returned information (open channel and device prompt), along with the command to run, to the do_command function. The output of the do_command function is printed to the console, and the session is closed.

Note: The default Paramiko exec_command function only allows for the execution of a single command, the channel cannot be reused for subsequent commands. As our script is invoking an interactive session using invoke_shell, we are keeping the channel open. This means that the do_command function can be reused multiple times to run multiple commands without the need to re-establish the session.

This post will be updated as more improvements are made.

Example output:

Script written using Python 3.7.4 and Paramiko 2.6.0