Как стать автором
Обновить

Как я Anyconnect на GitLab натягивал

Время на прочтение29 мин
Количество просмотров7K

Добрый день.

Меня зовут Василий и я сетевой инженер.

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

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

Итак, поехали.


Часть 0, с чего всё началось

У нас есть несколько площадок, на одной из них стояла пара Cisco ASA 5512 в файловере, на которую подключались пользователи и ходили по другим площадкам. Всё всех устраивало, пока бОльшая часть сотрудников работала из офиса. Потом началась повсеместная удаленка и мгновенно пришло понимание, что VPN работает из рук вон плохо: с возросшей нагрузкой 5512 не справляется, контроля доступов практически нет, основной трафик идет на другую площадку и каналы не вытягивают.

В темпе вальса мы закупили несколько ASA 5515, лицензировали их и поставили на самой нагруженной, в плане пользовательских обращений, площадке, собрали в vpn load-balancing и жить стало веселее.

А потом пришли инженеры ИБ с предложением как-то управлять доступами пользователей к внутренней сети. Мы сразу остановились на функционале dynamic-access-policy, но каждый раз заходить на N железок (да еще и в разных регионах) не хотелось вообще, хотелось автоматизацию.

Часть 1, где неправильно было почти всё

Давать доступы удобно на уровне Active Directory. А еще удобнее, когда сами ACLы пишет не сетевой инженер, а техподдержка. На том и порешили - делаем какой-нибудь общий файл, даем к нему доступы отделу техподдержки и широкими мазками открываем через него сегменты нужным командам.

Очевидно, что Git в данном случае подходит лучше всего - система контроля версий, видно кто что сделал и прочий *Ops подход. Осталась самая малость - научить всех участников нашего vpn-кластера забирать конфиги из этого файла.

Так как задача выглядела тривиальной, был выбран, так называемый, Hello World автоматизации (ну почти): формируем текстовый файл с конфигом и разливаем его по устройствам, далее формируем dap.xml, так же кладем его на устройства и активируем.

О чем я вообще говорю?

Есть Cisco Anyconnect. Это такой простенький VPN-клиент, который умеет очень много всего. Одно из таких умений - это Dynamic Access Policy. Смысл довольно простой: проверяем пользователя и/или устройство на какие-нибудь соответствия, после чего навешиваем на него те политики, под которые попали пользователь или устройство. Эти соответствия могут быть совершенно разными - от группы в AD, до версии установленного антивируса.

Эффектом от политики могут быть разные штуки, но мы будем говорить про ACL. Каждая политика имеет свой ACL, если пользователь под нее подпадает, на его vpn-туннель навешивается его личный ACL. Если он подпал под несколько политик, то их ACLы складываются в один, а порядок сложения зависит от приоритета политики и еще некоторых вещей, о которых можно узнать из админ гайда.

Еще, к политикам можно прикрутить более сложную логику с помощью LUA скриптов. Если скрипт возвращает true, значит пользователь под политику подпадает (а еще там есть всякие галки типа AND и OR, но мы не будем про них говорить).

Политика в нашем случае строится из трёх сущностей:

1) Создание политики командой dynamic-access-policy, в ней мы говорим на какой ACL смотрим и что делаем (продолжаем или терминируем подключение)

2) ACL, на который смотрит политика.

3) Файл dap.xml, в котором содержатся привязки группы на ASA к группе в AD, а также LUA скрипты с какой-либо сложной логикой.

При этом, не важно через что аутентифицируются клиенты(радиус или saml), авторизовать их всегда можно через AD, а значит и вытащить все группы, в которых состоит пользователь, а так же его LDAP атрибуты.

Чтобы всё было максимально просто и по фен-шуй, делаем YAML файл, содержащий название DAP-группы, как ключ, и список ACE, как значение:

Group_1:
	- permit ip any 192.168.1.0 255.255.255.0
	- permit tcp any 192.169.2.0 255.255.255.0 eq 5432

Group_2:
	- permit tcp any 192.169.2.0 255.255.255.0 eq 5432
	- permit tcp any 10.1.1.1 255.255.255.255 eq 22

Ну и так далее.

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

Без проблем, делаем import re, составляем список регексов под проверку синтаксиса каждого возможного варианта:

regexs = ["(permit)\s(tcp|icmp|udp|ip)\s(any)\s(any)$",
"(permit)\s(tcp|icmp|udp)\s(any)\s(any)\s(eq)\s(\d+)$",
"(permit)\s(tcp|icmp|udp|ip)\s(any)\s(host)\s(\d+\.\d+\.\d+\.\d+)$",
"(permit)\s(tcp|icmp|udp|ip)\s(any)\s(\d+\.\d+\.\d+\.\d+)\s(\d+\.\d+\.\d+\.\d+)$",
"(permit)\s(tcp|icmp|udp)\s(any)\s(host)\s(\d+\.\d+\.\d+\.\d+) (eq) (\d+)$",
"(permit)\s(tcp|icmp|udp)\s(any)\s(host)\s(\d+\.\d+\.\d+\.\d+) (range) (\d+) (\d+)$",
"(permit)\s(tcp|icmp|udp)\s(any)\s(\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+) (eq) (\d+)$",
"(permit)\s(tcp|icmp|udp)\s(any) (eq) (\d+)\s(\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+)$",
"(permit)\s(tcp|icmp|udp)\s(any) (eq) (\d+)\s(host)\s(\d+\.\d+\.\d+\.\d+)$",
"(permit)\s(tcp|icmp|udp)\s(any)\s(\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+) (range) (\d+) (\d+)$",
"(permit)\s(tcp|icmp|udp)\s(any)\s(range) (\d+) (\d+)\s(\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+)$",
"(permit)\s(tcp|icmp|udp)\s(any)\s(range) (\d+) (\d+)\s(host) (\d+\.\d+\.\d+\.\d+)$",
]

Прогоняем каждую строчку через этот массив и если хоть что-то сматчилось - хорошо, если нет - плохо и надо обратить на это внимание. Корректность IP/Маска проверяем через библиотеку ipaddress.

На выходе получается словарь, где ключом выступает AD-группа (она же DAP-группа), а значением набор ACE. Из этого словаря мы можем сформировать настоящий ACL, после чего сходить на ASA, сделать diff, из которого поймем, что надо добавить, а что удалить.

Далее нам нужно переложить всё это в сами DAP политики: берем ключи этого словаря и реплейсом подсовываем их в темплейт

dynamic-access-policy-record _FOR_REPLACE_
 network-acl _FOR_REPLACE_

Готово. Следующий, и последний шаг - собрать dap.xml, где будут прописаны привязки AD-групп к DAP-группам. Делаем функцию, которая обернет каждую группу в XML, подставляем туда недостающие строки и убираем пробелы:

from lxml import etree

def createPolicy(policies):
    root = etree.Element('dapRecordList')
    for policy in policies:
        tree = etree.ElementTree(root)
        dapRecord = etree.SubElement(root, 'dapRecord')
        dapName = etree.SubElement(dapRecord, 'dapName')
        dapNameValue =  etree.SubElement(dapName, 'value')
        dapNameValue.text = policy
        dapViewsRelation = etree.SubElement(dapRecord, 'dapViewsRelation')
        dapViewsRelationValue =  etree.SubElement(dapViewsRelation, 'value')
        dapViewsRelationValue.text = 'and'
        dapBasicView = etree.SubElement(dapRecord, 'dapBasicView')
        dapSelection = etree.SubElement(dapBasicView, 'dapSelection')
        dapPolicy = etree.SubElement(dapSelection, 'dapPolicy')
        dapPolicyValue =  etree.SubElement(dapPolicy, 'value')
        dapPolicyValue.text = 'match-all'
        attr = etree.SubElement(dapSelection, 'attr')
        attrName = etree.SubElement(attr, 'name')
        attrName.text = 'aaa.ldap.memberOf'
        attrValue = etree.SubElement(attr, 'value')
        attrValue.text = policy
        attrOperation = etree.SubElement(attr, 'operation')
        attrOperation.text = 'EQ'
        attrType = etree.SubElement(attr, 'type')
        attrType.text = 'caseless'
    return root
  
xmlText = etree.tostring(xml, xml_declaration=True, encoding='UTF-8', standalone=True, pretty_print=True).decode('utf-8')
xmlText = xmlText.replace(' ','')
xmlText = xmlText.replace('<?xmlversion=\'1.0\'encoding=\'UTF-8\'standalone=\'yes\'?>', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')

Нормально, но есть пара проблем:

Проблема 1: Может случится ошибка или опечатка, где название группы в YAML не будет соответствовать группе в AD, никто этого не заметит, а работать не будет.

Решение: В начале pipeline делать ldapsearch, забирать из AD все группы и проверять, все ли группы в ямле присутствуют в AD.

Проблема 2: ASA автоматом исправляет некоторые порты в ACL с цифр на буквы, например: tcp/80 > www, tcp/22 > ssh, udp/53 > domain и так далее.

Решение: Собираем словарь протокол/цифра/имя, выдергиваем из каждого ACE порт и прогоняем его через словарь, который выглядит следующим образом:

{
'tcp': {'5190' : 'aol', '179' : 'bgp', '3020' : 'cifs',
        '1494' : 'citrix-ica', '514' : 'cmd', '2748' : 'ctiqbe',
        '13' : 'daytime', '9' : 'discard', '53' : 'domain',
        '7' : 'echo', '512' : 'exec', '79' : 'finger',
        '21' : 'ftp', '20' : 'ftp-data', '70' : 'gopher',
        '1720' : 'h323', '101' : 'hostname', '80' : 'http',
        '443' : 'https', '113' : 'ident', '143' : 'imap4',
        '194' : 'irc', '543' : 'klogin', '544' : 'kshell',
        '389' : 'ldap', '636' : 'ldaps', '513' : 'login',
        '1352' : 'lotusnotes', '515' : 'lpd', '139' : 'netbios-ssn',
        '2049' : 'nfs', '119' : 'nntp', '5631' : 'pcanywhere-data',
        '496' : 'pim-auto-rp', '109' : 'pop2', '110' : 'pop3',
        '1723' : 'pptp', '514' : 'rsh', '554' : 'rtsp', '5060' : 'sip',
        '25' : 'smtp', '1522' : 'sqlnet', '22' : 'ssh',
        '111' : 'sunrpc', '49' : 'tacacs', '517' : 'talk',
        '23' : 'telnet', '540' : 'uucp', '43' : 'whois', '80' : 'www'}
'udp': {'53' : 'domain', '514' : 'rsh', '554' : 'rtsp', 
        '80' : 'www', '137' : 'netbios-ns', '5060' : 'sip', '123' : 'ntp'	}
}
Эээ, GitLab?

Здесь предполагается, что читатель знает что такое GitLab и CI.

В общих чертах, GitLab - это git со всякими штуками. Среди этих штук есть функционал, так называемых пайплайнов (pipelines). Пайплайн - это некая последовательность задач, где задачи либо не зависят друг от друга и выполняются параллельно, либо где где зависят и выполняются одна за одной. При этом запускаться они могут в ручную или самостоятельно. А еще они могут передавать между друг другом файлы и называется это артефактами.

С точки зрения операциониста, вся работа выглядит следующим образом:

1) Открыли IDE/веб-страницу.

2) Нажали 2-3 кнопки.

3) Отредактировали yaml-файл по аналогии, нажали еще кнопок.

4) Почитали что написано на экране, подумали, нажали кнопку.

5) Попросили аппрув, чтобы нажать последние пару кнопок, нажали их.

Всё.

В целом это вся логика, далее идем к админам, просим gitlab-runner и пустую репу, собираем контейнер, кладем в registry, и пишем CI состоящий из шагов:

  • Build, где мы проверяем валидность ямла, группы в AD и собираем три текстовых файла: ACL, блоки DAP политик и файл dap.xml, через артефакты передаем их в следующие джобы.

  • Check, где мы берем артефакты из Build, пробегаем по всем фаерволлам в кластере и сравниваем боевые ACLы со сформированными. Выводим diff на экран. Если у нас появился или пропал какой-то ACL целиком - значит в наш diff попадет еще и создание/удаление DAP политики.

  • Deploy - делаем всё тоже самое, но с отправкой конфигурации. Удаляем/создаем ACE, удаляем/создаем ACL, которые тянут за собой редактирование блоков dynamic-access-policy, которые тянут за собой заливку dap.xml через SCP.

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

Шишка с DNS

ASA не умеет делать ACL с FQDNами для DAP и это напрягает. Один отдел может перенести какой-то популярный сервис за другой nginx, никто об этом не узнает и не поменяет ip-адреса в гите, в итоге куча людей не сможет зайти по новому адресу, а техподдержка получит на утро кучу одинаковых заявок.

Решение: добавляем в конец ACE комментарий с FQDN указанного IP адреса, после чего пишем небольшой скрипт, который будет:

а) Обходить весь ямл, выдергивая комментарии regex`ом, и резолвить их во внутреннем сервисе (у одной из команд, по удачному стечению обстоятельств, нашелся сервис, который обходит все DNSы и забирает себе записи с внутренних доменов, эта команда великодушно сделала api-ручку, которую можно использовать для резолвинга, спасибо им).

б) Проходить ямл еще раз и проверять соответствует ли IP адрес в ACE адресу в словаре из прошлого пункта, если нет - собирать новую строку с правильным IP и реплейсом заменять на нее неправильную.

в) Создавать новый бранч с изменениями, подготавливать мердж реквест и рапортовать в слак, чтобы кто-нибудь просмотрел изменения и нажал кнопку Deploy.

Добавляем этот скрипт в крон и теперь будем сразу знать если у какого-то FQDN поменяется IP адрес.

Шишка с аппрувами

У нас бесплатный GitLab и встроенные аппрувы там не очень.

Пишем еще один скрипт, который включает в себя списки с инженерами ИБ, инженерами ТП и ходит в API гитлаба проверять кто merge request создал и кто его заапрувил.

Вставляем его в джобу, получается следующая логика:

Человек создает MR, на шаге с Check проверяет актуальность изменений, жмет деплой и он падает. В это время в выделенный слак-канал падает сообщение вида “Привет <список ИБ>, смотрите что делает <создавший MR инженер> в <ссылка на дифф гитлаба>”.

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

Шишка с HostScan

Как говорится, аппетит приходит во время еды, а именно “Давайте проверять обновления на пользовательских ПК и рапортовать в техподдержку в случае отсутствия нужных патчей”.

Эдакий Posturing без ISE, давайте. Создаем LUA скрипт, который будет смотреть HostScan атрибуты и, если ПК не соответствует, возвращать true.

Пример:

assert(function()
    function windows()
        if  ( EVAL(endpoint.anyconnect.platform, "EQ", "win", "string")) then
            return true
        end
    end
    
    function hotfix()
        if (
            EVAL(endpoint.os.hotfix["KB_1"],"EQ","true", "string") or
            EVAL(endpoint.os.hotfix["KB_2"],"EQ","true", "string") or
            EVAL(endpoint.os.hotfix["KB_3"],"EQ","true", "string")
            ) then
                return true
            else
                return false
            end
    end
    
    if (windows())then
        if (
            hotfix() == true
        )then
            return false
        else
            return true
        end
    end
    
end)()

Данный код смотрит ОС пользователя и, если это Windows, проверяет наличие KB по списку. Если проверка не пройдена - возвращает true, на человека прилетает особая DAP политика и ее наличие означает что ПК без обновлений.

Теперь надо научится класть этот код на ASA. Для этого вставляем в функцию собирающую dap.xml следующий блок в определенное место:

    if policy == "<особая группа с проверками>":
            advancedView = etree.SubElement(dapRecord, 'advancedView')
            advancedViewValue =  etree.SubElement(advancedView, 'value')
            advancedViewValue.text = '_REPLACE_HERE_'

После чего реплейсим '_REPLACE_HERE_' на lua-скрипт.

Где KB - там и всё остальное: антивирусы, софт, ключи реестров и так далее. Скрипт начал распухать и пришлось писать парсеры логов на syslog-сервере, это было неудобно, но поделать что-то с этим было тяжело. Все смирились.

Шишка с запрещающими правилами

Со временем появились требования “закрыть всё, кроме”. Руками это обычно делается примерно так:

access-list myACL line 1 extended permit tcp any host 10.0.0.1 eq 22
access-list myACL line 2 extended deny ip any 10.0.0.0 255.255.255.0

Но, как можно догадаться, порядок ACE в репозитории не бьется с порядком ACE на ASA. Об этом не подумали в самом начале, поэтому приходилось вручную обходить все МСЭ и вставлять в нужные места запрещающие правила. Требования были не очень частые, но всё равно ручные действия напрягали. После этих изменений нужно было сходить в репозиторий и вставить туда эти правила с deny`ями, чтобы их не затер следующий деплой. С этим смирились тоже.

Часть 2, где всё сожгли и сделали заново

Так мы жили больше года, со временем dap.yaml распух до, почти, 2 тысяч строк. Общее количество политик было около сотни и мы начали в них тонуть. Дополнительно, за год собралось несколько фичреквестов, а именно:

  1. Object-group`ы. Есть, например, 10 контроллеров домена. На каждый контроллер нужно открыть примерно 10 портов. На выходе имеем 10*10 строк ACE - это не очень.

  2. Наследование. На среднего разработчика нужно накинуть минимум штук 5 обязательных групп и еще столько же опциональных. Было бы здорово сразу говорить, что группа А включает в себя группы Б и В. Добавить группу в группу не работает, так как memberOf групп ASA не умеет.

  3. Пачки портов. Если вынести порты в отдельное поле, будет лучше, так как вместо

permit tcp any host A eq 22
permit tcp any host A eq 23

веселее писать

permit tcp any host A eq [22,23]
  1. Поддержка очередности ACE - хочется вставлять deny в нужные места по флоу, а не руками.

  2. Знать какие именно проверки не прошел пользователь. Со временем образовалась куча проверок и все в одном скрипте. Так как мы не нашли возможность писать в лог из LUA (debug-trace, пожалуйста, не предлагайте), то без хождения на syslog мы не знаем точную причину, почему человек зафейлил проверки.

  3. Проверка команд из шага с diff`ом в деплое. Бывали случаи, что инженер ТП сделал MR и отправил на согласование. Diff ему показался адекватный, но пока он собирал аппрувы в слаке, коллега выкатил изменения из другого бранча и замержил в мастер. Деплой эти изменения в репозитории ожидаемо не видит и перетирает. В итоге доступ вроде как открыл, но он не работает.

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

Писать всё на чистом python, как выяснилось, довольно утомительно. Еще утомительней это поддерживать и допиливать. Поэтому было принято решение переписать всё с нуля, набело, да еще и на Nornir.

Новый dap.yaml будет иметь такой формат:

DAP_Group_1:
  rules:
    - {line: 1, action: permit, proto: tcp, net: 10.0.0.1/32,
    	ports: ['10’,’20’,’30’,’40-60’], fqdn: 'our_server.domain.local.'}

Формат object-groups.yaml:

Server_pack_1:
  - {net: '10.0.1.1/32', fqdn: 'server_1.domain.local.'}

Подготовка

Сначала нам нужно всё-всё проверить, после чего переделать наши ямлы в конфиги, докинув туда все поля и подставив дефолтные значения (например приоритет группы).

Делаем еще один ямл с неким “скелетом” ключей и полями по умолчанию. Прогоняем все строки с правилами через этот ямл, проверяя поля на указанный в нем regex и подставляя дефолтовые значения в случае отсутствия каких-то ключей


# Это все возможные варианты параметров с вариантами их значений по умолчанию.

# dap.yaml
acl_regex:
  line: (^\d+$)
  action: (^permit$|^deny$)
  proto: (^tcp$|^udp$|^icmp$|^ip$)
  source: ((((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/([1-9]|[12][0-9]|3[0-2])$)|any)
  source_ports: (^\d+$|^\d+-\d+$) # List
  net: ((((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/([1-9]|[12][0-9]|3[0-2])$)|any)
  object: (^\S+$)
  ports: (^\d+$|^\d+-\d+$) # List
  fqdn: (^\S+\.$)

acl_defaults:
  line: 9999999
  action: ''
  proto: ''
  source: 'any'
  source_ports: []
  net: ''
  object: ''
  ports: []
  fqdn: ''

dap_policies_defaults:
  priority: 50
  action: ''
  childrens: []
  ad_group: ''
  lua_script: ''
  lua_script_content: ''

regex_objects:
  net: ((((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/([1-9]|[12][0-9]|3[0-2])$))
  fqdn: (^\S+\.$)

Далее можно озаботиться написанием кучи assert`ов. Проверяем буквально всё: наличие ключей, отсутствие ключей, использование несовместимых между собой ключей (например, правило не может иметь поля object и net одновременно, либо ACE на группу, либо на ip), группы в AD, наличие в отдельной папке прилинкованных к политикам LUA-скриптов, номера портов, адреса и маски, и так далее - список можно продолжать довольно долго.

Следующий шаг - переделка ямлов в cli-конфиги. Жонглируем питоном и словарями, на выходе имеем два файла: конфиги для ACL и конфиги для DAP политик

На выходе получаем такого рода файл с ACL:

DAP_Group_1:
  access-list nr_ACL_DAP_Group_1 extended permit icmp any any: '1'
  access-list nr_ACL_DAP_Group_1 extended deny ip any 192.168.0 255.255.255.0: '2'
  access-list nr_ACL_DAP_Group_1 extended permit tcp any host 192.168.1.1 eq 22: null
  access-list nr_ACL_DAP_Group_1 extended permit ip any any: null
DAP_Group_2:
 ....

Можно заметить, что ключом выступает ACE, а значением либо цифра, либо null. Цифра забирается из поля line. Если оно отсутствует - кладем в него 9999999 из дефолтов, далее сортируем список от малого к большому и заменяем 9999999 на null. Это нужно чтобы в дальнейшим сравнивать номер линии с реальной и двигать правила в нужном нам порядке. Еще я бы рекомендовал приклеивать к ACL префиксы, чтобы при дальнейшем сравнении не затрагивать другие ACL, которые не имеют отношения к DAP политикам.

Так будет выглядеть файл с DAP политиками:

DAP_Group_1:
  rules: []
  priority: 50
  action: ''
  childrens:
  - nr_ACL_DAP_Group_1
  ad_group: DAP_Group_1
  lua_script: ''
  lua_script_content: ''
DAP_Group_2:
	...
  • action в данном случае означает action DAP политики, он может быть continue\quarantine\terminate. По умолчанию он continue и в show run не показывается (только в show run all) , отсюда и пустое значение.

  • childrens - это ACLы, которые будут привязываться к группе. По умолчанию подставляем туда префикс ACL + имя политики, а если поле childrens заполнено в ямле - аналогично, но с добавлением префикс + имена всех политик из этого поля. С помощью этого, мы можем добиться аналога наследования: при добавлении пользователя в группу с childrens, на него будет прилетать ACL этой группы плюс всех групп, указанных в этом поле.

  • ad_group - на какую AD группу мы будем привязывать политику, по умолчанию оно берется из названия группы, если не указано иное.

  • lua_script - имя файла LUA-скрипта из соседней директории.

  • lua_script_content - содержимое этого файла (заполняется автоматически из файла в lua_script).

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

С подготовкой покончено, можем приступать к самому интересному, а именно к проверке и деплою конфигураций с помощью Nornir.

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

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

Делаем следующую функцию:

def is_alive(nr):
    def show_version(task):
        r = task.run(task=netmiko_send_command, command_string='show version')
    result = nr.run(task=show_version)
    for host in result.keys():
        if result[host].failed:
            colors.print(f'{host} [red]FAILED[/red]')
        else:
            colors.print(f'{host} [green]OK[/green]')

Если хост не отдаст show version, он пометится как failed, далее можем посмотреть есть ли у нас зафейленные хосты (if len(nr.data.failed_hosts) > 0) и прекратить выполнение.

Далее, можно начать обработку object-group. Сделаем общую для всех функцию, куда будем добавлять, так называемые, функции-действия. В данном случае их пока будет три: загружаем текущее с устройства, сравниваем сами группы, сравниваем наполнение групп. Вот первая:

def main(task):
    download_object_groups(task)
    compare_object_groups(task)
    compare_object_groups_nets(task)

def download_object_groups(task):
    r = task.run(task=netmiko_send_command,
                    command_string=f"show run object-group network",
                    name='Downloading object-groups from devices',
                    use_textfsm = True,
                    severity_level=logging.DEBUG)
    text.host[“textfsm_objects”] = r.result
...
А это то что?

На этом моменте предполагается, что читатель знает что такое TextFSM, genie и как их использовать.

Про первое и многое другое можно почитать здесь.

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

В случае обычного netmiko всё тоже самое: ...send_command("...", use_genie=True)

TextFSM:

Value Filldown,Required NAME (\S+)
Value List NETWORK (\d+\.\d+\.\d+\.\d+\s+\d+\.\d+\.\d+\.\d+)
Value List HOST (\d+\.\d+\.\d+\.\d+)

Start
  ^object-group -> Continue.Record
  ^object-group\s+network\s+${NAME}\s*
  ^\s+network-object\s+${NETWORK}
  ^\s+network-object\s+host\s+${HOST}
End

На первом шаге (download_object_groups) у нас получится список словарей (спасибо TextFSM) с информацией о настроенной object-group`ах, которые мы сравним с object-groups.yaml. На выходе после всех трёх шагов получаем некие списки того, что добавилось, что удалилось и что на что нужно поменять, у меня получились следующие переменные:

task.host['objects_to_add'] - группы объектов на создание (имена)
task.host['objects_to_del'] - группы объектов на удаление (имена)
task.host['nets_to_add'] - объекты в группах на создание
task.host['nets_to_del'] - объекты в группах на удаление

После этого, можем идти описывать темплейт на Jinja2:

{######### Object-Groups #########}
{# Создание Object-group #}
{% if host.objects_to_add is defined and host.objects_to_add|length>0 %}
!
!!! Adding object-groups
!
{% for object_group in host.objects_to_add %}
object-group network {{object_group}}
{% for net in host.configured_object_groups[object_group] %}
 network-object {{ net }}
{% endfor %}
{% endfor %}
!
{% endif %}
{# Добавление вхождений Object-group #}
{% if host.nets_to_add is defined and host.nets_to_add|length>0 %}
!
!!! Adding new object-groups entries
!
{% for object_group in host.nets_to_add %}
object-group network {{ object_group }}
{% for net in host.nets_to_add[object_group]%}
 network-object {{ net }}
{% endfor %}
{% endfor %}
!
{% endif %}
{# Удаление вхождений Object-group #}
{% if host.nets_to_del is defined and host.nets_to_del|length>0 %}
!
!!! Deleting not discribed object entries
!
{% for object_group in host.nets_to_del %}
object-group network {{ object_group }}
{% for net in host.nets_to_del[object_group]%}
 no network-object {{ net }}
{% endfor %}
{% endfor %}
!
{% endif %}
{##}

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

Конфиг на рендеринг темплейта и деплой:

def build_from_template(task):
    task.host['commands'] = list()
    r = task.run(task=template_file,
                path=f'./j2_templates/',
                template='object-groups.j2',
                name='Building Object Groups configuration from template...',
                severity_level=logging.INFO)
    output = r.result.splitlines()
    task.host['commands'] = output

def deploy_config(task):
    if len(task.host['commands']) > 0:
        task.run(task=netmiko_send_config,
                config_commands=task.host['commands'],
                name='Applying Object Groups configuration....',
                severity_level=logging.INFO)

В целом это всё, что нужно для приведения состояния конфигов object-group к ямлу.

Делаем то же самое для ACL. На данном шаге нам не пригодятся ни TextFSM, ни genie, благодаря переформатированию из подготовки, у нас уже есть сформированный словарь с названием групп и самими ACL, поэтому берем нехитрый regex и забираем все ACL с нашим префиксом (опять же, чтобы не трогать ACLы для других задач). Далее собираем словарь с этими данными и номерами линий:

regex_acl = re.compile('access-list (\S+) line (\d+) extended (.+)\s(\(hitcnt.+)')

def download_acls(task):
    r = task.run(task=netmiko_send_command,
                    command_string=f"show access-list | i ^access-list {acl_prefix}",
                    name='Collecting access-list....',
                    use_timing = True,
                    severity_level=logging.DEBUG)
    task.host['current_raw_acls'] = r.result.splitlines()
    task.host['acl_map'] = dict()
    for ace in task.host['current_raw_acls']:
        if len(regex_acl.findall(ace)) > 0:
            acl_name, ace_line, ace_rule, _ = regex_acl.findall(ace)[0]
            policy_name = acl_name.replace(acl_prefix, '')
            rule_from_config = f'access-list {acl_name} extended {ace_rule}'
            task.host['acl_map'].setdefault(policy_name, dict())
            task.host['acl_map'][policy_name][rule_from_config] = ace_line

Следующим шагом сравниваем наш acl_map c текущим конфигом, жонглируем номерами строк и получаем списки того, что нужно удалить/создать. Например, так выглядит словарь, из которого будут создаваться новые правила, и в которых присутствуют номера line:

ipdb> print(nr.inventory.hosts['my_host']['ace_to_add'])

{
    'Group_1': [
        'access-list nr_ACL_Group_1 line 1 extended permit tcp any any eq domain',
        'access-list nr_ACL_Group_1 line 2 extended permit tcp any any eq 5353',
        'access-list nr_ACL_Group_1 extended permit udp any any eq 5353',
        'access-list nr_ACL_Group_1 extended permit udp any any eq ntp'
    ]
}

Совет 1: Настоятельно рекомендую использовать модуль ipdb, с его помощью можно в любой момент провалиться в дебаг и “на горячую” смотреть переменные хостов, как в примере выше.

Совет 2: Так как существует разница конфигурации ACL c линиями, лучше сначала применять правила, в которых очередность указана, и при этом начинать с самой маленькой цифры. Благодаря этому, правила сразу встанут в нужный вам порядок.

Когда с ACL покончено, осталась всего одна сущность - DAP политики.

По такой же логике, что и с object-group`ами, сравниваем блоки dynamic-access-policy. TextFSM:

Value Filldown,Required NAME (\S+)
Value List CHILDRENS (\S+)
Value ACTION (\S+)
Value PRIORITY (\d+)

Start
  ^dynamic-access-policy-record  -> Continue.Record
  ^dynamic-access-policy-record\s${NAME}
  ^\s+network-acl\s+${CHILDRENS}
  ^\s+action\s+${ACTION}
  ^\s+priority\s+${PRIORITY}
End

Следующим шагом, в любом случае, формируем dap.xml.

Темплейт Jinja2 представлен ниже (в переменной host['yaml_daps'] лежит ямл нашего файла c DAP политиками из шагов подготовки):

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<dapRecordList>
{% for policy in host['yaml_daps'] %}
<dapRecord>
<dapName>
<value>{{ policy }}</value>
</dapName>
<dapViewsRelation>
<value>and</value>
</dapViewsRelation>
{% if host['yaml_daps'][policy]['lua_script_content'] is defined and host['yaml_daps'][policy]['lua_script_content']|length>0 %}
<advancedView>
<value>{{ host['yaml_daps'][policy]['lua_script_content']}} </value>
</advancedView>
{% endif %}
<dapBasicView>
<dapSelection>
<dapPolicy>
<value>match-all</value>
</dapPolicy>
<attr>
<name>aaa.ldap.memberOf</name>
<value>{{ host['yaml_daps'][policy]['ad_group'] }}</value>
<operation>EQ</operation>
<type>caseless</type>
</attr>
</dapSelection>
</dapBasicView>
</dapRecord>
{% endfor %}
</dapRecordList>

Аналогично предыдущему шагу, рендерим и кладем его в переменную task.host['xml'] и в артефакты, после чего скачиваем такой же с устройства, перекладываем в словарь, сравниваем, и в случае несовпадения, создаем флаг о том, что нужна перекатка:

def download_and_compare_xml(task):
    task.host['need_xml'] = 0
    xml_on_device = task.run(task=netmiko_send_command,
        										command_string='more disk0:/dap.xml',
        										name='Downloading XML file',
        										use_timing = True,
        										severity_level=logging.DEBUG)
    task.host['xml_on_device'] = xml_on_device.result
    task.host['xml_on_device_dict'] = json.loads(json.dumps(xmltodict.parse(task.host['xml_on_device'])))
    task.host['xml_dict'] = json.loads(json.dumps(xmltodict.parse(task.host['xml'])))
    if task.host['xml_dict'] != task.host['xml_on_device_dict']:
        colors.print(f'[gold1]!\ndap.xml на [magenta]{task.host}[/magenta] не соответствует сформированному, необходим деплой\n![/gold1]')
        task.host['need_xml'] = 1

Итого, если на шаге планирования изменений на каком-то устройстве dap.xml будет не соответствовать описанному, мы получим необходимый флаг и готовый xml в директории с артефактами, загрузить его мы можем через scp (будет не лишним добавить сюда бекап старого файла и активацию нового командой dynamic-access-policy-config activate):

def upload_file(task):
    upload = task.run(task=netmiko_file_transfer,
											source_file=f'{artifacts}/{task.host}_dap.xml',
											dest_file='dap.xml',
											file_system='disk0:/',
											overwrite_file = True,
											name='Uploading XML File...',
											severity_level=logging.INFO)

В целом, в плане кода это всё, и мы можем приступать к созданию Dockerfile и CI, но сначала хочу обратить ваше внимание на несколько нюансов:

Сохранять итоги темплейтов в артефакты

Это принесет пользу в том плане, что позволит нам проверять актуальность планируемых команд на шаге с деплоем, на случай если кто-то рядом выкатил другую ветку, а мы не сделали rebase. Для этого сортируем task.host['commands'] и сохраняем в папку с артефактами с именем устройства (что-нибудь типа f’{task.host}_commands’), на шаге с деплоем аналогично, но без сохранения. Вместо этого сравниваем текущую переменную с артефактом, и если они разняться, значит явно что-то пошло не так и следует перезапустить план и еще раз на него посмотреть.

Использовать аргументы запуска

Так как по сути, план и деплой - это одно и то же действие (за исключением самого процесса деплоя), будет полезно использовать атрибуты запуска. Сделать это можно через библиотеку argparse, после чего обрабатывать дефолтные и заданные аргументы в коде. Например, мы можем разбить функцию main(), где содержится вообще всё, на две: всё, что про загрузки/сравнение/темплейты, и на ту, где происходит только деплой.

plan = nr.run(task=plan_and_prepare, name='Plan function...', severity_level=logging.DEBUG)
print_result(plan, severity_level=log_level)

if args.deploy == True:
    deploy = nr.run(task=deploy, name='Deploy function...', severity_level=logging.DEBUG)
    print_result(deploy, severity_level=log_level)

А между ними сравнивать актуальность планируемых на ввод в CLI команд.

Глобальные флаги и exit code

Есть смысл использовать какую-нибудь глобальную переменную, чтобы понимать, что изменения вообще планируются. Логика следующая: в начале выполнения объявляем какой-нибудь changes_falg = 0, и если планируем хоть что-то делать, то выставляем его в 1. В самом конце говорим “если это dry run и флаг == 1 - пиши в консоль что изменения планируются и завершайся с особым exit code”, далее этот exit code мы сможем обработать в CI и завершить джобу с варнингом или ошибкой. Варнинг привлечет внимание, а ошибка не позволит двигаться дальше по пайплайну.

Что тут может случиться

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

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

FROM alpine:3.15

RUN set -x \
   && apk --no-cache add bash curl jq \
                        python3 py3-pip python3-dev py3-paramiko py3-yaml \
                        openldap-clients libxml2 libxslt-dev libxml2-dev build-base git openssh \
                        build-base linux-headers py3-grpcio \
   && pip3 install --upgrade pip \
   && pip3 install --upgrade wheel \
   && pip3 install --upgrade setuptools \
   && pip3 install pyats \
   && pip3 install nornir nornir_utils nornir_napalm nornir-netmiko nornir-paramiko \
   && pip3 install nornir-jinja2 nornir-scrapli napalm-asa rich \
   && pip3 install genie ipdb ttp ipaddress tqdm python-gitlab

(если хотим использовать ldaps при подключении к AD, можно через COPY подложить ldap.conf и сертификат в нужные места)

Пишем CI, определим якоря для последующих вызовов:

.base_connection: &base_connection
  - cp ./some_folder/base_connection.py /usr/lib/python3.9/site-packages/netmiko/base_connection.py
 
.template_plan: &template_plan
  before_script:
    - *base_connection
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: never
    - changes:
        - dap/*
        - inventory/*
        - lua_scripts/*
  artifacts:
    when: always
    paths:
      - ./nornir.log
      - ./artifacts/*
 
.template_deploy: &template_deploy
  before_script:
    - *base_connection
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: never
    - when: manual
  allow_failure:
    exit_codes:
      - 127
  artifacts:
    when: always
    paths:
      - ./nornir.log
      - ./artifacts/*

В этом куске мы перекладываем некий файл с дефолтами netmiko, чтобы не сталкиваться с ошибкой netmiko.ssh_exception.NetmikoTimeoutException: Paramiko: 'No existing session' error: try increasing 'conn_timeout' to 10 seconds or larger, а также говорим, что exit code 127 - это допустимо и следует завершаться со статусом Warning, привлекая внимание. Сам же exit-code спускаем из python, в случаях когда это необходимо (например, в случаях планируемых изменений).

Ну и примерно так будут выглядеть сами джобы:

stages:
  - check
  - plan
  - approves
  - deploy
 
Check:
  <<: *template_plan
  stage: check
  script:
    - mkdir ./artifacts
    - python3 -u ./check_syntax.py
    - python3 -u ./make_file_acls.py
    - python3 -u ./make_file_dap.py
 
Plan:
  <<: *template_plan
  stage: plan
  script:
    - python3 -u nornir_script.py
  needs:
    - job: Check
  allow_failure:
    exit_codes:
      - 127
 
Approves:
  <<: *template_deploy
  stage: approves
  script:
    - python3 -u ./approves.py
  needs:
    - job: Plan
 
Deploy:
  <<: *template_deploy
  stage: deploy
  script:
    - python3 -u ./nornir_script.py --no-dry-run
  needs:
    - job: Approves

Всё.

Мониторинг

Мониторинга здесь в целом три.

Процессор/память/количество подключений - стандартно через SNMP, здесь ничего интересного.

Syslog

Syslog со всеми сообщениями про атрибуты HostScan и логами о сетевых сессиях клиентов улетает в GrayLog, где сообщения разбиваются по полям и заполняют собой дашборд с информацией кто подключался, когда, куда заходил, сколько трафика сгенерировал, под какие DAP группы попал(благодаря полям lua_script и ad_group, мы теперь можем делать много маленьких политик, вешать их на одну группу AD, и видеть какие именно проверки HostScan не прошел пользователь), с какого компьютера/города пользователь, что у него на компьютере установлено, какие файлы и сертификаты лежат. В общем всё, что можно вытащить через логи.

Тут тоже есть нюанс: при отправке большого количества стоит периодически посматривать в статистику sho log queue и show log - из-за небольшого размера очереди по умолчанию (logging queue) есть риск столкнуться с дропами логов, иногда нужно будет расширять очередь.

Prometheus.

Здесь мы забираем вывод show vpn-session detail anyconnect, парсим его и отрисовываем в метриках статистику по версиям Anyconnect, платформам, соотношением клиентов на DTLS/TLS, количеству дропнутых логов и так далее.

Способов тут не так что бы очень много: складывать метрики в текстовом виде на диск и далее читать оттуда данные через node-exporter, либо написать свой маленький экспортер, который при обращении на него через веб будет бегать на железо и отрисовывать те же метрики в вебе.

Сюда же можно вынести парсер и алерт на скорое истечение сертификатов, ASP дропы, а актуальность правил из dap.yaml можно также проверять следующим способом:

  1. Раз в N времени обходим все МСЭ, забираем всё из вывода show access-list | access-list DAP-ip-user.

  2. Схлопываем одинаковые правила в ключ, после чего складываем хиткаунты всех таких одинаковых правил на всех фаерволлах. На выходе получаем словарь, где ключом служит ACE (например, permit any host 10.0.0.1 eq 22), а значением суммарное количество хиткаунтов такого правила на всех фаерволлах.

  3. Отдаем в Prometheus. По крону обновляем информацию и видим, какие правила явно пользуются популярностью, а какие уже не являются актуальными и туда никто не ходит.

В итоге

В итоге компания получила легко и понятно конфигурируемый VPN, для изменения которого не требуется каждый раз ходить к сетевику, а сетевик получил немного больше времени для занятия чем-то более интересным, чем по несколько раз (или десятков раз) в день обходить кучу ИБшников и устройств для закрытия совершенно одинаковых заявок.

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

Спасибо.

Теги:
Хабы:
Всего голосов 12: ↑11 и ↓1+10
Комментарии11

Публикации