SSH Automation with Paramiko: Your First Hello, Router in Python

This is the post where Python starts talking to actual routers. Everything so far — data structures, parsing, sockets — has been preparation. Today you open an SSH session to a device from Python, run a command, and read the result back. The library is Paramiko, the pure-Python SSH implementation that nearly every higher-level network tool (including Netmiko, which we cover tomorrow) is built on top of.

Why learn Paramiko if Netmiko is easier? Because understanding the layer underneath makes the layer above make sense. When Netmiko throws a timeout, you will know what actually happened on the wire.

Install and First Connection

Paramiko is third-party, so it goes in your virtual environment (the venv we set up in Week 1):

pip install paramiko

The minimal connection looks like this:

import paramiko

client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

client.connect(
    hostname="192.168.1.1",
    username="admin",
    password="cisco123",
    look_for_keys=False,    # don't try SSH keys; we're using a password
    allow_agent=False,
    timeout=10,
)
print("Connected!")
client.close()

Two lines deserve explanation. set_missing_host_key_policy(AutoAddPolicy()) tells Paramiko to accept a device’s SSH host key automatically the first time it sees it. In production you would verify keys properly, but in the lab this gets you moving. look_for_keys=False and allow_agent=False stop Paramiko from trying key-based auth first — without them, password auth to network gear is often slow or fails outright.

exec_command vs. an Interactive Shell — the Big Gotcha

Paramiko gives you two ways to run things, and choosing wrong is the number-one beginner mistake with network devices.

# exec_command: opens a NEW channel per command, runs it, closes.
# Works great on Linux. Often BREAKS on Cisco IOS because IOS
# expects an interactive shell, not a one-shot exec channel.
stdin, stdout, stderr = client.exec_command("show version")
print(stdout.read().decode())

On a Linux server, exec_command is perfect. On a Cisco router, many platforms do not support exec channels and you need an interactive shell instead — the same PTY you would get from a real SSH login:

import time

shell = client.invoke_shell()
time.sleep(1)
shell.recv(65535)                       # drain the login banner

shell.send("terminal length 0\n")       # disable --More-- paging
time.sleep(1)
shell.send("show ip interface brief\n")
time.sleep(2)                            # give the device time to respond

output = shell.recv(65535).decode()
print(output)

Notice the time.sleep() calls and the manual recv(). This is the raw, fiddly reality of driving an interactive shell: you send text, you wait, you read whatever has arrived. Get the timing wrong and you read a half-finished response. This friction is exactly why Netmiko exists — it handles the waiting and prompt-detection for you. But seeing the friction once makes you appreciate what Netmiko does.

terminal length 0: The First Command, Always

Interactive sessions on Cisco gear paginate long output with --More-- prompts. A script does not know to press space, so it hangs. Sending terminal length 0 before anything else disables paging for the session. Make it the first thing you send, every time.

Cisco Context: A Reusable Connect-and-Run Helper

Wrapping the boilerplate in a function — using the try/finally discipline from Week 1 so the connection always closes — gives you something you will actually reuse:

import time
import paramiko

def run_commands(host, user, pw, commands, read_wait=2):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        client.connect(hostname=host, username=user, password=pw,
                       look_for_keys=False, allow_agent=False, timeout=10)
        shell = client.invoke_shell()
        time.sleep(1)
        shell.recv(65535)                       # clear banner
        shell.send("terminal length 0\n")
        time.sleep(1)
        shell.recv(65535)

        output = {}
        for cmd in commands:
            shell.send(cmd + "\n")
            time.sleep(read_wait)
            output[cmd] = shell.recv(65535).decode()
        return output
    finally:
        client.close()

results = run_commands("192.168.1.1", "admin", "cisco123",
                       ["show version", "show ip int brief"])
for cmd, out in results.items():
    print(f"=== {cmd} ===\n{out}\n")

Handling Auth and Connection Failures

Real networks have unreachable devices and wrong passwords. Catch the specific exceptions Paramiko raises so a single bad device does not kill a whole run:

import paramiko, socket

try:
    client.connect(host, username=user, password=pw, timeout=5,
                   look_for_keys=False, allow_agent=False)
except paramiko.AuthenticationException:
    print(f"{host}: bad credentials")
except (socket.timeout, OSError):
    print(f"{host}: unreachable")
except paramiko.SSHException as e:
    print(f"{host}: SSH error - {e}")

AuthenticationException, a connection timeout, and the catch-all SSHException are the three you will meet most. This is your Week 1 exception handling applied to a real-world failure surface.

Exercises

If you do not have a lab device, a Linux box with SSH enabled works for the exec_command exercises, and you can rent a free always-on sandbox from Cisco DevNet for the IOS ones.

  1. Warm-up. Connect to a device (or a Linux host) and confirm the connection by printing "Connected", then close cleanly. Use try/finally.
  2. One command. On a Linux host, use exec_command to run uname -a and print the decoded stdout.
  3. Shell mode. On a Cisco device, use invoke_shell to disable paging and run show version, returning the output as a string.
  4. Robust connect. Wrap a connection in handlers for bad credentials and unreachable hosts so the script prints a clear reason instead of a traceback. Test it by pointing at an unreachable IP.
  5. Challenge. Write backup_config(host, user, pw) that connects, runs show running-config, and writes the output to a file named {host}_running.txt. Strip the echoed command and prompt lines so the file starts at the real config.

Answers

Show answers

1. Warm-up

import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
    client.connect("192.168.1.1", username="admin", password="cisco123",
                   look_for_keys=False, allow_agent=False, timeout=10)
    print("Connected")
finally:
    client.close()

2. One command (Linux)

stdin, stdout, stderr = client.exec_command("uname -a")
print(stdout.read().decode().strip())

3. Shell mode (Cisco)

import time
def show_version(client):
    shell = client.invoke_shell()
    time.sleep(1); shell.recv(65535)
    shell.send("terminal length 0\n"); time.sleep(1); shell.recv(65535)
    shell.send("show version\n"); time.sleep(2)
    return shell.recv(65535).decode()

4. Robust connect

import paramiko, socket
def safe_connect(host, user, pw):
    c = paramiko.SSHClient()
    c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        c.connect(host, username=user, password=pw, timeout=5,
                  look_for_keys=False, allow_agent=False)
        return c
    except paramiko.AuthenticationException:
        print(f"{host}: bad credentials"); return None
    except (socket.timeout, OSError):
        print(f"{host}: unreachable"); return None

5. Challenge

import time, paramiko
def backup_config(host, user, pw):
    c = paramiko.SSHClient()
    c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    try:
        c.connect(host, username=user, password=pw,
                  look_for_keys=False, allow_agent=False, timeout=10)
        sh = c.invoke_shell()
        time.sleep(1); sh.recv(65535)
        sh.send("terminal length 0\n"); time.sleep(1); sh.recv(65535)
        sh.send("show running-config\n"); time.sleep(3)
        raw = sh.recv(655350).decode()
        # keep lines from the first '!' or 'version' onward
        lines = raw.splitlines()
        start = next((i for i, l in enumerate(lines)
                      if l.strip().startswith(("version", "!"))), 0)
        with open(f"{host}_running.txt", "w") as f:
            f.write("\n".join(lines[start:]))
        print(f"Saved {host}_running.txt")
    finally:
        c.close()

The next(... ) with a generator finds the index of the first real config line so we drop the echoed command and banner. Tomorrow Netmiko will do all this — paging, prompt handling, command echo — in two lines. Now you will know what it saved you.


Previously: Socket Programming Basics. Coming tomorrow — Netmiko: the same job as today, minus all the time.sleep() pain.

Leave a Reply