Библиотека SNMP на Go, зачем я создал еще одну и чем она может быть интересна

в 19:21, , рубрики: snmp, snmpv3

Когда-то давно, в 2012–2014 годах, мне и коллегам понадобилось собирать различные данные с большого числа различных коммутаторов и прочего сетевого оборудования.

В то время у нас были в основном коммутаторы Cisco немного Moxa и немного HP.

Мониторилось все это при помощи PRTG и Nagios, а для сбора данных мы использовали ПО switchmap для Cisco и собственные скрипты на PHP для Moxa. ПО в целом выполняло свою функцию, информация собиралась и помогала в работе.

Однако с течением времени количество коммутаторов и маршрутизаторов увеличивалось, появилось оборудование других производителей, которое switchmap опросить уже не мог, так же возникали различные проблемы и с Moxa, плюс ко всему SNMP версии 2C требовалось по возможности больше не использовать, перейдя на версию 3 (актуальная по сей день).

Так же хотелось сохранять данные в базе данных и/или в файлах формата XML/JSON.
И конечно хотелось параллельного опроса множества коммутаторов.

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

Остановился я на библиотеке WebNMS от Zoho, она коммерческая, но была и бесплатная версия (если что, нвйти по ней информацию чегодня практический невозможно).

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

Однако довольно быстро обнаружились проблемы: с некоторого оборудования данные получить не удавалось – я получал ошибку парсинга ASN.1 (тут поясню для тех, кто глубоко не знает SNMP. Данные в SNMP кодируются при помощи ASN.1 BER, и именно то, что это BER приводит к проблемам, которые я опишу ниже).

Так же периодически получал различные друге ошибки, которые требовалось исправлять в коде библиотеки.

И можно было бы перейти на коммерческую версию и полагать что Zoho исправит ошибки в рамках поддержки, но Zoho вообще прекратили ее поддерживать, а теперь про нее ничего не может найти даже гугл.

Закопавшись в исходниках библиотеки я стал понимать, что для того, чтоб что-то там исправить, помимо понимания структуры библиотеки, необходимо еще и глубокие знания SNMP и ASN.1. Моих знаний на тот момент было недостаточно и пришлось их форсированно пополнить.

В итоге я принял решение написать свою библиотеку SNMP v3, но начал, разумеется, с версии 2C.

Затем я потратил много времени на погружение в аутентификацию и шифрование (на тот момент у меня были не нулевые знания в этой области, но их было недостаточно) и следующим этапом было добавление поддержки 3 версии SNMP в библиотеку. Я добавил только шифрование AES-128 и аутентификацию SHA и на этом ограничился.

Итогом этого труда стало то, что я с коллегами смог опрашивать все наше оборудование, а там, где возникали проблемы мы их оперативно исправляли.

Очень скоро оказалось что некоторое оборудование поддерживает только DES и MD5, пришлось добавить и их.

В 2025 году мне понадобилось добавить поддержку шифрования AES-256, и я решил так же немного сделать рефакторинг кода, а за одно довести его до состояния, когда им будет не стыдно делиться и в итоге опубликовал его на Github под лицензией MIT.

А в этой статье я попробую раскрыть детали проекта и возможно кому-то моя библиотека пригодится.

Итак приступим.

Любая библиотека SNMP должна уметь кодировать и декодировать данные в формате ASN.1 BER. Нам придется немного погрузиться в этот стандарт.

Минимально что нужно знать – ASN.1 это бинарное кодирование данных, в виде последовательности тип, длина, значение (Type Length Value или TLV), существует множество вариантов такого кодирования, но нас интересует BER – базовое и DER строгое, и то что парсер ASN.1 в Go это парсер именно DER (и именно DER применяется в криптографии).

Но в SNMP применяется BER и напрямую, стандартный парсер Go ASN.1 применить можно, но очень скоро мы столкнемся с оборудованием, декодировать данные которого не сможем.

Тут замечу что в обратную сторону это работает - то есть закодировав запрос DER кодированием, оборудование его поймет.

Очень важное и ключевое отличие между BER и DER это как кодируется длина.
В DER есть правило, ели длина меньше 127 то кодируем одним байтом, в BER же это правило можно не соблюдать и кодировать длину, например в 5 байт двумя байтами, зачем спросите вы? Ну например для того, чтоб структура данных была фиксированной длины, или еще по какой-то причине, это вопросы к производителям оборудования, например к Moxa.

Из этого следует что придется либо использовать сторонний парсер ASN.1, либо писать свой.

Я решил просто модифицировать стандартный парсер, ослабив проверки и введя поддержку еще одного формата длины – Indefinite, это когда в конце данных есть маркер окончания, длину в такой форме в SNMP я никогда не видел, но решил ее поддержку тоже добавить.

Так появился форк стандартного парсера ASN.1, с которым можно ознакомиться по ссылке.

Итак, кодер/декодер есть, теперь нужно реализовать:

Для версии 2c

  • формирование запроса

  • Парсинг ответа

  • Посылку/прием запроса/ответа, повторную посылку в случае неполучения ответа, таймауты

  • Функции Get, Set, GetNext

  • Поддержку Bulk запросов/ответов

  • Высокоуровневый Walk

  • Служебные функции, такие как обнаружение выхода из ветки для завершения Walk, инициализацию и прочее

Для версии 3 в дополнение к тому, что есть у 2c

  • Шифрование/Дешифрование

  • Аутентификация

  • Процедура Discovery

  • Обработка Report сообщений

  • Работа со специфичными для 3 версии функциями – например в версии 3 можно сообщить передатчику сколь данных максимально мы можем принять, и тогда буфер, в который будет прочитана датаграмма, можно ограничить этим объемом сэкономив память.

Так же нужно реализовать прием trap/inform как для версии 2c, так и для 3 версии и еще многое другое.

Если вас утомил текст то вы можете ознакомится с проектом по ссылке и далее не читать, а если же нет, то далее я коротко опишу чем проект интересен, а следующим этапом погрузимся в технические детали, но это будет уже вторая статья (если конечно вы мне не напишите в коментариях - нет, не интересно и не нужно продолжение). А пока опишу чем проект интересен и какие есть альтернативы.

И так сильные (на мой взгляд) стороны библиотеки:

Использование слегка модифицированного парсера ASN.1 из стандартной библиотеки.
Почему это преимущество? Потому что это родной Go парсер, он хорошо оттестирован и широко используется, поскольку модификации минимальные и хорошо описаны, их легко можно внести в более новые версии стандартного парсера. Так же используется традиционный Go способ кодировки/декодировки – marshal/unmarshal.

Синхронизация Boots/Time выполняется при выполнении процедуры Discovery и при получении Report сообщения с ошибкой notInTimeWindows. Почему это как мне кажется хорошо? Давайте немного подробнее разберемся как это возможно реализовать:

И так для того, чтоб ваш запрос к устройству был валидный, вам помимо знания Username, протоколов и ключей аутентификации и шифрования а так же Engine ID, надо еще указать два параметра – Boots (количество перезагрузок устройства) и Time (время в секундах, с момента включения устройства).

Boots должен точно совпадать а Time может отличаться не более чем на ±150 секунд
Если этот параметр однократно инициализировать при Discovery, то спустя 150 секунд мы получим Report с ошибкой notInTimeWindows.

Если же этот параметр инкрементировать при каждом запросе, то если сеть быстрая, а запросов много, то может так случиться, что счетчик, наоборот, убежит вперед и тоже станет не валидным.

Можно, конечно, использовать реальное время, а можно просто при получении Report notInTimeWindows, произвести синхронизацию и повторить запрос.

Обработка Report с ошибкой usmStatsUnknownEngineIDs (выполнение повторного Discovery).

Ошибки разделены на фатальные и не фатальные, так же содержат детальную информацию. Это требует отдельного описания, оно будет ниже.

Компактный код прост для понимания.

Теперь остановимся подробнее на процессе Discovery и обработке Report сообщений, разумеется, это все относится только к версии 3 протокола.

Что же такое Discovery и зачем она нужна? Для того чтоб отправить запрос, например Get, понадобится указать Username, Engine ID, Boots, Time, Auth parameter, Priv parameter. Из всего этого на начальном этапе известен лишь Username, потому что для вычисления Auth/Priv параметров, и создания корректного запроса необходимо знать Engine ID, Boots и Time.

Чтоб получить эти данные пошлем Get запрос с пустыми, всеми этими полями:

Библиотека SNMP на Go, зачем я создал еще одну и чем она может быть интересна - 1

В ответ должен придет Report с ошибкой usmStatsUnknownEngineIDs (OID 1.3.6.1.6.3.15.1.1.4.0):

Библиотека SNMP на Go, зачем я создал еще одну и чем она может быть интересна - 2

Из этого сообщения мы можем извлечь сразу и Engine ID и Boots/Time.

Данная процедура выполняется до начала обмена данными, однако же, если у вас какой то очень протяженный по времени опрос, и в какой то момент Engine ID на устройстве сменился, нужно обработать эту ситуацию и произвести определение Engine ID заново, а значит и сгенерировать новый ключевой материал, так как он зависит от Engine ID.

К обработке report сообщений мы еще вернемся, а пока перейдем к обработке ошибок при, например отсутствии запрашиваемого OID на оборудовании.

Проблема актуальна только для GET в котором запрашиваются данные сразу по нескольким OID’ам, суть проблемы:

Рассмотрим случай, в котором мы хотим получить данные по нескольким OID’ам:

   "1.3.6.1.2.1.1.5.0",   // sysName
   "1.3.6.1.2.1.1.6.0",   // sysLocation
   "1.3.6.1.2.1.1.99.0",  // Ошибочный OID (вернет noSuchObject)
   "1.3.6.1.2.1.1.100.0",  // Ошибочный OID (вернет noSuchObject)

Вот так выглядит Get запрос:

Библиотека SNMP на Go, зачем я создал еще одну и чем она может быть интересна - 3

А так, ответ на него:

Библиотека SNMP на Go, зачем я создал еще одну и чем она может быть интересна - 4

То есть формально, SNMP ошибки нет, однако запрашиваемые данные содержат значения noSuchObject. И обычно, это должен обработать пользователь библиотеки.

В PowerSNMPv3 происходит все иначе, Функция SNMP_GetMulti вернет только те данные, которые существуют, и вернет ошибку, ошибка структурирована и есть специальная функция ParseError, которая возвращает информацию, фатальная была ошибка или нет, список ошибочных OID и причину ошибки, и еще некоторую полезную информацию.

Что касается операции Set то тут ситуация абсолютно другая. Set операция атомарная, и если не удалось установить один из OID’ов то вся операция провалена и ничего не будет модифицировано.

Соответственно ошибка будет всегда фатальная.

Так же если мы выполним GET с несколькими OID’ами и все будут с ошибкой, ошибка тоже будет фатальная. Давайте посмотрим на SET:

Библиотека SNMP на Go, зачем я создал еще одну и чем она может быть интересна - 5

И ответ на него:

Библиотека SNMP на Go, зачем я создал еще одну и чем она может быть интересна - 6

Тут уже поле error-status указывает на тип ошибки, а error-index на тот OID (начиная с 1) по которому и произошла ошибка, если в списке будут другие ошибочные OID мы ничего о них не узнаем, индекс только один.

Однако в данных, которые прислал коммутатор, выглядит все так как будто все применилось.

И некоторые библиотеки отдают эти данные пользователю. PowerSNMPv3 же в этом случае, вернет пустые данные.

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

Итак, напишем небольшой тест того, что выше описано.

package main

import (
    "flag"
    "fmt"

    PowerSNMP "github.com/OlegPowerC/powersnmpv3"
)

func main() {
    //Параметры командной строки
    Host := flag.String("h", "", "Switch or routers IP")
    SNMPuser := flag.String("u", "", "SNMP v3 USER")
    SNMPauthProtocol := flag.String("a", "", "SNMP auth protocol")
    SNMPauthPassword := flag.String("A", "", "SNMP auth password")
    SNMPprivProtocol := flag.String("x", "", "SNMP priv protocol")
    SNMPprivPassword := flag.String("X", "", "SNMP priv password")
    flag.Parse()

    //Осздаем описание коммутатора
    var dev PowerSNMP.NetworkDevice
    dev.IPaddress = *Host
    dev.SNMPparameters.SNMPversion = 3
    dev.Port = 161
    dev.SNMPparameters.Username = *SNMPuser
    dev.SNMPparameters.AuthProtocol = *SNMPauthProtocol
    dev.SNMPparameters.AuthKey = *SNMPauthPassword
    dev.SNMPparameters.PrivProtocol = *SNMPprivProtocol
    dev.SNMPparameters.PrivKey = *SNMPprivPassword

    //Инициализируем SNMP
    Ssess, InitErr := PowerSNMP.SNMP_Init(dev)
    if InitErr != nil {
       fmt.Println(InitErr)
       return
    }

    fmt.Println("=== выполним GET для единичного OID ===")
    swrongoid := "1.3.6.1.2.1.1.99.0"
    swrongoidiarr, parseerr := PowerSNMP.ParseOID(swrongoid)
    if parseerr != nil {
       fmt.Println(parseerr)
       return
    }
    GetsingleRes, GetsingleErr := Ssess.SNMP_Get(swrongoidiarr)
    if GetsingleErr != nil {
       snmpErr, commonErr := PowerSNMP.ParseError(GetsingleErr)
       if commonErr != nil {
          // Ошибка связанная с недоступностью хоста, шифрованием, еще чем то подобным
          fmt.Println("ошибка Network/system:", GetsingleErr)
          return
       }
       if snmpErr.IsFatal {
          // Фатальная SNMP ошибка
          fmt.Println("Фатальная ошибка SNMP, результат недоступен")
          for _, descr := range snmpErr.Oids {
             fmt.Println("Описание ошибки:", descr.ErrorDescription, descr)
          }
       } else {
          // Ошибка не фатальная
          fmt.Printf("Частичная ошибка, %d ошибочные OID'ы:", len(snmpErr.Oids))
          for _, oidErr := range snmpErr.Oids {
             fmt.Printf("  | %s", oidErr.ErrorDescription)
          }
       }
       fmt.Println("")
    } else {
       fmt.Println("--- Результат одиночного GET ---")
       for _, wl := range GetsingleRes {
          fmt.Println(PowerSNMP.Convert_OID_IntArrayToString_RAW(wl.RSnmpOID), "=", PowerSNMP.Convert_Variable_To_String(wl.RSnmpVar), ":", PowerSNMP.Convert_ClassTag_to_String(wl.RSnmpVar))
       }
    }

    fmt.Println("=== выполним GET с несколькими OID'ами ===")
    OidsStrings := []string{"1.3.6.1.2.1.1.6.0", "1.3.6.1.2.1.1.99.0", "1.3.6.1.2.1.1.5.0", "1.3.6.1.2.1.1.100.0"}
    OidsConverted := []PowerSNMP.SNMP_Packet_V2_Decoded_VarBind{}

    for _, OidSting := range OidsStrings {
       Ioid, IoidErr := PowerSNMP.Convert_OID_StringToIntArray_RAW(OidSting)
       if IoidErr != nil {
          fmt.Println(IoidErr)
          return
       }
       OidsConverted = append(OidsConverted, PowerSNMP.SNMP_Packet_V2_Decoded_VarBind{Ioid, PowerSNMP.SNMPvbNullValue})
    }
    GetRes2, verr2 := Ssess.SNMP_GetMulti(OidsConverted)

    fmt.Println("--- Проверим ошибки ---")
    if verr2 != nil {
       snmpErr, commonErr := PowerSNMP.ParseError(verr2)
       if commonErr != nil {
          // Ошибка связанная с недоступностью хоста, шифрованием, еще чем то подобным
          fmt.Println("ошибки Network/system:", commonErr)
       }

       if snmpErr.IsFatal {
          // Фатальная SNMP ошибка
          fmt.Println("Фатальная ошибка SNMP, результат недоступен")
          for _, descr := range snmpErr.Oids {
             fmt.Println("Описание ошибки:", descr.ErrorDescription, descr)
          }
       }

       // Ошибка не фатальная, результат каой то доступен
       fmt.Printf("Частичная ошибка, %d ошибочные OID'ы:", len(snmpErr.Oids))
       for _, oidErr := range snmpErr.Oids {
          fmt.Printf("  | %s", oidErr.ErrorDescription)
       }
       fmt.Println("")
    } else {
       fmt.Println("- Нет ошибок -")
    }

    fmt.Println("--- Результаты ---")
    for _, wl := range GetRes2 {
       fmt.Println(PowerSNMP.Convert_OID_IntArrayToString_RAW(wl.RSnmpOID), "=", PowerSNMP.Convert_Variable_To_String(wl.RSnmpVar), ":", PowerSNMP.Convert_ClassTag_to_String(wl.RSnmpVar))
    }

    fmt.Println("=== операция SET нескольких OID ===")
    VarData := []PowerSNMP.SNMPVar{PowerSNMP.SetSNMPVar_OctetString("Test 6.0"), PowerSNMP.SetSNMPVar_OctetString("Test 99.0"), PowerSNMP.SetSNMPVar_OctetString("Test 5.0")}
    SetStringOids := []string{"1.3.6.1.2.1.1.6.0", "1.3.6.1.2.1.1.99.0", "1.3.6.1.2.1.1.5.0"}
    SetDataVB := []PowerSNMP.SNMP_Packet_V2_Decoded_VarBind{}
    if len(VarData) == len(SetStringOids) {
       for VdataInd, StoidS := range SetStringOids {
          IoidS, IoidErrS := PowerSNMP.Convert_OID_StringToIntArray_RAW(StoidS)
          if IoidErrS != nil {
             fmt.Println(IoidErrS)
             return
          }
          SetDataVB = append(SetDataVB, PowerSNMP.SNMP_Packet_V2_Decoded_VarBind{IoidS, VarData[VdataInd]})
       }
    } else {
       fmt.Println("Не равно количество oid и данных")
       return
    }

    sdata, verres3 := Ssess.SNMP_SetMulti(SetDataVB)
    fmt.Println("--- Проверим ошибки ---")
    if verres3 != nil {
       snmpErr, commonErr := PowerSNMP.ParseError(verres3)
       if commonErr != nil {
          // Ошибка связанная с недоступностью хоста, шифрованием, еще чем то подобным
          fmt.Println("ошибки Network/system:", commonErr)
       }
       if snmpErr.IsFatal {
          // Фатальная ошибка
          fmt.Println("Фатальная ошибка SNMP, операция провалена")
          for _, descr := range snmpErr.Oids {
             fmt.Println("Описание ошибки:", descr.ErrorDescription)
          }
       } else {
          // Частичная ошибка, но ее не может быть при операйии SET
          fmt.Printf("Частичная ошибка, %d ошибочные OID'ы:", len(snmpErr.Oids))
          for _, oidErr := range snmpErr.Oids {
             fmt.Printf("  | %s", oidErr.ErrorDescription)
          }
       }

       fmt.Println("")
    } else {
       fmt.Println("- Нет ошибки -")
    }

    fmt.Println("--- Результат, который вернул SET ---")
    for _, wl := range sdata {
       fmt.Println(PowerSNMP.Convert_OID_IntArrayToString_RAW(wl.RSnmpOID), "=", PowerSNMP.Convert_Variable_To_String(wl.RSnmpVar), ":", PowerSNMP.Convert_ClassTag_to_String(wl.RSnmpVar))
    }
}

И выполним, указав тестовый коммутатор и все нужные параметры, результат должен быть примерно такой:

   === выполним GET для единичного OID ===
   Фатальная ошибка SNMP, результат недоступен
   Описание ошибки: 1.3.6.1.2.1.1.99.0 (status=128): NoSuchObject {[1 3 6 1 2 1 1 99 0] 128 1.3.6.1.2.1.1.99.0 (status=128): NoSuchObject}

   === выполним GET с несколькими OID'ами ===
   --- Проверим ошибки ---
   Частичная ошибка, 2 ошибочные OID'ы:  | 1.3.6.1.2.1.1.99.0 (status=128): NoSuchObject  | 1.3.6.1.2.1.1.100.0 (status=128): NoSuchObject
   --- Результаты ---
   1.3.6.1.2.1.1.6.0 = Test location : Universal OCTET STRING
   1.3.6.1.2.1.1.5.0 = powercsw01.powerc : Universal OCTET STRING
   === операция SET нескольких OID ===
   --- Проверим ошибки ---
   Фатальная ошибка SNMP, операция провалена
   Описание ошибки: 1.3.6.1.2.1.1.99.0 (status=11): CannotCreateVariable

   --- Результат, который вернул SET ---

Полезно выполнить в режиме отладки, поставить точки останова а так же запустить Wireshark.
В Wireshark можно указать параметры SNMP и он будет дешифровать данные и отображать расшифрованные.

Так же в библиотеку включен интеграционный тест.

Думаю на этом первую чась я закончу и милости просим в коментарии для обсуждения.

Автор: OlegPowerC

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js