» » » Анализ таблиц маршрутизации, или зачем ещё сетевому инженеру Python

 

Анализ таблиц маршрутизации, или зачем ещё сетевому инженеру Python

Автор: admin от 13-06-2018, 23:55, посмотрело: 28

Hello Habr! Эта моя первая статья на Хабре, и родилась она из вопроса на одном из профессиональных форумов. Выглядел вопрос, несколько перефразируя, следующим образом:




  • Имеется набор текстовых файлов, содержащих вывод таблиц маршрутизации с различных сетевых устройств;

  • Каждый файл содержит информацию с одного устройства;

  • Устройства могут иметь различный формат вывода таблицы маршрутизации;

  • Необходимо на основании имеющихся данных по запросу выводить путь до произвольной подсети или IP-адреса с каждого из устройств;

  • Вывод должен включать на каждом участке пути информацию о записи из таблицы маршрутизации, по которой будет смаршрутизирован пакет.



Задача мне показалась мне интересной и перекликалась с одной из собственных сетевых утилит, планируемых в перспективе.Поэтому в свободный вечер, поразмыслив над ее решением, написал Proof-of-Concept реализацию на Python 2.7 под формат Cisco IOS, IOS-XE и ASA, отвечающую основным требованиям.



В статье попытаюсь воспроизвести ход мысли и прокомментировать основные моменты.

Материал рассчитан на людей, уже базово знакомых с основами сетей и Python.

Всем заинтересовавшимся добро пожаловать под кат!

SubnetTree.



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



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



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

Специфика области применения подсказывает средний лимит в 1.000.000 записей в таблице маршрутизации на одно современное устройство. На роутере с BGP Full View по состоянию на июнь 2018 фактически может быть 724.000+ маршрутов.

По грубой оценке и результатам тестов на in-memory хранение и обработку каждого 1.000.000 префиксов потребуется около 500MB RAM. Таким образом средней производительности рабочая станция с 8GB RAM (2018 год все еще держим в уме) позволит проанализировать топологию с совокупной емкостью до 14-16.000.000 маршрутов. То есть сегмент из примерно 18-20 маршрутизаторов с full view на каждом.

Для большинства случаев этого вполне достаточно, а для больших (как ударение ни ставь) сетей нужно или разбивать анализ на сегменты, или переносить логику на out-of-memory базу данных.

640КБ хватит всем.Остановимся на in-memory варианте.



Парсинг исходных файлов и выбор структур данных



Файлы с таблицами маршрутизации вынесем в отдельную суб-директорию и сделаем ее переменной:



RT_DIRECTORY = "./routing_tables"


Для Cisco IOS и IOS-XE таблица маршрутизации может выглядеть примерно так:





В Cisco ASA формат схожий, но, вместо длин префиксов, отображается маска подсети в десятичном представлении:





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

Выходит две глобальные группы: маршруты типов Local+Connected и все остальное.

Несколько усложняет дело возможность наличия многострочных маршрутов с переменным количеством next-hop’ов. Из-за этого же проблематично читать файл для обработки построчно. Один из выходов — обработка множества совпадений итератором регулярного выражения в загруженном целиком в текстовую переменную файле.



Напишем регулярные выражения с учетом перечисленных требований и ограничений:



# Local and Connected route strings matching.
REGEXP_ROUTE_LOCAL_CONNECTED = re.compile(
     '^(?P<routeType>[L|C])s+'
    + '((?P<ipaddress>dd?d?.dd?d?.dd?d?.dd?d?)'
    + 's?'
    + '(?P<maskOrPrefixLength>(/dd?)?'
    + '|(dd?d?.dd?d?.dd?d?.dd?d?)?))'
    + ' is directly connected, '
    + '(?P<interface>S+)',
    re.MULTILINE
)
# Static and dynamic route strings matching.
REGEXP_ROUTE = re.compile(
      '^(SS?*?s?S?S?)'
    + 's+'
    + '((?P<subnet>dd?d?.dd?d?.dd?d?.dd?d?)'
    + 's?'
    + '(?P<maskOrPrefixLength>(/dd?)?'
    +'|(dd?d?.dd?d?.dd?d?.dd?d?)?))'
    + 's*'
    + '(?P<viaPortion>(?:n?s+([dd?d?/d+])s+'
    + 'vias+(dd?d?.dd?d?.dd?d?.dd?d?)(.*)n?)+)',
    re.MULTILINE
)


Оба выражения содержат именованные группы (named groups) для удобства извлечения данных при поиске совпадений и поддержания кода.

В частности, из каждого маршрута нужно получить префикс (группы subnet/interface и maskOrPrefixLength) и информацию о том, куда он ведёт (группы viaPortion/interface).



Так как выражения учитывают несколько вариантов представления префикса, в группе maskOrPrefixLength может быть как маска подсети, так и длина префикса. Будем приводить это к единому формату при обработке, остановимся на длине префикса:



def convert_netmask_to_prefix_length(mask_or_pref):
    if not mask_or_pref:
        return ""
    if re.match("^/dd?$", mask_or_pref):
        return mask_or_pref
    if re.match("^dd?d?.dd?d?.dd?d?.dd?d?$",
                mask_or_pref):
        return (
            "/"
           + str(sum([bin(int(x)).count("1") for x in mask_or_pref.split(".")]))
        )
    return ""


Добавим также регулярные выражения для построчного разбора next-hop из группы viaPortion, проверки формата IPv4-адресов и пользовательского ввода:



# Route string VIA portion matching.
REGEXP_VIA_PORTION = re.compile(
    '.*vias+(dd?d?.dd?d?.dd?d?.dd?d?).*'
)
# RegEx template string for IPv4 address matching. 
REGEXP_IPv4_STR = (
      '((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).'
    + '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).'
    + '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).'
    + '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))'
)
# IPv4 CIDR notation matching in user input.
REGEXP_INPUT_IPv4 = re.compile("^" + REGEXP_IPv4_STR + "(/dd?)?$")


Теперь перенесем представление нашей сети на структуры данных Python.

Полученные в результате парсинга таблиц маршрутизации префиксы будем использовать в качестве ключей в префиксном дереве. Объект префиксного дерева наследуем из модуля SubnetTree.

Результат поиска по префиксу в дереве будет возвращать список из списка next-hop’ов и полного текстового представления соответствующего маршрута из оригинального файла.

Дополнительно создадим лист локальных интерфейсов.

Каждый маршрутизатор представим словарем, в который поместим вышеперечисленные данные.



# Example data structures
route_tree = SubnetTree.SubnetTree()
route_tree[’subnet’] = ((next_hop_1, next_hop_n), raw_route_string)
interface_list = ((interface_1, ip_address_1), (interface_n, ip_address_n))
connected_networks = ((interface_1, subnet_1), (interface_n, subnet_n))
router = {
    ‘routing_table’: route_tree,
    ‘interface_list’: interface_list,
    ‘connected_networks’: connected_networks,
}


Запрос к таблице маршрутизации вынесем в отдельную функцию:



def route_lookup(destination, router):
    if destination in router['routing_table']:
        return router['routing_table'][destination]
    else:
        return (None, None)


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

В результате будем добавлять получившиеся объекты маршрутизаторов в словарь с ключом в виде этого уникального идентификатора.



ROUTERS = {
    ‘router_id_1’: router_1,
    ‘router_id_n’: router_n,
}


Нам также необходим механизм для поиска следующего маршрутизатора по IP-адресу next-hop’а, полученного из таблицы маршрутизации во время поиска пути. Создадим для этого еще одно глобальное префиксное дерево с ключами в виде IP-адресов всех известных маршрутизаторов в топологии и возвращаемым по ним листам с идентификатором маршрутизатора и типом интерфейса.



# Example
GLOBAL_INTERFACE_TREE = SubnetTree.SubnetTree()
GLOBAL_INTERFACE_TREE[‘ip_address’] = (router_id, interface_type)

# Returns RouterID by Interface IP address which it belongs to.
def get_rid_by_interface_ip(interface_ip):
    if interface_ip in GLOBAL_INTERFACE_TREE:
        return GLOBAL_INTERFACE_TREE[interface_ip][0]


Соберём парсер для формата IOS/IOS-XE/ASA воедино. На входе передаем ему таблицу маршрутизации в текстовом виде, на выходе получаем словарь router в заданном выше формате.



def parse_show_ip_route_ios_like(raw_routing_table):
    router = {}
    route_tree = SubnetTree.SubnetTree()
    interface_list = []
    # Parse Local and Connected route strings in text.
    for raw_route_string in REGEXP_ROUTE_LOCAL_CONNECTED.finditer(raw_routing_table):
        subnet = (
            raw_route_string.group('ipaddress') 
          + convert_netmask_to_prefix_length(
                raw_route_string.group('maskOrPrefixLength')
            )
        )
        interface = raw_route_string.group('interface')
        route_tree[subnet] = ((interface,), raw_route_string.group(0))
        if raw_route_string.group('routeType') == 'L':
            interface_list.append((interface, subnet,))
    if not interface_list:
        print('Failed to find routing table entries in given output')
        return None
    # parse static and dynamic route strings in text
    for raw_route_string in REGEXP_ROUTE.finditer(raw_routing_table):
        subnet = (
            raw_route_string.group('subnet') 
          + convert_netmask_to_prefix_length(
                raw_route_string.group('maskOrPrefixLength')
            )
        )
        via_portion =  raw_route_string.group('viaPortion')
        next_hops= []
        if via_portion.count('via') > 1:
            for line in via_portion.splitlines():
                if line:
                    next_hops.append(REGEXP_VIA_PORTION.match(line).group(1))
        else:
            next_hops.append(REGEXP_VIA_PORTION.match(via_portion).group(1))
        route_tree[subnet] = (next_hops, raw_route_string.group(0))
    router = {
        'routing_table': route_tree,
        'interface_list': interface_list,
    }
    return router


Обернём парсер в еще одну функцию для возможности последующего добавления парсеров других форматов (например, NX-OS):



def parse_text_routing_table(raw_routing_table):
    """
    Parser functions wrapper.
    Add additional parsers for alternative routing table syntaxes here.
    """
    router = parse_show_ip_route_ios_like(raw_routing_table)
    if router:
        return router


Итак, остаётся пройтись по текстовым файлам в директории:



def do_parse_directory(rt_directory):
    new_routers = {}
    if not os.path.isdir(rt_directory):
        print("{} directory does not exist.".format(rt_directory)
            + "Check rt_directory variable value."
        )
        return None
    start_time = time()
    print("Initializing files...")
    for FILENAME in os.listdir(rt_directory):
        if FILENAME.endswith('.txt'):
            file_init_start_time = time()
            with open(os.path.join(rt_directory, FILENAME), 'r') as f:
                print ('Opening {}'.format(FILENAME))
                raw_table = f.read()
                new_router = parse_text_routing_table(raw_table)
                router_id = FILENAME.replace('.txt', '')
                if new_router:
                    new_routers[router_id] = new_router
                    if new_router['interface_list']:
                        for iface, addr in new_router['interface_list']:
                            GLOBAL_INTERFACE_TREE[addr]= (router_id, iface,)
                else:
                    print ('Failed to parse ' + FILENAME)
            print (FILENAME + " parsing has been completed in %s sec".format(
                   "{:.3f}".format(time() - file_init_start_time))
            )
    else:
        if not new_routers:
            print ("Could not find any valid .txt files with routing tables"
                 + " in {} directory".format(rt_directory)
            )
        else:
            print ("nAll files have been initialized"
                 + " in {} sec".format("{:.3f}".format(time() - start_time))
            )
            return new_routers


И, разложив все файлы в упорядоченные структуры данных, можно приниматься за вторую часть задачи.



Поиск пути по обработанным таблицам маршрутизации



В целом на этом этапе задача сводится к анализу графа сети. Маршрутизаторы являются вершинами графа, L3-линки между ними — ребрами.

Словарь ROUTERS хранит в ключах идентификаторы маршрутизаторов, а в соответствующих им значениях — ссылки на IP-адреса next-hop'ов. То есть в совокупности с GLOBAL_INTERFACE_TREE, возвращающему по IP-адресу идентификаторы маршрутизаторов, для каждой искомой подсети он определяет таблицу смежности графа.



Если же провести параллели с реальными маршрутизаторами, для поиска пути нужно воспроизвести высокоуровневую логику их работы (абстрагируясь от RIB/FIB/ASIC и прочих оптимизаций) при обработке пакета от лукапа в таблицу маршрутизации до ARP-запроса (или router_id в нашем случае) и маршрутизации или дропа пакета, в зависимости от результата.



Реализуем рекурсивный поиск пути по маршрутизаторам. Каждый участок пути будет представлен листом из router_id и raw_route_string — исходной строки маршрута на нем. Текущий путь будем записывать в кортеж path. При достижении точки назначения, отсутствии маршрута в таблице маршрутизации или next-hop'а в изученной топологии текущий path будет добавляться в результирующий кортеж paths, который и возвратит функция.



def trace_route(source_router_id, target_ip, path=[]):
    if not source_router_id:
        return [path + [(None, None)]]
    current_router = ROUTERS[source_router_id]
    next_hop, raw_route_string = route_lookup(target_ip, current_router)
    path = path + [(source_router_id, raw_route_string)]
    paths = []
    if next_hop:
        if nexthop_is_local(next_hop[0]):
            return [path]
        for nh in next_hop:
            next_hop_rid = get_rid_by_interface_ip(nh)
            if not next_hop_rid in [r[0] for r in path]:
                inner_path = trace_route(next_hop_rid, target_ip, path)
                for p in inner_path:
                    paths.append(p)
            else:
                path = path + [(next_hop_rid+"LOOP DETECTED", None)]
                return [path]
    else:
        return [path]
    return paths

def nexthop_is_local(next_hop):
    interface_types = ('Eth', 'Fast', 'Gig', 'Ten', 'Port',
                      'Serial', 'Vlan', 'Tunn', 'Loop', 'Null'
    )
    for type in interface_types:
        if next_hop.startswith(type):
            return True


Добавим функцию для запуска поиска в интерактивном режиме после инициализации текстовых файлов:



def do_user_interactive_search():
    while True:
        print ('n')
        target_subnet = raw_input('Enter Target Subnet or Host: ')
        if not target_subnet:
            continue
        if not REGEXP_INPUT_IPv4.match(target_subnet.replace(' ', '')):
            print ("incorrect input")
            continue
        lookup_start_time = time()
        for rtr in ROUTERS.keys():
            subsearch_start_time = time()
            result = trace_route(rtr, target_subnet)
            if result:
                print ("n")
                print ("PATHS TO {} FROM {}".format(target_subnet, rtr))
                n = 1
                print ('Detailed info:')
                for r in result:
                    print ("Path {}:".format(n))
                    print ([h[0] for h in r])
                    for hop in r:
                        print ("ROUTER: {}".format(hop[0]))
                        print ("Matched route string: n{}".format(hop[1]))
                    else:
                        print ('n')
                    n+=1
                else:
                    print ("Path search on {} has been completed in {} sec".format(
                           rtr, "{:.3f}".format(time() - subsearch_start_time))
                    )
        else:
            print ("nFull search has been completed in {} sec".format(
                   "{:.3f}".format(time() - lookup_start_time),)
            )


Финальный штрих для объединения двух частей:



def main():
    global ROUTERS
    ROUTERS = do_parse_directory(RT_DIRECTORY)
    if ROUTERS:
        do_user_interactive_search()

if __name__ == "__main__":
    main() 


И имеем готовый к работе код.





Проверка работы скрипта



Вооружимся небольшой абстрактной топологией из четырех Cisco CSR-1000v:



Анализ таблиц маршрутизации, или зачем ещё сетевому инженеру Python

Они соединены попарно через интерфейсы GigabitEthernet 2 и 3. Между ними установлено соседство по EIGRP, через который анонсируются все Connected сети, включая сети на Loopback интерфейсах за каждым маршрутизатором.

Между csr1000v-01 и csr1000v-04 дополнительно поднято два GRE-туннеля, через удаленные IP-адреса которых прописаны статические маршруты на сеть 10.0.0.0/8 для тестирования петли маршрутизации.











Сохраним эти выводы show ip route в отдельные файлы в суб-директории ./routing_tables/ и запустим скрипт:





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

Введем поочередно несколько вариантов и сверим полученный результат с информацией с самих маршрутизаторов.





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

Добавление комментария

Имя:*
E-Mail:
Комментарий:
Полужирный Наклонный текст Подчеркнутый текст Зачеркнутый текст | Выравнивание по левому краю По центру Выравнивание по правому краю | Вставка смайликов Выбор цвета | Скрытый текст Вставка цитаты Преобразовать выбранный текст из транслитерации в кириллицу Вставка спойлера
Введите два слова, показанных на изображении: *