Управляем сетевыми политиками доступа в стиле «Network as Code». Часть 2

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

536db938abb74c51005cb13381cf466e.jpg
  • Часть 1 — Концепция

  • Часть 2 — Код (вы находитесь тут)

Disclaimer

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

Краткое содержание предыдущей серии

Те, кто прочитал первую часть статьи, должны помнить, что для автоматизации управления политиками доступа мы руководствовались принципами «Network as Code». В Gitlab мы разместили набор файлов, которые содержат данные следующих типов:

  • описание сервисов,

  • описание хостов и сетей,

  • списки доступа,

  • политики доступа.

Файлов в репозитории много, каждый из них может отвечать как за один маршрутизатор, так и за одну или несколько локаций. Теперь нам надо превратить множество этих файлов в команды CLI и отправить их на сетевые устройства.

Проверка введенных данных

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

  • неправильное форматирование YAML,

  • отсутствуют обязательные данные,

  • данные не соответствуют ожидаемым,

  • в правилах используется отсутствующее описание хоста,

  • описание хоста или сервиса не соответствует принятым Naming conventions.

Для того, чтобы отслеживать такие ошибки на начальных этапах,  был написан небольшой скрипт на Python. Изобретать велосипед мы не стали, воспользовались широко известной библиотекой schema, которая позволяет проверять структуры данных. Схема данных, с которой сверяется каждый YAML‑файл, для удобства вынесена в отдельный файл. При нахождении проблем скрипт выводит в консоль диагностические сообщения и завершается с ошибкой.

# code/lint_state.py

import yaml
import os
from schema import Schema, SchemaError
from state_scheme import scheme_dict

# сформируем список файлов state из каталога intended_state
state_files = []
for root, dirs, files in os.walk('./intended_state', topdown = False):
   for name in files:
        state_files.append(os.path.join(root, name))

# загрузим YAML, заодно проверим, что нет ошибок, связанных с форматом
yaml_dict = {}
for filename in state_files:
    try:
        with open(filename, 'r') as file:
            yaml_data = yaml.safe_load(file)
            print(f'{filename} - OK')
    except yaml.YAMLError as exc:
        print (f'{filename} - ошибка при парсинге файла\n')
        if hasattr(exc, 'problem_mark'):
            if exc.context != None:
                print (f'  ERROR:\n  {exc.problem_mark}\n  {exc.problem} {exc.context}')
            else:
                print (f'  ERROR:\n  {exc.problem_mark}\n  {exc.problem}')
        else:
            print (f'{filename} - неизвестная ошибка при парсинге файла')
        exit(1)

    yaml_dict[filename] = yaml_data

# проверим на соответствие схеме данных
output = ''
success = True

for filename, yaml_data in yaml_dict.items():
    yaml_schema = yaml_data.get('_metadata',{}).get('schema')
    if yaml_schema not in scheme_dict:
        success = False
        output += f'{filename} - ошибка при проверке файла\n'
        output += f'В метаданных отсутствует или неправильно указана schema для проверки\n'
    try:
        config_schema = Schema(scheme_dict[yaml_schema])
        config_schema.validate(yaml_data)
        output += f'{filename} - OK\n'
    except SchemaError as exc:
        success = False
        output += f'{filename} - ошибка при проверке файла\n'
        output += f'{exc}\n'

# выведем диагностическое сообщение
print(output)
if not success:
    # что-то пошло не так, выйдем с ошибкой
    exit(1)
Схема данных — code/state_scheme.py
# code/state_scheme.py

from schema import Or, Regex, Optional
import datetime

scheme_dict = {}

# очень простые регулярные выражения
IP4_REGEX = r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$'
IP4_NETWORK_REGEX = r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3} (255)\.(0|128|192|224|240|248|252|254|255)\.(0|128|192|224|240|248|252|254|255)\.(0|128|192|224|240|248|252|254|255)$ '
IP4_RANGE_REGEX = r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3} (?:[0-9]{1,3}\.){3}[0-9]{1,3}$'
PORTS_REGEX = r'^(range [0-9]{2,5} [0-9]{2,5})|(eq|lt|gt) [0-9]{1,5}$'

metadata_scheme = {
    Optional('filter'): {
        Optional('location_types'): [str],
        Optional('locations'): [str],
        Optional('hosts'): [str],
    },
    'weight': int,
    'description': str,
    'is_active': bool,
    'schema': Or('network_groups',
                 'service_groups',
                 'rules',
                 'policy'
                ),
}

scheme_dict['network_groups'] = {
    '_metadata': metadata_scheme,
    'network_groups': {
        str: {
            Optional('description'): str,
            Optional('extend'): bool,
            Optional('fqdn'): str,
            Optional('networks'): [Regex(IP4_NETWORK_REGEX)],
            Optional('hosts'): [Regex(IP4_REGEX)],
            Optional('ranges'): [Regex(IP4_RANGE_REGEX)],
            Optional('groups'): [str],
        },
    }
}

scheme_dict['service_groups'] = {
    '_metadata': metadata_scheme,
    'service_groups': {
        str: {
            Optional('extend'): bool,
            Optional('tcp'): [Regex(PORTS_REGEX)],
            Optional('udp'): [Regex(PORTS_REGEX)],
        },
    }
}

scheme_dict['rules'] = {
    '_metadata': metadata_scheme,
    'rules': {
        str: {
            Optional('extend'): bool,
            Optional('lines'): [{
                Optional('sequence'): int,
                'action': Or('permit', 'deny'),
                'source': str,
                Optional('source_type'): str,
                'destination': str,
                Optional('destination_type'): str,
                'services': {
                    Optional('protocol'): Or('ip',
                                             'icmp',
                                             'tcp',
                                             'udp'),
                    Optional('ports'): [int],
                    Optional('group'): str,
                },
                Optional('reason'): str,
                Optional('expiry'): Or(datetime.date,
                                       datetime.datetime),
            }]
        },
    }
}

scheme_dict['policy'] = {
    '_metadata': metadata_scheme,
    'name': Or('RM_NAT', 'ACL_NAT'),
    'type': Or('route-map', 'acl'),
    Optional('lines'): {
        int: {
            'rule': str,
            'action': Or('permit', 'deny'),
        }
    }
}

Подготовка списка затронутых устройств

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

# code/get_changes.py

import sys
from git import Repo
from pathlib import Path

# Создадим необходимые каталоги
Path(Path.cwd() / 'artifacts').mkdir(parents=True, exist_ok=True)
ARTIFACTS_PATH = Path.cwd() / 'artifacts'

# хэш коммита передается первым параметром
COMMIT_HASH = sys.argv[1]

# подготовим список измененных файлов
repo = Repo.init(Path.cwd())
changed_files = ''
for filename in repo.commit(COMMIT_HASH).stats.files:
    if filename.split('/')[0] == 'intended_state' and filename.split('.')[-1] == 'yaml':
        changed_files += f'{filename}\n'

if not changed_files:
    print('State не изменился! Завершаем работу')
    exit(1)
with open(f'{ARTIFACTS_PATH}/changed_files.txt', 'w') as file:
    # сохраним список файлов в артефакты
    file.write(changed_files)

Теперь, используя API Nautobot, мы можем запросить актуальный список маршрутизаторов и ограничить его только теми локациями, которые относятся к файлам, измененным в текущем коммите. В качестве API будем использовать GraphQL, а результаты сохраним в файле inventory.yaml

# code/prepare_inventory.py
# часть кода опущена, так как сейчас не важна

affected_items = {
    'location_types': set(),
    'locations': set(),
    'hosts': set(),
}
affected_all = False
for filename in changed_files:
    with open(filename.strip(), 'r') as file:
        yaml_data = yaml.safe_load(file)
        if 'filter' not in yaml_data['_metadata']:
            # изменение касается всех без исключения
            affected_all = True
            # дальше что-то искать нет смысла
            break
        for filter_name, filter_data in yaml_data['_metadata']['filter'].items():
            for item in filter_data:
                affected_items[filter_name].add(item)

# вытащим из Nautobot список устройств
nautobot_health = requests.get(f'https://{NAUTOBOT_URL}/health', verify=False)
if nautobot_health.status_code != 200:
    print("Ошибка! Nautobot недоступен")
    exit(1)

query = '''
query {
    devices (role: ['Router'], status: 'Active') {
        name
        primary_ip4 {
            address
        }
        location {
            name
            location_type {
                name
            }
        }
        rel_device_soft {
            version
            device_platform {
              name
            }
        } 
        tags {
          name
        }
    }
}
'''
nb = pynautobot.api(
    url = f'https://{NAUTOBOT_URL}',
    token = NAUTOBOT_TOKEN,
    threading=True
)
graphql_response = nb.graphql.query(query=query)
devices = graphql_response.json['data']['devices']
affected_devices = {}
affected_devices_for_test = {}

# список поддерживаемых ОС
ANSIBLE_NETWORK_OS = {
    'Cisco IOS': 'ios',
    'Mikrotik RouterOS': 'routeros',
}

for device in devices:
    if not device['primary_ip4']:
        # пропустим устройства без IP адреса
        continue
    if (not device['rel_device_soft'] or 
     device['rel_device_soft']['platform']['name'] not in ANSIBLE_NETWORK_OS
    ):
        # пропустим неизвестные и неподдерживаемые ОС
        continue 
    if (
        affected_all or
        device['location']['location_type']['name'] in affected_items['location_types'] or
        device['location']['name'] in affected_items['locations'] or
        device['name'] in affected_items['hosts']
    ):
        affected_devices[device['name']] = {
            'address': device['primary_ip4']['address'].split('/')[0],
            'location': device['location']['name'],
            'location_type': device['location']['location_type']['name'],
            'platform': device['rel_device_soft']['platform']['name'],
            'software_version': device['rel_device_soft']['version']
        }

# подготовим inventory для ansible
inventory = {
    'all': {
        'hosts': {}
    }
}
for host, data in affected_devices.items():
    inventory['all']['hosts'][host] = {
        'ansible_host': data['address'],
        'location': data['location'],
        'location_type': data['location_type'],
        'software_version': data.get('software_version'),
        'ansible_connection': 'network_cli',
        'ansible_network_os': ANSIBLE_NETWORK_OS[data['platform']],
    }

with open(f'{INVENTORY_PATH}/inventory.yaml', 'w') as file:
    # сохраним инвентори в артефакты
    yaml.dump(inventory, file, allow_unicode=True)

Обратите внимание: в данные каждого хоста мы записываем версию ОС — это пригодится нам в будущем

Сборка политик доступа

Как правило, политика доступа для каждого маршрутизатора собирается из нескольких файлов: это могут быть общие политики, политики площадки или политика для самого устройства. Для объединения политик в единое целое будем учитывать веса, указанные в разделе _metadata, и не забываем про директиву extend. Вот так, например, выглядит фрагмент скрипта code/prepare_configs.py, отвечающий за объединение правил (rules):

# code/prepare_configs.py (не весь)

# вытащим из артефактов список  файлов
all_files = []
for root, dirs, files in os.walk('intended_state', topdown = False):
    for name in files:
        all_files.append(os.path.join(root, name))

# сформируем словарь политик
intended_schema = defaultdict(dict)
for filename in all_files:
    with open(filename.strip(), 'r') as file:
        yaml_data = yaml.safe_load(file)
        if yaml_data['_metadata'].get('is_active'):
            intended_schema[yaml_data['_metadata']['schema']][filename.strip()] = yaml_data

# соберем правила доступа для каждого хоста
if 'rules' in intended_schema:
    for host, host_data in inventory['all']['hosts'].items():
        location = host_data['location']

        # отсортируем правила по весам
        weighted_dict = {}
        for current_state in intended_schema['rules'].values():
            if (
                host_data[‘location_type’] in current_state['_metadata'].get('location_types',[]) or
                host in current_state['_metadata'].get('hosts',[]) or
                host_data['location'] in current_state['_metadata'].get('locations',[])
            ):
                weighted_dict[current_state['_metadata']['weight']] = copy.deepcopy(current_state)

        # выберем правила с самым большим весом для этого хоста
        rendered_state = {'rules': {}}
        for weight, current_state in sorted(weighted_dict.items()):
            if weight > rendered_state.get('weight', 0):
                for rule_name, rule_data in current_state['rules'].items():
                    if rule_data.get('extend') and rule_name in rendered_state['rules']:
                        rendered_state['rules'][rule_name]['lines'].extend(rule_data.get('lines',[]))
                    else:
                        rendered_state['rules'][rule_name] = rule_data

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

Генерация целевой конфигурации и команд для отправки

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

Для нас проще всего было превратить словарь с данными в кусок конфигурации с помощью Python и Jinja2. В процессе разработки мы столкнулись с одним нюансом, который нельзя не отметить. В Cisco IOS начиная с 17 версии в конфигурации ACL сохраняется sequence. А до 16 — не сохраняется. Поэтому при генерации конфигурации мы учитываем версию ПО, установленного на маршрутизатор. Именно для этого мы и сохранили версию в данных хоста в файле inventory.

# code/prepare_configs.py (не весь)

# sequence зависит от версии ПО, начиная с 17 версии IOS - sequence обязательна
# по умолчанию шаг=10
default_step = 10
need_sequence = host_data['software_version'] and host_data['software_version'].split('.')[0] == '17'

for rule in rendered_state['rules']:
    step = default_step
    for line in rendered_state['rules'][rule]['lines']:
        if need_sequence and not line.get('sequence'):
            # установим sequence, ведь он необходим, а его нет
            line['sequence'] = step
            step += default_step
        if not need_sequence and line.get('sequence'):
            # удалим sequence из исходных данных
            line.pop('sequence', None)
        
# целевой конфиг
template = env.get_template('rules.j2')
host_intended_config = template.render({
            'rules': rendered_state['rules'],
})

# сохраним целевой конфиг в артефакты для последующего анализа
with open(f'{INTENDED_CONFIG_PATH}/{host}_rules.conf', 'w') as file:
    file.write(host_intended_config)

Следующий шаг — сравнение ожидаемой и фактической конфигураций. Напомню, актуальный running‑config каждого сетевого устройства у нас хранится в репозитории на корпоративном Gitlab, так что вытащить его не составляет никакого труда. Для сравнения конфигураций мы используем модуль Compliance, который входит в замечательнейшую библиотеку Netutils от network.toCode. На вход подаем название вендора (это может быть Cisco, Huawei и даже Mikrotik), секцию, в которой будем искать различия, и две конфигурации — фактическую и целевую. На выходе получаем два набора команд,  лишние и отсутствующие. Вот так легко и элегантно две конфигурации ACL превращаются в список готовых команд для маршрутизаторов Cisco:

from netutils.config.compliance import compliance
features = [
     {
         'name': 'ACL_TEST_NAT',
         'ordered': True,
         'section': ['ip access-list extended ACL_TEST_NAT']
     },
 ]
host_intended_config = '''
ip access-list extended ACL_TEST_NAT
 permit icmp any host 8.8.8.8 
'''
host_actual_config = '''
ip access-list extended ACL_TEST_NAT
 permit tcp any host 8.8.8.8 eq 53
 permit udp any host 8.8.8.8 eq 53
'''

network_os = 'cisco_ios'
diff = compliance(features, host_actual_config, host_intended_config, network_os, 'string')

# output pprint(diff[features[0][name]])
# 
# {'actual': 'ip access-list extended ACL_TEST_NAT\n'
#            ' permit tcp any host 8.8.8.8 eq 53\n'
#            ' permit udp any host 8.8.8.8 eq 53',
#  'cannot_parse': True,
#  'compliant': False,
#  'extra': 'ip access-list extended ACL_TEST_NAT\n'
#           ' permit tcp any host 8.8.8.8 eq 53\n'
#           ' permit udp any host 8.8.8.8 eq 53',
#  'intended': 'ip access-list extended ACL_TEST_NAT\n'
#              ' permit icmp any host 8.8.8.8',
#  'missing': 'ip access-list extended ACL_TEST_NAT\n'
#             ' permit icmp any host 8.8.8.8',
#  'ordered_compliant': False,
#  'unordered_compliant': False}

Целевую конфигурацию (host_intended_config) мы получили, выполнив рендер в соответствующий шаблон Jinja, а текущую (host_actual_config) загрузили из файла бэкапа. Осталось только сохранить в файл команды. Лишние (extra) команды добавим тоже, но уже с ключевым словом no в начале строки:

code/prepare_configs.py (пример для Cisco)
# code/prepare_configs.py (не весь)

from netutils.config.compliance import compliance

# вытащим актуальный конфиг
host_actual_config = ''
with open(f'{CURRENT_STATE_PATH}/{site}/{host}.conf', 'r') as file:
    host_actual_config = file.read()

# список поддерживаемых ОС
COMLIANCE_NETWORK_OS = 'cisco_ios'
host_diff_config = ''
features = []

# список ACL для сравнения
for rule_name, rule_data in rendered_state['rules'].items():
    features.append({
        'name': f'ip access-list extended {rule_name}',
        'ordered': True,
        'section': [f'ip access-list extended {rule_name}']
    })
host_compliance = compliance(
    features,
    host_actual_config,
    host_intended_config,
        COMLIANCE_NETWORK_OS,
        'string'
)

# для каждого ACL проверим результаты сравнения
for feature in features:
    feature_compliance = host_compliance[feature['name']]
    if not feature_compliance['compliant']:
        # есть различия в конфигурациях
        if feature_compliance['extra']:
            # лишние строки в конфигурации, добавим в начало no
            for line in feature_compliance['extra'].splitlines():
                if line[:1] == ' ':
                    host_diff_config += f' no {line[1:].strip()}\n'
                else:
                    host_diff_config += line.strip() + '\n'
        if feature_compliance['missing']:
            # недостающие строки в конфигурации
            host_diff_config += feature_compliance['missing'] + '\n'

# сохраним команды в файл для последующего деплоя на устройство
if host_diff_config:
    with open(f'{DIFF_CONFIG_PATH}/{host}_rules.conf', 'w') as file:
        file.write(host_diff_config)

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

Доставка конфигураций

Ну вот, самая сложная часть работы была сделана, оставалось только отправить готовый набор команд на нужные устройства. Это мы уже умели делать с помощью Ansible, потребовалось лишь слегка переработать существующий плейбук. В результате работы скрипта code/prepare_configs.py сформировались файлы с командами для каждого маршрутизатора, на который необходимо внести изменения. Плейбук должен проверить наличие соответствующего файла и отправить его на устройство.

ansible/playbook.yml (пример для Cisco)
---
- name: Обновление правил NAT
  hosts: all
  gather_facts: false
  vars:
    artifacts_path: artifacts/configs/diff

  tasks:

  - name: Проверка наличия service object group для хоста
    stat:
      path: "{{ artifacts_path }}/{{ inventory_hostname }}_service_groups.conf"
    register: host_specific_service_og_config

  - name: Проверка наличия network object group для хоста
    stat:
      path: "{{ artifacts_path }}/{{ inventory_hostname }}_network_groups.conf"
    register: host_specific_network_og_config

  - name: Проверка наличия rules (ACL) для хоста
    stat:
      path: "{{ artifacts_path }}/{{ inventory_hostname }}_rules.conf"
    register: host_specific_rules_config

  - name: Проверка наличия policy для хоста
    stat:
      path: "{{ artifacts_path }}/{{ inventory_hostname }}_policy.conf"
    register: host_specific_policy_config

  - name: Обновление service object group 
    ios_config:
      src: "../{{ artifacts_path }}/{{ inventory_hostname }}_service_groups.conf"
    when: host_specific_service_og_config.stat.exists

  - name: Обновление network object group 
    ios_config:
      src:  "../{{ artifacts_path }}/{{ inventory_hostname }}_network_groups.conf"
    when: host_specific_network_og_config.stat.exists

  - name: Обновление rules (ACL) 
    ios_config:
      src:  "../{{ artifacts_path }}/{{ inventory_hostname }}_rules.conf"
    when: host_specific_rules_config.stat.exists

  - name: Обновление policy (route-map) 
    ios_config:
      src:  "../{{ artifacts_path }}/{{ inventory_hostname }}_policy.conf"
    when: host_specific_policy_config.stat.exists

  - name: Сохранение конфигурации 
    ios_config:
      save_when: modified

Собираем все воедино — Gitlab CI/CD

К этому моменту мы:

  1. подготовили политики доступа в формализованном виде,

  2. проверили правильность политик доступа,

  3. сгенерировали целевую конфигурацию для каждого хоста,

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

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

  6. написали Ansible playbook, который отправляет команды на сетевые устройства.

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

4f896df34d680e04926308078d504110.jpeg

Не буду рассказывать про настройку Gitlab и установку gitlab‑runners, это заслуживает отдельной статьи, и не одной. Если не раскрывать всю магию DevOps, то с точки сетевого инженера пайплайн выглядит следующим образом:

  • вносим изменения в YAML (коммитим в ветку test или master),

  • запускается проверка всех файлов с политиками,

  • формируется список хостов, которых касаются внесенные изменения,

  • для каждого хоста создаются файлы с командами для отправки на устройство,

  • команды отправляются на целевые маршрутизаторы,

  • инициируется обновление бэкапов конфигураций маршрутизаторов в Gitlab.

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

За все это отвечает стандартный механизм Gitlab CI/CD, который настраивается в файле.gitlab‑ci.yml

.gitlab-ci.yml
stages:
  - lint
  - prepare
  - deploy

# ШАБЛОНЫ
.default-stage-template:
  image: network/ansible:2.13-22.04
  tags:
    - network-runner

.prepare-stage-template:
  extends:
    - .default-stage-template
  script:
    - python3 ./code/get_changes.py $CI_COMMIT_SHORT_SHA
    - python3 ./code/prepare_inventory.py
    - python3 ./code/prepare_configs.py
  artifacts:
    paths:
      - artifacts/
    expire_in: 1 week

.deploy-stage-template:
  extends:
    - .default-stage-template
  script:
    - cp ansible/ansible.cfg /etc/ansible/
    - ansible-playbook ansible/playbook.yaml
  after_script:
    - python3 ./code/update_current_state.py

# LINT – проверка файлов состояния
yaml-lint:
  # проверка yaml-файлов
  # выполняется при коммите в master или test, если меняется intended state
  stage: lint
  extends:
    - .default-stage-template
  script:
    - python3 ./code/lint_state.py
  rules:
    - if: ($CI_PIPELINE_SOURCE == "push" && ($CI_COMMIT_BRANCH == "test" || $CI_COMMIT_BRANCH == "master")) || $CI_PIPELINE_SOURCE == "web"
      changes:
        - intended_state/**/*.yaml

# PREPARE – подготовка inventory и конфигураций
prepare-artifacts-test:
  # подготовка конфигов и inventory-файла для тестовых роутеров
  # выполняется только при коммите в ветку test, если меняется intended state
  stage: prepare
  extends:
    - .prepare-artifacts-template
  environment:
    name: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "test"
      changes:
        - intended_state/**/*.yaml

prepare-artifacts:
  # подготовка конфигов и inventory-файла для роутеров, у которых изменился state
  # выполняется при коммите в master, если меняется intended state
  stage: prepare
  extends:
    - .prepare-artifacts-template
  environment:
    name: prod
  rules:
    - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "master"
      changes:
        - intended_state/**/*.yaml

prepare-artifacts-all:
  # подготовка конфигов и inventory-файла для всех роутеров
  # выполняется при запуске pipeline через веб-интерфейс
  stage: prepare
  extends:
    - .prepare-artifacts-template
  environment:
    name: manual
  rules:
    - if: $CI_PIPELINE_SOURCE == "web"

# DEPLOY - отправка команд на роутеры
deploy-test:
  # отправка команд на тестовые роутеры
  # выполняется только при коммите в ветку test
  # запуск вручную
  stage: deploy
  extends:
    - .deploy-stage-template
  environment:
    name: test
  before_script:
    - mkdir /ansible/inventory
    - cp artifacts/inventory_for_test/inventory.yaml /ansible/inventory/
  rules:
    - if: ($CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "test") || $CI_PIPELINE_SOURCE == "web"
      changes:
        - intended_state/**/*.yaml
      when: manual

manual-deploy-prod:
  # отправка команд на роутеры, у которых изменился state
  # выполняется только при коммите в ветку master
  # запуск вручную
  stage: deploy
  environment:
    name: prod
  extends:
    - .deploy-stage-template
  before_script:
    - mkdir /ansible/inventory
    - cp artifacts/inventory/inventory.yaml /ansible/inventory/
  rules:
    - if: ($CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "master" && $CI_COMMIT_AUTHOR != "nautobot”) || $CI_PIPELINE_SOURCE == "web"
      changes:
        - intended_state/**/*.yaml
      when: manual

auto-deploy-prod-from-nautobot:
  # отправка команд на тестовые роутеры
  # выполняется только при коммите в ветку master
  # автором коммита может быть только nautobot
  # запуск автоматический
  stage: deploy
  environment:
    name: prod
  extends:
    - .deploy-stage-template
  before_script:
    - mkdir /ansible/inventory
    - cp artifacts/inventory/inventory.yaml /ansible/inventory/
  rules:
    - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "master" && $CI_COMMIT_AUTHOR == "nautobot"
      changes:
        - intended_state/**/*.yaml

Настроены три этапа:

  • lint (проверка данных)

  • prepare (подготовка артефактов — список хостов, конфигурации)

  • deploy (доставка конфигураций)

и два окружения:

  • prod (доставка на маршрутизаторы, которых касаются изменения в текущем коммите)

  • test (аналогично prod, но доставка только на тестовые маршрутизаторы)

После выполнения этапа deploy запускается скрипт update_current_state.py, основное назначение которого — инициировать обновление бэкапов текущих конфигураций всех маршрутизаторов, на которые была отправлена новая конфигурация.

Автоматическое внесение изменений

Очень часто к нам приходят с задачей открыть доступ к определенному сайту по его доменному имени. К сожалению, Cisco IOS позволяет использовать в ACL и object‑group только IP‑адреса. Да, конечно, при создании ACL можно указать hostname, но оно немедленно будет преобразовано в IP‑адрес, который и попадет в конфигурацию.

То же самое касается и заявок на открытие временного доступа. Вроде и есть в Cisco IOS функционал time‑based ACL, но для таких запросов он подходит не очень. Надо каждый раз создавать новый time‑range, а потом его удалять.

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

По уже сложившейся традиции, мы разработали плагин для Nautobot, в состав которого вошел Job, запускаемый встроенным планировщиком. Job клонирует репозиторий, после чего парсит правила и описания сетей. Если в описании хоста присутствует поле fqdn, Job асинхронно обращается к серверам DNS для проверки актуальности IP‑адресов. Правила с истекшим сроком действия (expiry) удаляются из политик. Все изменения сохраняются обратно в файлы и коммитятся и пушатся на сервер. Ну, а дальше все происходит по уже описанной схеме.

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

Бонус. Покажем политики доступа пользователям

Можно было, конечно, дать доступ пользователям к репозиторию. Но искать нужную информацию во множестве YAML‑файлов — задачка не из легких. Гораздо проще найти площадку в Nautobot и увидеть все правила, которые к ней привязаны. К счастью, Nautobot — открытая система и позволяет добавлять подобные страницы к любому объекту учета.

Так у нас можно вывести правила для конкретной площадки:

0f6674855bde69b779d6ae6165339196.jpg

На странице IP‑адреса можно посмотреть правила, в которые он попадает:

df8ae861280e1520758e33e61f173e1e.jpg

Выводы, которые каждый может сделать сам

Даже простые рутинные операции лучше автоматизировать. Это сильно снижает вероятность человеческой ошибки и ускоряет время выполнения заявок. Чем же хорош подход «Network as code»? Работать с текстовыми данными очень удобно; система контроля версий предоставляет удобный функционал по версионированию, логированию, авторизации; использование единого источника истины позволяет переиспользовать данные, например, для систем ИБ. Рассмотренное решение легко можно адаптировать для автоматизации управления политиками QoS или правилами zone‑based firewall.

© Habrahabr.ru