Добрый день.
Меня зовут Василий и я сетевой инженер.
В данной статье хочу немного рассказать вам про то, как мы идем к удобному и гибкому в плане управления 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 тысяч строк. Общее количество политик было около сотни и мы начали в них тонуть. Дополнительно, за год собралось несколько фичреквестов, а именно:
-
Object-group`ы. Есть, например, 10 контроллеров домена. На каждый контроллер нужно открыть примерно 10 портов. На выходе имеем 10*10 строк ACE - это не очень.
-
Наследование. На среднего разработчика нужно накинуть минимум штук 5 обязательных групп и еще столько же опциональных. Было бы здорово сразу говорить, что группа А включает в себя группы Б и В. Добавить группу в группу не работает, так как memberOf групп ASA не умеет.
-
Пачки портов. Если вынести порты в отдельное поле, будет лучше, так как вместо
permit tcp any host A eq 22
permit tcp any host A eq 23
веселее писать
permit tcp any host A eq [22,23]
-
Поддержка очередности ACE - хочется вставлять deny в нужные места по флоу, а не руками.
-
Знать какие именно проверки не прошел пользователь. Со временем образовалась куча проверок и все в одном скрипте. Так как мы не нашли возможность писать в лог из LUA (debug-trace, пожалуйста, не предлагайте), то без хождения на syslog мы не знаем точную причину, почему человек зафейлил проверки.
-
Проверка команд из шага с diff`ом в деплое. Бывали случаи, что инженер ТП сделал MR и отправил на согласование. Diff ему показался адекватный, но пока он собирал аппрувы в слаке, коллега выкатил изменения из другого бранча и замержил в мастер. Деплой эти изменения в репозитории ожидаемо не видит и перетирает. В итоге доступ вроде как открыл, но он не работает.
-
Было бы здорово проставлять приоритет групп не хардкодом, а из ямла.
Писать всё на чистом 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 политики, он может быть continuequarantineterminate. По умолчанию он 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-groups+networks+${NAME}s*
^s+network-objects+${NETWORK}
^s+network-objects+hosts+${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-records${NAME}
^s+network-acls+${CHILDRENS}
^s+actions+${ACTION}
^s+prioritys+${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 можно также проверять следующим способом:
-
Раз в N времени обходим все МСЭ, забираем всё из вывода show access-list | access-list DAP-ip-user.
-
Схлопываем одинаковые правила в ключ, после чего складываем хиткаунты всех таких одинаковых правил на всех фаерволлах. На выходе получаем словарь, где ключом служит ACE (например, permit any host 10.0.0.1 eq 22), а значением суммарное количество хиткаунтов такого правила на всех фаерволлах.
-
Отдаем в Prometheus. По крону обновляем информацию и видим, какие правила явно пользуются популярностью, а какие уже не являются актуальными и туда никто не ходит.
В итоге
В итоге компания получила легко и понятно конфигурируемый VPN, для изменения которого не требуется каждый раз ходить к сетевику, а сетевик получил немного больше времени для занятия чем-то более интересным, чем по несколько раз (или десятков раз) в день обходить кучу ИБшников и устройств для закрытия совершенно одинаковых заявок.
Надеюсь, что данная статья хотя бы немного подтолкнет вас в сторону сетевой автоматизации и освободит часть вашего времени.
Спасибо.
Автор:
Vasiliy_A