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 opens an SSH session to a device from Python, runs a command, and reads the result back. The library is Paramiko, the pure-Python SSH implementation that nearly every higher-level network tool (including Netmiko, tomorrow’s topic) is built on top of.

Why learn Paramiko when Netmiko is easier? Because understanding the layer underneath makes the layer above make sense. When Netmiko throws a timeout, knowing what actually happened on the wire is what makes it fixable.

Install and First Connection

Paramiko is third-party, so it goes in the virtual environment (the venv from 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. Production code would verify keys properly, but in the lab this gets things 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 offers 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 an interactive shell is required instead — the same PTY a real SSH login provides:

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: send text, wait, read whatever has arrived. Get the timing wrong and the response comes back half-finished. This friction is exactly why Netmiko exists — it handles the waiting and prompt-detection automatically. But seeing the friction once makes what Netmiko does easy to appreciate.

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 command sent, 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 — produces something genuinely reusable:

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. Catching the specific exceptions Paramiko raises keeps a single bad device from killing 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 that come up most. This is the Week 1 exception handling applied to a real-world failure surface.

Exercises

With no lab device on hand, a Linux box with SSH enabled works for the exec_command exercises, and a free always-on sandbox from Cisco DevNet covers the IOS ones.

This is Day 12 of the 21‑post Python for Network Engineers series.

  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, dropping the echoed command and banner. Tomorrow Netmiko does all of this — paging, prompt handling, command echo — in two lines. Having seen the raw version makes clear exactly what it saves.


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