Автоматизация сетевого оборудования на Python. Работа через jump-host

10d9749f587b39174062045e7b069773

В моей первой статье на сайте об автоматизации оборудования Juniper в качестве подопытного выступал коммутатор, который стоит под столом, и до которого имеется полный прямой доступ по сети. Однако, такая ситуация скорее исключение. Безопасности работы сетевого оборудования в наше время, когда практически все аспекты жизни завязаны на «всемирную паутину» и различные онлайн-сервисы, уделяется повышенное внимание. И один из подходов к повышению безопасности — использование доступа к оборудованию только с выделенных устройств, которые называют jump host или bastion. В дальнейшем я буду писать «сервер» подразумевая jump host. Сценарий работы в этом случае обычно выглядит так: пользователь подключается к серверу по SSH, а уже с него выходит по SSH, telnet либо другому протоколу на оборудование. Ну и на самом оборудовании установлены списки доступа, запрещающие подключение откуда-либо, кроме отдельных разрешенных адресов. Схема простая и рабочая. И в большинстве случаев не создает каких-то особых неудобств в работе. Но, в случае с автоматизацией, тут есть о чем подумать. Теперь мы должны сделать непростой выбор: либо наши скрипты должны запускаться прямо на этом сервере, с которого прямой доступ до оборудования имеется, либо наша программа должна учесть наличие промежуточного устройства и научиться с ним работать.
У первого подхода, кроме преимущества простоты, есть и определенные подводные камни:

  • Наличие на сервере необходимых библиотек/версии Python. На сервере может оказаться какая-то экзотическая система либо очень стабильная, но древняя версия, для которой не так то просто поставить Python вообще, не говоря уже про последние версии, где много приятных возможностей языка. Кроме того, этот сервер, как правило, огражден от Интернета, и установка туда нужных пакетов может стать отдельной задачей.

  • Доступность с сервера внешних сервисов. Например, наш скрипт обошел кучу оборудования, что-то там сделал, и мы хотим по окончанию отработки отправить письмо с результатом. Но не факт, что почтовый сервер будет доступен с этого сервера, скорее всего никакой связи между ними нет. Тоже касается и возможностей хранения информации, использую базу данных

  • Удобство отладки. На своей машине у нас в этом вопросе полный доступ, а на удаленном сервере, в большинстве случаев, все приходится проверять через print/логирование. Поэтому в этой статье я бы хотел рассмотреть возможные решения в рамках второго подхода, когда мы с нашей машины работаем с оборудованием через сервер.

Метод №1. Встроенные возможности SSH

Проблема необходимости работы через промежуточный сервер не нова. Так что неудивительно, что в самом протоколе SSH заложен подобный сценарий. Действительно,
мы можем написать в файле .ssh/config примерно следующее

host jumphost 
	HostName 192.168.0.100
	IdentitiesOnly yes 
	IdentityFile ~/.ssh/rsa 
	User jh_user 
	
host * !jumphost 
	User dev_user 
	ProxyJump jumphost

Тут мы определяем сервер jumphost, вход на который будет осуществляться по ключу из файла ~/.ssh/rsa под пользователем jh_user. А далее указываем, что на все остальные устройства мы будем ходить через этот сервер. И все. Теперь достаточно в консоли написать ssh и мы попадаем на нужное нам устройство. В скриптах, возможно, придется подсказать, что нужно использовать конфигурацию из файла. Например, если мы используем netmiko, нужно при создании соединения указать параметр ssg_config_file

    dev = {
        'device_type': 'huawei',
        'ip': IP,
        'username': USER_NAME,
        'password': USER_PWD,
        'ssh_config_file': '~/.ssh/config'
        'port': 22,
    }
    connect = netmiko.ConnectHandler(**dev)

Казалось бы, решение найдено и статью можно заканчивать. Но у данного метода есть пара проблемных моментов:

  1. Встроенный механизм использования jump host предполагает, что авторизация на нем производиться по ключу пользователя. А если такая авторизация не поддерживается, механизм не заработает.

  2. Есть еще оборудование, которое не поддерживает SSH и управляется по telnet. В этом случае этот метод тоже не подойдет.

На самом деле, первую проблему иногда можно обойти

Если вы используете Linux, то можно воспользоваться утилитой sshpass, которая перехватывает вывод команды ssh, ожидает запрос на ввод пароля и вводит его. Для примера, если мы запишем наш пароль в файл jh_pass, то можем изменить файл .ssh/config следующим образом

Host * !jumphost
	ProxyCommand sshpass -f ~/jh_pass ssh -W %h:%p -q jh_user@jh_ip

В случае Windows можно использовать WSL

Метод №2. Netmiko и redispatch

Я уже упоминал замечательную библиотеку netmiko, которая заметно упрощает работу с сетевым оборудованием. Не обошли в ней стороной и возможность работы через промежуточное устройство.

Первый способ, можно сказать «в лоб», состоит из следующих простых шагов:

  1. Подключаемся с помощью библиотеки к нашему серверу.

  2. Выполняем на нем команду для подключения к нужному устройству, например ssh user@host

  3. Работаем уже с нашим устройством. Фактически мы просто повторяем те действия, которые выполняли бы работая с устройством без всякого скрипта через терминал. Давайте попробуем реализовать эти шаги при помощи python.

Подключение к серверу

Код подключения стандартный для библиотеки, тип устройства указываем как linux (в том случае, если на нашем сервере стоит Linux, если это какое-то специализированное устройство тип может быть другим). Подключимся и распечатаем вывод команды uname -a. Сразу хочу оговориться, что тут и далее, в целях сокращения, код будет содержать минимум каких либо проверок и обработок исключений. В боевом коде это, несомненно, это нужно будет учесть.

import netmiko

def connect_to_jh():
    dev = {
        'device_type': 'linux', 
        'ip': JH_IP,
        'username': JH_NAME,
        'password': JH_PWD,
        'port': 22,
    }
    net_connect = netmiko.ConnectHandler(**dev)
    return net_connect

def main():
    connect = connect_to_jh()
    if connect:
        print(connect.send_command('uname -a'))
if __name__ == "__main__":
    main()

На выходе получим что-то вроде этого:

Linux JH-U1 5.15.10-1.el7.x86_64 #1 SMP Sat Dec 18 18:25:19 MSK 2021 x86_64 x86_64 x86_64 GNU/Linux

а значит первый шаг мы прошли успешно.

Подключение к маршрутизатору

На втором шаге сформируем команду подключения с нашего сервера до целевого устройства, например маршрутизатора, и отправим ее на сервер. Тут нас ждет первая проблема. Если ранее, когда мы подключались средствами библиотеки netmiko, она сама заботилась о нахождении приглашения на ввод пароля, то теперь нам придется делать это самим. Поэтому, вместо вызова функции send_commnad, которая ожидает получения стандартного приглашения для завершения работы, нам надо воспользоваться функцией send_command_expect, в которой мы явно укажем, что мы ждем в качестве отклика. Вот код функции для подключения:

def connect_to_device(connect, ip):
    cmd = f'ssh -o "StrictHostKeyChecking=no" {USER_NAME}@{ip}'
    connect.send_command_expect(cmd, expect_string="assword:")
    connect.send_command_timing(USER_PWD)

def main():
    connect = connect_to_jh()
    connect_to_device(connect, DEV_IP)
    print(connect.send_command('show version'))

В функцию мы передаем объект типа BaseConnection из библиотеки netmiko и ip адрес устройства для подключения. Внутри функции формируем из имени пользователя и ip полноценную команду на подключение по ssh, после чего вызываем connect.send_command_expect(cmd, expect_string="assword:"), которая отправляет команду на сервер и считывает вывод, ожидая, пока не появится приглашение на ввод пароля. Отдельно хочу обратить внимание на опцию -o "StrictHostKeyChecking=no" при формировании команды. Добавить ее при вызове ssh надо, чтобы ключ от устройства автоматически сохранился в файле .ssh/known_hosts. Если этого не сделать, а устройства не будет в этом файле, ssh будет запрашивать разрешения сохранить ключ и до приглашения на ввод пароля дело не дойдет, что нам создаст дополнительные трудности. Можно, конечно, обработать вывод и при запросе отправить yes, но зачем усложнять программу?

Работа с устройством

Сразу перейдем к шагу 3 и попробуем с помощью нашей программы подключиться через промежуточный сервер к какому-нибудь маршрутизатору juniper и посмотреть на вывод команды show version (вызов этой команды я уже добавил в скрипт выше).
На выходе получим что-то вроде этого (вывод я сократил для наглядности)

Hostname: ************-AR1
Model: mx480
Junos: 16.1R7.7
JUNOS OS Kernel 32-bit  [20180601.93ff995_builder_stable_10]
JUNOS OS libs [20180601.93ff995_builder_stable_10]
JUNOS OS runtime [20180601.93ff995_builder_stable_10]
JUNOS OS time zone information [20180601.93ff995_builder_stable_10]
JUNOS py extensions [20180612.033802_builder_junos_161_r7]
JUNOS py base [20180612.033802_builder_junos_161_r7]
JUNOS OS crypto [20180601.93ff995_builder_stable_10]
JUNOS network stack and utilities [20180612.033802_builder_junos_161_r7]
JUNOS libs [20180612.033802_builder_junos_161_r7]
JUNOS runtime [20180612.033802_builder_junos_161_r7]
...

Как видим, программа успешно вывела результат работы команды. Однако, расслабляться нам пока рано. В последнее время Ростелеком все больше использует оборудование отечественных производителей. Подключимся к коммутатору одного из них — Eltex и попробуем вывести конфигурацию коммутатора выполнив show running-config. Результат нас разочарует

netmiko.exceptions.ReadTimeout:
Pattern not detected: 'TEST\\-SW10\\#' in output..

В чем проблема? Чтобы понять, что произошло, добавим в наш скрипт логирование обмена данными по SSH.

import logging
logging.basicConfig(filename='netmiko.log', level=logging.DEBUG)
logger = logging.getLogger("netmiko")

После повторного запуска скрипта в файле netmiko.log мы увидим следующие строки (вывод сокращен)

DEBUG:netmiko:write_channel: b'show running-config\n'
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel: s
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel: how running-config
no spanning-tree
!
DEBUG:netmiko:Pattern found: (show\ running\-config) show running-config
DEBUG:netmiko:read_channel: vlan database
 vlan 10,17,220
DEBUG:netmiko:read_channel:
exit
DEBUG:netmiko:read_channel:
!
port jumbo-frame
DEBUG:netmiko:read_channel: !
loopback-detection enable
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel: loopback-detection vlan-based
DEBUG:netmiko:read_channel: More: ,  Quit: q or CTRL+Z, One line: 
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
DEBUG:netmiko:read_channel:
...

В самом начале лога мы видим, что команда отправлена и начинаем считывать приходящие данные. В ответ на команду наш коммутатор начинает отправлять конфигурация. В несколько вызовов read_channel мы считываем часть ответа, после чего новая информация перестает поступать — в конце куча вызовов read_channel, которые не возвращают новых данных. Этих вызовов гораздо больше (вывод сильно сокращен), до самого наступления таймаута. Из вывода проблема становится сразу видна. More: ,  Quit: q or CTRL+Z, One line:  — вывод коммутатора производится в постраничном режиме и выведя первую порцию данных коммутатор ждет нашей реакции.
Все дело в том, что библиотека netmiko при подключении к устройству не только обрабатывает процесс авторизации пользователя, но и, в зависимости от переданного типа устройства, выполняет некоторую начальную подготовку, в том числе выполняет команду отключения постраничного вывода. Это мы знаем, что уже подключились к коммутатору Eltex, а вот программе об этом не сообщили. Как ей сказали при первом подключение, что это устройство типа linux, так программа с ним и работает. Мы отправили команду, коммутатор отдал часть вывода. И в конце добавил More: ,  Quit: q or CTRL+Z, One line: сигнализируя, что ждет от нас команды на продолжение либо на отмену. А для библиотеки же сигналом на окончание вывода является получение TEST\-SW10\#. Не дождавшись этого библиотека бросает исключение. В случае с MX480, к которому подключались ранее, нам повезло, что вывод поместился в одну страницу. Если бы информации было бы больше или мы попробовали бы произвести настройку через send_config_set, нас так же ждало бы разочарование. Что же делать? Именно для этого в библиотеке netmiko предусмотрен механизм redispatch. Суть этого механизма в том, что мы сообщаем netmiko тип нового устройства, а библиотека динамически меняет тип нашего подключения на соответствующий. Для этого имеется следующая функция:

def redispatch(  
    obj: "BaseConnection", device_type: str, session_prep: bool = True  
) -> None

Как видно из объявления функции, которое я скопировал из кода библиотеки, она принимает на вход 3 параметра. Первый — это существующее подключение, тип которого мы хотим изменить. Второй — новый тип устройства. А третий параметр как раз и позволяет нам выполнить на текущем устройства начальные команды по подготовки сессии к работе (отключение различных «улучшайзеров», отключение постраничного ввода и др.). По умолчанию данная функциональность включена.
Библиотека Netmiko поддерживает работу с широкой номенклатурой оборудования. В том числе есть модуль для работы с оборудованием Eltex. Изменим нашу функцию и явно укажем, что новое устройство это eltex. Вот, что получается.

def main():
    connect = connect_to_jh()
    if connect:
        connect_to_device(connect, DEV_IP)
        netmiko.redispatch(connect, device_type='eltex')
        print(connect.send_command('show running-config', read_timeout=60))

Запускаем скрипт — и конфигурация коммутатора успешно отображается у нас на экране.
Хочу отметить, что желательно обновить версию библиотеки до последней. Дело в том, что, при переходе на 4 версию, механизм redispatch для некоторых устройств (в том числе Juniper, Еltex) сломался. На что я наткнулся и был озадачен, что скрипт работает на ноутбуке, а на другой машине — нет. Вот ссылка на ошибку в библиотеке, которая была исправлена.

Нужного результата мы добились. Более того, теперь мы можем многократно прыгать с одного устройства на другое, каждый раз менять тип на нужный и все будет работать.
Из неочевидных минусов данного метода хочу заметить следующее. Фактически мы подключаемся к jump host под учетной записью пользователя, поднимается полноценная сессия. И обежав таким методом несколько тысяч устройств, а потом подключившись самостоятельно через терминал, можно обнаружить, что история команд пополнилось на несколько тысяч новых записей. Теи, кто привык стрелочками выбирать команды из списка последних — будет грустно :)

Метод №3. Проброс портов

Фактически, все современные программы для работы по SSH, такие как SecureCRT, XShell и другие, поддерживают возможность работы через промежуточное устройство. И для этого они используют возможность протокола SSH по пробросу портов. В SSH мы можем создать канал между портом нашей локальной машины и определенным портом на удаленном устройстве через jump host. Сделать это можно, передав необходимые параметры клиенту SSH, но мы сделаем это с помощью кода. Для этого мы обратимся к библиотеке paramiko, которая реализует работу по протоколу SSH. Именно эту библиотеку использует «под капотом» и netmiko, и ncclient (который реализует протокол NETCONF) , когда мы работаем по SSH (netmiko может работать не только по SSH). Сначала напишем функцию, которая соединит локальный порт нашего компьютера с нужным нам портом удаленного устройства через jump host. Незамедлительно приступим:

def get_new_channel_via_jump_host(ip, local_port):
    vm = paramiko.SSHClient() #создаем клиента
    vm.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    vm.connect(JH_IP, username=JH_NAME, password=JH_PWD) #подключаемся к нашему jump_host
    transport = vm.get_transport()
    dest_addr = (ip, 22)
    local_addr = ('127.0.0.1', local_port)
    return transport.open_channel("direct-tcpip", dest_addr, local_addr) #создаем канал через jump host между local_port нашего компьютера и 22 портом целевого устройства

В коде постарался прокомментировать основные моменты, но, думаю, должно быть понятно, благо тут всего несколько строчек.
Бежим дальше. Канал между нами и целевым устройством создан и можем им воспользоваться для подключения. Как уже и говорил, netmiko хорошо дружит с paramiko, и создать подключение, используя уже созданный канал, достаточно просто. Новая функция подключения выглядит следующим образом:

def connect_to_device(ip, dev_type):
    local_port = 40700
    channel = get_new_channel_via_jump_host(ip, local_port)
    dev_connect_params = {
        'device_type': dev_type,
        'ip': '127.0.0.1',
        'username': USER_NAME,
        'password': USER_PWD,
        'sock': channel,
        'conn_timeout': 30,
    }

    return netmiko.ConnectHandler(**dev_connect_params)

Тут тоже нет ничего сложного. Мы выбираем локальный порт на нашей машине (тут я его прописал жестко, в реальном коде выбор этого порта на ваш вкус) и пробрасываем канал. А потом создаем подключение стандартным для netmiko способом, только добавляем дополнительный параметр sock, в котором передаем ранее созданный канал.
Вот весь код нашего второго способа.

import netmiko
import paramiko

def get_new_channel_via_jump_host(ip, local_port):
    vm = paramiko.SSHClient()
    vm.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    vm.connect(JH_IP, username=JH_NAME, password=JH_PWD)
    transport = vm.get_transport()
    dest_addr = (ip, 22)
    local_addr = ('127.0.0.1', local_port)
    return transport.open_channel("direct-tcpip", dest_addr, local_addr)

def connect_to_device(ip, dev_type):
    local_port = 40700
    channel = get_new_channel_via_jump_host(ip, local_port)
    dev_connect_params = {
        'device_type': dev_type,
        'ip': '127.0.0.1',
        'username': USER_NAME,
        'password': USER_PWD,
        'sock': channel,
        'conn_timeout': 30,
        'global_delay_factor': 5,
    }
    return netmiko.ConnectHandler(**dev_connect_params)

def main():
    connect = connect_to_device(DEV_IP, 'huawei')
    if connect:
        print(connect.send_command('display version'))

if __name__ == "__main__":
    main()

Поскольку я подключался к живому устройству я добавил в число параметров 'global_delay_factor': 5, чтобы нагруженная коробка успела вывести prompt за отведенный таймаут.
Попробуем с помощью этого кода подключиться к маршрутизатору Huawei и вывести результат команды display version. На выходе получим что-то вроде этого:

Huawei Versatile Routing Platform Software
VRP (R) software, Version 5.160 (CX600 V600R008C10SPC300)
Copyright (C) 2000-2014 Huawei Technologies Co., Ltd.
HUAWEI CX600-X8 uptime is 3103 days, 18 hours, 42 minutes
Patch version : V600R008SPH131
CX600-X8 version information:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

BKP 1 version information:
  PCB         Version : CX61BKP08B REV C
  MPU  Slot  Quantity : 0
  SRU  Slot  Quantity : 2
  SFU  Slot  Quantity : 1
  LPU  Slot  Quantity : 8

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MPU version information:

MPU(Master) 9  : uptime is 3103 days, 18 hours, 41 minutes
         StartupTime   2015/12/17   02:28:53
  SDRAM Memory Size   : 3904M bytes
  FLASH Memory Size   : 32M  bytes
  NVRAM Memory Size   : 4096K bytes
  CFCARD Memory Size : 1954M bytes
...

Хочется отметить, что поскольку в параметре sock мы передаем уже готовый к работе сокет, то параметр ip смысла не имеет и не будет никак использоваться. Однако он обязательный и хоть что-то в нем передать надо.

Поскольку мы можем пробросить наш туннель до любого порта на удаленном устройстве, то данный подход мы можем использовать и для подключения по протоколу telnet. Для этого чуть поменяем функцию get_new_channel_via_jump_host, добавив параметр с номером удаленного порта, и напишем функцию для подключения по протоколу telnet, использую библиотеку telnetlib.

def get_new_channel_via_jump_host(ip, local_port, remote_port=22):
    vm = paramiko.SSHClient()
    vm.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    vm.connect(JH_IP, username=JH_NAME, password=JH_PWD)
    transport = vm.get_transport()
    dest_addr = (ip, remote_port)
    local_addr = ('127.0.0.1', local_port)
    return transport.open_channel("direct-tcpip", dest_addr, local_addr)

def login(tn):
        login_prompt = [l.encode('utf-8') for l in
                             ["login:", 'Username:', ]]
        pass_prompt = [l.encode('utf-8') for l in ["Password:", 'password:', "PassWord:",]]
        num, _, read_data = tn.expect(login_prompt, 5)
        if read_data:
            tn.write((USER_NAME + '\r\n').encode('utf-8'))
            num, _, read_data = tn.expect(pass_prompt, 5)
            if read_data:
                tn.sock.sendall((USER_PWD + '\r\n').encode('utf-8'))
                prompt = [b'>', b'#']
                num, _, read_data = tn.expect(prompt, 10)


def connect_to_device_telnet(ip):
    local_port = 40701
    channel = get_new_channel_via_jump_host(ip, local_port, 23)
    con = tn.Telnet()
    con.sock = channel
    login(con)
    con.write(b'show version\n')
    sleep(2)
    ret = con.read_very_eager()
    print(ret.decode('utf-8'))

Сразу хотел бы обратить внимание на создание подключения. Обычно при создании объекта Telnet в качестве параметра в него передается ip адрес устройства, получив который конструктор пытается открыть новый сокет. Но нам этого делать не надо, сокет у нас уже есть. Поэтому мы вызываем конструктор без параметра, а после присваиваем полю sock значение ранее созданного туннеля.
В коде я отдельно сделал функцию login для авторизации на устройстве. Код максимально упрощен, мы просто ожидаем приглашение на ввод имени пользователя, вводим, ждем запрос пароля, вводим пароль и отправляем команду show version. После чего ждем 2 секунды, чтобы хоть что-то пришло и выводим полученные данные в консоль. Вывод будет примерно таким:

show version
Cisco IOS Software, c7600s3223_rp Software (c7600s3223_rp-ADVIPSERVICES-M), Version 12.2(33)SRC2, RELEASE SOFTWARE (fc2)
Technical Support: http://www.cisco.com/techsupport
Copyright (c) 1986-2008 by Cisco Systems, Inc.
Compiled Thu 18-Sep-08 09:32 by prod_rel_team

ROM: System Bootstrap, Version 12.2(17r)SX3, RELEASE SOFTWARE (fc1)

 TEST-AR1 uptime is 4 weeks, 7 hours, 9 minutes
Uptime for this control processor is 4 weeks, 7 hours, 11 minutes
System returned to ROM by power-on (SP by power-on)
System image file is "bootdisk:c7600s3223-advipservices-mz.122-33.SRC2.bin"
Last reload type: Normal Reload

cisco CISCO7604 (R7000) processor (revision 2.0) with 458752K/65536K bytes of memory.
Processor board ID FOX11170DK7
R7000 CPU at 300Mhz, Implementation 0x27, Rev 3.3, 256KB L2, 1024KB L3 Cache
Last reset from power-on
12 Virtual Ethernet interfaces
9 Gigabit Ethernet interfaces
1915K bytes of non-volatile configuration memory.

 --More--

В выводе мы видим, что в начале ответа присутствует повторение команды, а в конце приглашение --More-- для вывода следующей страницы данных. В реальном боевом коде нам надо либо обрабатывать постраничный вывод, либо его отключать. Да и стратегия получения данных, когда отправляем команду, пару секунд ждем и считываем вывод, годиться только для тестовых целей, да и то, очень ограниченных. При «правильном» подходе, нам нужно находить приглашение консоли и уже на основании его обрабатывать вывод.
Но есть и хорошая новость. Для достаточно большого парка оборудования нам особо ничего изобретать не надо. В библиотеке netmiko для большинства типов оборудования есть не только классы, отвечающие за взаимодействие по протоколу SSH, но и их коллеги, работающие по протоколу telnet. Для этого при создании подключение нужно передать необходимое значение в параметре device_type. Как правило, к обычному значению просто добавляется _telnet, например cisco_ios_telnet или juniper_junos_telnet, после чего библиотека будет использовать внутри себя библиотеку telnetlib, в остальном практически не отличаясь от версии с SSH. Но одно, немного неприятное, отличие все же есть. Дело в том, что если при создании подключения по SSH библиотека обращает внимание на переданный ей параметр sock, то в случае с Telnet этот параметр не учитывается. И нам придется написать немного дополнительного кода.

def connect_to_device(ip, dev_type, auto_connect = True):
    local_port = 40701
    channel = get_new_channel_via_jump_host(ip, local_port, 23)
    dev_connect_params = {
        'device_type': dev_type,
        'ip': '127.0.0.1',
        'username': USER_NAME,
        'password': USER_PWD,
        'sock': channel,
        'conn_timeout': 30,
        'global_delay_factor': 5,
        'auto_connect': auto_connect,
    }
    return netmiko.ConnectHandler(**dev_connect_params)


def main():
    connect = connect_to_device(DEV_IP, 'cisco_ios_telnet', False)
    if connect:
        connect.remote_conn = tn.Telnet()
        connect.remote_conn.sock = connect.sock
        connect.channel = netmiko.channel.TelnetChannel(conn=connect.remote_conn,
                                                        encoding=connect.encoding)
        connect.telnet_login()
        connect._try_session_preparation()
        print(connect.send_command('show version'))

Первое, что мы сделали в этом коде — добавили параметр auto_connect в функцию connect_to_device и передаем его в библиотеку netmiko при создании подключения. Название достаточно говорящее, получив в этом параметре False библиотека не пытается сразу создать подключение. А мы, создав нужный класс, сами выполним код по созданию подключения. Аналогично предыдущему примеру, создаем класс Telnet и передаем ему ранее созданный сокет. Дальше повторяем действия библиотеке при инициации подключения, вызываем telnet_login для входа и _try_session_preparation для выполнения подготовительных команд. Дальше работаем с подключением обычным образом.
А что с упомянутой в предыдущей статье библиотекой PyEZ для работы с оборудованием Juniper? Тут все не так красиво, как хотелось бы. Фактически, библиотека PyEZ внутри для подключения использует библиотеку ncclient, которая реализует протокол NETCONF. У нее, так же как и у netmiko имеется возможность передачи параметра sock, с уже созданным сокетом для работы, и способ подключения через эту библиотеку аналогичен. Однако, библиотека PyEZ этот параметр не принимает, не может его передать в нижележащую ncclient и изначально не готова к работе с пробросом портов. Костыльным решением в этом случае будет правка исходников PyEZ чтобы самостоятельно добавить новый параметр в конструктор и его передачу при создании подключение в ncclient. После внесения подобных правок все отлично работает. По этому поводу я создал заявку на изменение в PyEZ.

Заключение

В данной статье я постарался описать те решения по работе с jump host, которые использую сам. Разные методы обладают своими преимуществами и недостатками и могут использоваться исходя из условий текущей задачи. Например, если на jump host-е запрещен проброс портов, первый и третий методы работать не будут. Ни в коем случае не претендую на то, что данный список исчерпывающий. За любые дополнения и замечания буду благодарен — возможно, получится сделать работу более эффективной.

© Habrahabr.ru