Juniper: выращивание можжевельника в домашних условиях


    Привет, Habr! Меня зовут Дмитрий, и я разработчик DCImanager — панели для управления оборудованием от ISPsystem. Довольно продолжительное время в команде я провёл, разрабатывая софт для управления коммутаторами. Вместе мы пережили взлеты и падения: от написания сервисов для управления железом до падения офисной сети и часовых свиданий в серверной в надежде не потерять своих любимых.


    И вот настало время тестирования. Часть обработчиков мы смогли покрыть готовыми решениями для тестирования. Но с Juniper так не получилось. Ресерч и реализация послужили идеей для написания этой статьи. Если интересно, добро пожаловать под кат!


    DCImanager работает с разными видами оборудования: коммутаторы, распределители питания, серверы. Сейчас DCImanager поддерживает четыре обработчика коммутаторов. Два по протоколу SNMP (Cisco Catalyst и общий snmp common) и еще два по протоколу NETCONF (Juniper с поддержкой ELS и без).


    Всю работу с оборудованием мы обильно покрываем тестами. Использовать для автоматического тестирования реальное оборудование не получается: тесты запускаются на каждый пуш и проходят параллельно. Поэтому мы стараемся использовать эмуляторы.


    Обработчики с поддержкой протокола SNMP мы смогли покрыть тестами, используя библиотеку SNMP Agent Simulator. А вот с Juniper’ом возникли проблемы. Поискав готовые решения, выбрали пару библиотек, но одна из них не завелась, а другая делала не то, что нужно — я потратил больше времени на попытки оживить это чудо.


    Встал вопрос, а как же эмулировать работу коммутаторов Juniper? Juniper работает по протоколу NETCONF, который, в свою очередь, работает поверх SSH. В голове промелькнула мысль написать небольшой сервис, который будет работать поверх SSH и эмулировать работу коммутатора. Соответственно, нам нужен сам сервис, а также «снимок» Juniper для эмуляции данных.


    В snmpsim под снимком понимается полная копия состояния коммутатора, со всеми его поддерживаемыми OID и их текущими значениями. В Juniper всё немного сложнее: такой снимок сделать не получится. Здесь под снимком будем понимать набор шаблонов типа: запрос-ответ.

    Часть первая: архитектура посадки


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


    В самом простом варианте — фабрика, которая в зависимости от протокола и обработчика (некоторые коммутаторы могут работать по нескольким протоколам), будет возвращать объект коммутатора, в котором уже будет реализована вся логика его поведения. В случае с Juniper, это небольшой синтаксический анализатор запроса. В зависимости от входного rpc-запроса с параметрами, он будет выполнять необходимые действия.


    Важное ограничение: мы не сможем полностью имитировать работу коммутатора. На описание всей логики уйдёт много времени, а добавив новую функциональность в реальный обработчик, нам придется править и mock коммутатора.


    Часть вторая: подбираем почву для посадки


    Взгляд пал на библиотеку paramiko, которая предоставляет удобный интерфейс работы по протоколу SSH. Для начала хотелось не разносить архитектуру, а проверить базовые вещи, например, коннект и какой-нибудь простой запрос. Мы же всё-таки ресерчем занимаемся. Поэтому над авторизацией не заморачиваемся: простой ServerInterface и socket-сервер в связке дают нам что-то похожее на работающий вариант:


    class SshServer(paramiko.ServerInterface):
       def check_auth_password(self, user, password):
           if user == SSH_USER_NAME and password == SSH_USER_PASSWORD:
               return paramiko.AUTH_SUCCESSFUL
           return paramiko.AUTH_FAILED
    
    socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    socket.bind(("127.0.0.1", 8300))
    socket.listen(10)
    
    client, address = socket.accept()
    session = paramiko.Transport(client)
    
    server = SshServer()
    session.start_server(server=server)

    Примерная реализация того, что хотелось бы видеть, но выглядит страшно


    При подключении клиента к серверу, второй должен ответить списком своих capabilities (возможностей). Например таким:


    reply = """
        <hello>
         <capabilities>
          <capability>urn:ietf:params:xml:ns:netconf:base:1.0</capability>
          <capability>xml.juniper.net/netconf/junos/1.0</capability>
          <capability>xml.juniper.net/dmi/system/1.0</capability>
         </capabilities>
         <session-id>1</session-id>
        </hello>
        ]]>]]>
    """
    socket.send(reply)

    Да, это XML ]]>]]>


    Если что, код работает нестабильно. В данной реализации есть проблема с закрытием сокета. Нашел пару зарегистрированных issues в paramiko с этой проблемой. Отложил ненадолго, решив проверить оставшийся вариант.


    Часть третья: посадка


    Козырь в рукаве — Twisted. Это фреймворк разработки сетевых приложений с поддержкой большого количества протоколов. У него есть обширная документация и замечательный модуль Cred, который нам и поможет.


    Cred — это механизм аутентификации, позволяющий различным сетевым протоколам подключаться к системе в зависимости от ваших требований.


    Для организации всей логики используется Realm — часть приложения, отвечающая за бизнес-логику и доступ к ее объектам. Но обо всем по порядку.


    Ядром входа в систему является Portal. Если мы хотим написать надстройку над сетевым протоколом, определяем стандартный Portal. В нём уже есть методы:


    • login (предоставляет доступ клиента к подсистеме)
    • registerChecker (непосредственно проверка учетных данных).

    Для привязки бизнес-логики к системе аутентификации используется Realm-объект. Так как клиент уже авторизован, здесь начинается логика нашей надстройки над SSH. Данный интерфейс имеет всего один метод requestAvatar, который вызывается при успешной авторизации в Portal и возвращает основной объект — SwitchProtocolAvatar:


    @implementer(portal.IRealm)
    class SwitchRealm(object):
        def __init__(self, switch_obj):
            self.switch_obj = switch_obj
    
        def requestAvatar(self, avatarId, mind, *interfaces):
            return interfaces[0], SwitchProtocolAvatar(avatarId, switch_obj=self.switch_obj), lambda: None

    Самая простая реализация Realm-объекта, возвращающая необходимый Avatar


    За управление бизнес-логикой отвечают специальные объекты — Avatar`ы. В нашем случае здесь начинается надстройка над протоколом SSH. Когда отправляется запрос, данные попадают в SwitchProtocolAvatar, который проверяет подсистему запроса и обновляет конфигурацию:


    class SwitchProtocolAvatar(avatar.ConchUser):
        def __init__(self, username, switch_core):
            avatar.ConchUser.__init__(self)
            self.username = username
            self.channelLookup.update({b'session': session.SSHSession})
    
            netconf_protocol = switch_core.get_netconf_protocol()
            if netconf_protocol:
                self.subsystemLookup.update({b'netconf': netconf_protocol})

    Проверяем подсистему и обновляем конфигурацию при условии, что данный обработчик коммутатора работает по NETCONF


    К слову о протоколах. Не забываем, что мы работаем с NETCONF, и приступаем к выполнению. Для написания надстроек над уже существующими протоколами и реализации своей логики используется Protocol. Интерфейс данного класса простой:


    • dataReceived — используется для обработки событий на получение данных;
    • makeConnection — используется для установки соединения;
    • сonnectionMade — используется, когда соединение уже установлено. Здесь можно определить некоторую логику до того, как клиент начнет присылать запросы. В нашем случае надо отправить список своих capabilities.

    class Netconf(Protocol):
        def __init__(self, capabilities=None):
            self.session_count = 0
            self.capabilities = capabilities
    
        def __call__(self, *args, **kwargs):
            return self
    
        def connectionMade(self):
            self.session_count += 1
            self.send_capabilities()
    
        def send_capabilities(self):
            rpc_capabilities_reply = "<hello><capabilities>{capabilities}</capabilities>" \
                                     "<session-id>{session_id}</session-id></hello>]]>]]>"
            rpc_capabilities = "".join(f"<capability>{cap}</capability>" for cap in self.capabilities)
    
            self.transport.write(rpc_capabilities_reply.format(capabilities=rpc_capabilities, 
                                                               session_id=self.session_count))
    
        def dataReceived(self, data):
            # Process received data
            pass

    Минимальная реализация обертки над протоколом. Убрал лишнюю логику для наглядности


    Начинаем сворачивать нашу матрешку. Так как мы используем надстройку над SSH, то нам необходимо реализовать логику SSH-сервера. В нём мы определим ключи для сервера и обработчики для служб SSH. Реализация данного класса не сильно нас интересует, так как авторизация будет по паролю:


    class SshServerFactory(factory.SSHFactory):
        protocol = SSHServerTransport
    
        publicKeys = {b'ssh-rsa': keys.Key.fromFile(SERVER_RSA_PUBLIC)}
        privateKeys = {b'ssh-rsa': keys.Key.fromFile(SERVER_RSA_PRIVATE)}
    
        services = {
            b'ssh-userauth': userauth.SSHUserAuthServer,
            b'ssh-connection': connection.SSHConnection
        }
    
        def getPrimes(self):
            return PRIMES

    Реализация SSH-сервера


    Для работы SSH-сервера необходимо определить логику сессий, которая работает вне зависимости от того, по какому протоколу к нам пришли, и какой интерфейс запрашивается:


    class EchoProtocol(protocol.Protocol):
        def dataReceived(self, data):
            if data == b'\r':
                data = b'\r\n'
            elif data == b'\x03':  # Ctrl+C
                self.transport.loseConnection()
                return
            self.transport.write(data)
    
    class Session:
        def __init__(self, avatar):
            pass
    
        def getPty(self, term, windowSize, attrs):
            pass
    
        def execCommand(self, proto, cmd):
            pass
    
        def openShell(self, transport):
            protocol = EchoProtocol()
            protocol.makeConnection(transport)
            transport.makeConnection(session.wrapProtocol(protocol))
    
        def eofReceived(self):
            pass
    
        def closed(self):
            pass

    Логика сессий для всех описываемых интерфейсов


    Чуть не забыл о самом обработчике. После всех проверок и авторизаций логика переходит к объекту, эмулирующему работу коммутатора. Тут можно определить логику обработки запросов: получение или редактирование интерфейсов, конфигурация устройства и т. д.


    class Juniper:
        def __init__(self):
            self.protocol = Netconf(capabilities=self.capabilities())
    
        def get_netconf_protocol(self):
            return self.protocol
    
        @staticmethod
        def capabilities():
            return [
                "Candidate1_0urn:ietf:params:xml:ns:netconf:capability:candidate:1.0",
                "urn:ietf:params:xml:ns:netconf:capability:confirmed-commit:1.0",
                "urn:ietf:params:xml:ns:netconf:capability:validate:1.0",
                "urn:ietf:params:xml:ns:netconf:capability:url:1.0?protocol=http,ftp,file",
                "urn:ietf:params:netconf:capability:candidate:1.0",
                "urn:ietf:params:netconf:capability:confirmed-commit:1.0",
                "urn:ietf:params:netconf:capability:validate:1.0",
                "urn:ietf:params:netconf:capability:url:1.0?scheme=http,ftp,file"
            ]

    Основная логика обработчика. Вырезал всю функциональность и обработку запросов, оставив только получение capabilities


    Ну и наконец-то сращиваем всё это вместе. Регистрируем адаптер сессии (описывает поведение при подключении), определяем метод подключения по имени пользователя и паролю, настраиваем Portal и запускаем наш сервис:


    components.registerAdapter(Session, SwitchProtocolAvatar, session.ISession)
    
    switch_factory = SwitchFactory()
    switch = switch_factory.get("juniper")
    
    portal = portal.Portal(CustomRealm(switch))
    credential_source = InMemoryUsernamePasswordDatabaseDontUse()
    credential_source.addUser(b'admin', b'admin')
    portal.registerChecker(credential_source)
    
    SshServerFactory.portal = portal
    
    reactor.listenTCP(830, SshServerFactory())
    reactor.run()

    Настройка и запуск сервера


    Запускаем mock-сервер. Для проверки работоспособности можно подключиться с помощью библиотеки ncclient. Обычной проверки соединения и просмотра capabilities сервера хватит:


    from ncclient import manager
    
    connection = manager.connect(host="127.0.0.1",
                                 port=830,
                                 username="admin",
                                 password="admin",
                                 timeout=60,
                                 device_params={'name': 'junos'},
                                 hostkey_verify=False)
    
    for capability in connection.server_capabilities:
       print(capability)

    Подключаемся к mock-серверу по протоколу NETCONF и выводим список capabilities сервера


    Сам результат запроса представлен ниже. Мы успешно установили соединение, а сервер отдал нам список своих capabilities:


    Candidate1_0urn:ietf:params:xml:ns:netconf:capability:candidate:1.0
    urn:ietf:params:xml:ns:netconf:capability:confirmed-commit:1.0
    urn:ietf:params:xml:ns:netconf:capability:validate:1.0
    urn:ietf:params:xml:ns:netconf:capability:url:1.0?protocol=http,ftp,file
    urn:ietf:params:netconf:capability:candidate:1.0
    urn:ietf:params:netconf:capability:confirmed-commit:1.0
    urn:ietf:params:netconf:capability:validate:1.0
    urn:ietf:params:netconf:capability:url:1.0?scheme=http,ftp,file

    Capabilities сервера


    Заключение


    У данного решения достаточно плюсов и минусов. С одной стороны, мы тратим много времени на реализацию и описание всей логики обработки запросов. С другой — получаем возможность гибкой настройки и эмуляции поведения. Но главное — это масштабируемость. Фреймворк Twisted обладает богатой функциональностью и поддерживает большое число протоколов, поэтому можно без проблем описывать новые интерфейсы обработчиков. А если всё хорошо продумать, данную архитектуру можно использовать не только для работы с коммутаторами, но и для другого оборудования.


    Хотелось бы узнать мнение читателей. Делали ли вы подобное и если да, то какие технологии использовали и как выстраивали процесс тестирования?

    ISPsystem
    Софт для хостинга: ISPmanager, BILLmanager и др.

    Комментарии 5

      +1

      Ок мнения:


      1. А почему бы не использовать виртуальные коммутаторы от самих вендоров?
        Cisco Nexus — v9000
        Cisco Cloud Router — csr-1000v
        Juniper QFX — vQFX10000
        Их можно просто скачать и использовать.
        Да они возможно тяжеловаты по используемой памяти, но при этом получите практически реальное тестируемое железо с точки зрения control/management plane


      2. Судя по первым 3-м буквам в названии вашего продукта, он позиционируется для использования с ЦОД. Но кто сейчас catalyst в ЦОД ставит?
        ИМХО Вам больше стоит на Nexus ориентироваться ну и наверное стоит включиь в roadmap Arista и Huawei.


      3. Cisco Nexus-ы ровно так же как и Juniper-ы (любые) умеют в netconf. Причем не только по ssh в качестве транспорта, но и по https. В последнем случае данные могут быть в XML или в JSON, что сильно удобней.
        Cisco называет эту фичу RESTCONF. Уже пару лет оно есть и в NXOS и в IOS-XE (так что новые каталисты тоже поддерживают хоть им и нет места в ЦОДе).
        Таким образом ваш эмулируемый коммутатор Cisco превращается в простое веб-приложение отдающее json. Гораздо проще возни в SNMP.


      4. Использовать paramiko в качестве netconf клиента не очень удобно мягко говоря.
        Для этого существует ncclient. Кроме того рекомендую попробовать scrapli с драйвером scrapli_netconf.


        0
        1. Возможность работы с виртуальными коммутаторами рассматривалась. Однако рассматривались сторонние решения, не от вендоров. На момент ресерча данной темы нам казалось слишком затратным поднимать рассматриваемые виртуальные решения, так как наш workflow настроен таким образом, что тесты запускаются на каждый push в репозиторий.
        Поэтому, хотелось обойтись дешевым решением.

        2. Мы ориентируемся на клиентов и их запросы. Поверьте, есть клиенты с Сatalyst :)
        По поводу других коммутаторов. Мы пополняем наш список обработчиков. Недавно вышел обработчик на коммутаторы Arista. На подходе Cisco Nexus. Опять же, ориентируемся на клиентов.

        3. Когда-нибудь мы поднимем эмулятор Cisco с этой фичей. Спасибо за информацию :)

        4. Вы правы, для клиента не очень удобно. Но мы рассматривали случай написания серверной части. Библиотека Paramiko была одним из вариантов пробной реализации.
        Для NETCONF-клиента мы используем ncclient. За scrapli спасибо)
        –2
        Прошу уточнить, ПО о котором идёт речь распространяется по вашему стандартному лицензионному договору, в частности с пунктом отказа от ответственности в изложенном ниже виде?

        www.ispsystem.ru/contracts/license-contract.html?2

        8. ОТВЕТСТВЕННОСТЬ СТОРОН

        8.1. За неисполнение или ненадлежащее исполнение обязательств по настоящему Лицензионному договору Стороны несут ответственность в соответствии с законодательством Российской Федерации и условиями настоящего Лицензионного договора.

        8.2. Стороны освобождаются от ответственности за полное или частичное неисполнение обязательств по настоящему Лицензионному договору, если надлежащее исполнение оказалось невозможным вследствие непреодолимой силы, то есть чрезвычайных и непредотвратимых при данных условиях обстоятельств, возникших после заключения настоящего Лицензионного договора.

        8.3. Лицензиар ни при каких обстоятельствах не несет никакой ответственности за упущенную выгоду, прямые или косвенные убытки, понесенные Лицензиатом при работе с Программными продуктами, а также за убытки, связанные с отзывом лицензионных прав (расторжение настоящего Лицензионного договора) на Программные продукты. Лицензиар не гарантирует отсутствие ошибок, равно их исправление. Лицензиат заключает настоящий Лицензионный договор, руководствуясь принципом «как есть».

        Лицензиат полностью принимает на себя риск последствий использования Программных продуктов, в т.ч. их взаимодействия с другим программным обеспечением, установленным на компьютере (сервере) Лицензиата, и соглашается с тем, что результат использования Программных продуктов может не соответствовать ожиданиям Лицензиата.

        8.4. Ни при каких обстоятельствах Лицензиар не несет ответственность за любой ущерб или убытки (включая, но не ограничиваясь, упущенную выгоду, убытки, вызванные утратой конфиденциальной или иной информации), возникающие в связи с использованием или невозможностью использования Программных продуктов, в т.ч. в случае отказа работы Программного продукта, даже если Лицензиат направил уведомление о вероятности возникновения такого ущерба и/или убытков.

        Лицензиар не несет ответственность перед любыми лицами за неправомерные действия третьих лиц, временные технические сбои и перерывы в работе Программных продуктов, вызванные неполадками линий связи, иные аналогичные сбои, а также за неполадки оборудования, с помощью которого используются Программные продукты.

        8.5. Каждая из Сторон должна выполнять свои обязанности надлежащим образом в соответствии с настоящим Лицензионным договором и применимым национальным и международным законодательством, а также оказывать другой Стороне всевозможное содействие в выполнении ее обязанностей.

        8.6. В случае предъявления к Лицензиату претензий об авторском праве на Программные продукты и других претензий третьих лиц, вызванных нарушением Лицензиаром настоящего Лицензионного договора, Лицензиар обязуется принять разумные меры по урегулированию конфликта.

        8.7. Лицензиат самостоятельно несет ответственность перед всеми третьими лицами, включая сублицензиатов, за любые свои действия или бездействия.

        8.8. Лицензиар не несет материальную ответственность за возникшие у Лицензиата (Клиента Лицензиата) убытки, вытекающие, в том числе, из невозможности использования Программных продуктов.

        8.9. Лицензиар оставляет за собой право расторгнуть настоящий Лицензионный договор в одностороннем порядке при совершении Лицензиатом любого нарушения настоящего Лицензионного договора.

        8.10. Лицензиару не могут быть предъявлены никакие требования в отношении функционирования Дополнительного Контента, конечным правообладателем которого выступает третье лицо.
          0
          Если не путаю, то условия обсуждаются с отделом развития на этапе покупки, но я больше по коду, поэтому лучше спросите про него)
          0

          А вы не смотрели в сторону NAPALM?
          Они делают примерно похожий "универсальный" интерфейс.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое