Мониторинг ipsec strongSwan

от автора

Всем привет! Работая DevOps-инженером, я задумался о мониторинге IPsec-туннелей, которых у нас уже накопилось достаточно. Они в основном используются для связи между облаками, так как инфраструктура разнесена — например, dev и prod живут у разных облачных провайдеров. Также есть интеграции со сторонними организациями, кластеры Kubernetes в AWS, GCP и т.д. Основная цель — получать алерты о падении туннеля раньше, чем сработают алерты о недоступности сервисов. Это особенно важно, поскольку Prometheus у нас один, он живёт в одном из облаков, а prometheus-stack в Kubernetes-кластерах работают в режиме агентов.

Первая проблема — выбор экспортера или разработка своего

Изначально наткнулся на экспортер от dennisstritzke, но проект уже архивный, последний релиз датируется сентябрем 2021 года, в README автор рекомендует использовать более свежий и поддерживаемый экспортер. Однако он использует VICI, соответственно необходима миграция с более старого подхода конфигурирования с помощью ipsec.conf на swanctl.conf. В документации есть подробное описание, и даже ссылка на скрипт-конвертор. Но зачем ломать то, что уже работает, пусть даже и deprecated? В итоге написал свой python скрипт, который дергает ipsec status, парсит вывод и формирует необходимые мне метрики для Prometheus.

app.py

Скрытый текст
#!/usr/bin/env python3  import time import logging import subprocess from prometheus_client import start_http_server, Gauge import re  # Настройка логирования logging.basicConfig(     level=logging.INFO,     format="%(asctime)s - %(levelname)s - %(message)s",     handlers=[         logging.StreamHandler()     ] )  # Инициализация метрик UP_TUNNELS = Gauge('ipsec_up_tunnels', 'Number of active IPsec tunnels') CONNECTING_TUNNELS = Gauge('ipsec_connecting_tunnels', 'Number of connecting IPsec tunnels')   def get_tunnel_metrics():     """Получаем количество активных и подключающихся туннелей StrongSwan через ipsec status."""     try:         logging.info("Выполнение команды `ipsec status` для получения состояния туннелей.")          # Выполнение команды ipsec status         result = subprocess.run(             ["ipsec", "status"],             stdout=subprocess.PIPE,             stderr=subprocess.PIPE,             text=True         )          if result.returncode != 0:             logging.error(f"Ошибка при выполнении команды `ipsec status`: {result.stderr.strip()}")             UP_TUNNELS.set(0)             CONNECTING_TUNNELS.set(0)             return          status_output = result.stdout          if not status_output.strip():             logging.warning("Вывод команды `ipsec status` пустой. Устанавливаю метрики в 0.")             UP_TUNNELS.set(0)             CONNECTING_TUNNELS.set(0)             return          # Ищем строку "Security Associations (X up, Y connecting)"         sa_match = re.search(r"Security Associations \((\d+) up, (\d+) connecting\):", status_output)         if sa_match:             up_tunnels = int(sa_match.group(1))             connecting_tunnels = int(sa_match.group(2))             logging.info(f"Активные туннели (up): {up_tunnels}, подключающиеся туннели (connecting):"                          f" {connecting_tunnels}")         else:             logging.warning("Не удалось найти строку Security Associations. Устанавливаю метрики в 0.")             up_tunnels = 0             connecting_tunnels = 0          # Обновляем метрики         UP_TUNNELS.set(up_tunnels)         CONNECTING_TUNNELS.set(connecting_tunnels)      except Exception as ee:         logging.exception(f"Ошибка при сборе метрик: {ee}")         UP_TUNNELS.set(0)         CONNECTING_TUNNELS.set(0)   if __name__ == '__main__':     port = 9641     try:         logging.info(f"Запуск IPSec Exporter на порту {port}")         start_http_server(port)         logging.info(f"HTTP-сервер успешно запущен на порту {port}")     except Exception as e:         logging.exception(f"Не удалось запустить HTTP-сервер на порту {port}: {e}")         exit(1)      # Основной цикл опроса метрик     while True:         try:             get_tunnel_metrics()         except Exception as e:             logging.exception(f"Неожиданная ошибка в основном цикле: {e}")         time.sleep(5)  # Опрос каждые 5 секунд 

Dockerfile:

Скрытый текст
FROM python:3.9-slim  WORKDIR /usr/local/bin  RUN pip install --no-cache-dir prometheus-client RUN apt-get update && apt-get install -y --no-install-recommends \     strongswan \     && rm -rf /var/lib/apt/lists/*  COPY docker/ipsec-exporter/app.py . RUN chmod +x app.py EXPOSE 9641   CMD ["app.py"]

docker-compose.yaml

Скрытый текст
services:   ipsec_exporter:     image: prometheus_exporters/ipsec-exporter:latest     restart: unless-stopped     pid: "host"     ports:       - "9641:9641"     volumes:       - /var/run/starter.charon.pid:/var/run/starter.charon.pid       - /var/run/charon.pid:/var/run/charon.pid       - /var/run/charon.ctl:/var/run/charon.ctl

И вроде всё хорошо, метрики в Prometheus прилетают, настроили алерты:

Скрытый текст
- alert: NoActiveIPSecTunnels     expr: ipsec_up_tunnels == 0     for: 1m     labels:       severity: average     annotations:       summary: "Нет активных туннелей IPsec"       description: "Все туннели IPsec неактивны в течение минуты."    - alert: TooManyConnectingIPSecTunnels     expr: ipsec_connecting_tunnels > 0     for: 30s     labels:       severity: warning     annotations:       summary: "Не все ipsec туннели в статусе up"       description: "Количество подключающихся туннелей IPsec {{ $value }}." 

Но сама идея пробрасывать через volumes сокет charon в контейнер мягко говоря не очень, потому как при выполнении команды ipsec restart на хостовой машине связь с сокетом пропадала и не восстанавливалась, что логично. Привожу подробные конфиги для тех кто захочет повторить нечто подобное, возможно для других целей. Дорабатывать скрипт, писать какие-то дополнительные, костыльные решения не было желания, поэтому от собственного экспортера быстро отказались. Решили таки переписать конфиги туннелей и использовать готовый экспортер.

Вторая проблема — лаконичность конфигов или «те же яйца, только в профиль»

Для миграции с ipsec.conf на swanctl.conf первым делом я попробовал использовать скрипт-конвертер. Я добавил его в PyCharm, создал необходимые директории и файлы. Скрипт отработал, но на выходе я не получил ожидаемого результата. Видимо, требовался рефакторинг кода или использование более старых версий Python. Конечно, самый правильный подход это использование документации и самостоятельное формирование конфигов, но это занимает уж очень много времени. В итоге за основу была взята статья неизвестного мне автора. Привожу пример одного из своих старых конфигов и то, что получилось при его миграции:

Старый подход — /etc/ipsec.conf

Скрытый текст
# ipsec.conf - strongSwan IPsec configuration file  config setup         charondebug="all"         uniqueids=yes         strictcrlpolicy=no  conn tun-to-rogaikopyta         authby=secret         left=%defaultroute         leftid=my_public_ip         leftsubnet=my_subnet         right=remote_public_ip         rightid=remote_public_ip         rightsubnet=remote_subnet         ike=aes256-sha1-modp1024         esp=aes256-sha1-modp1024         keyingtries=1         leftauth=psk         rightauth=psk         keyexchange=ikev1         ikelifetime=24h         lifetime=1h         auto=route  conn tun-to-rogaikopyta-2         also=tun-to-rogaikopyta         leftsubnet=my_subnet         rightsubnet=remote_subnet1  conn tun-to-rogaikopyta-3         also=tun-to-rogaikopyta         leftsubnet=my_subnet         rightsubnet=remote_subnet2  conn tun-to-rogaikopyta-4         also=tun-to-rogaikopyta         leftsubnet=my_subnet         rightsubnet=remote_subnet3  ...

Новый подход — /etc/swanctl/conf.d/ipsec.conf

Скрытый текст
connections {     tun-to-rogaikopyta {         version = 1         local_addrs = my_private_ip         remote_addrs = remote_public_ip         proposals = aes256-sha1-modp1024         keyingtries = 1          local {             auth = psk             id = my_public_ip         }         remote {             auth = psk             id = remote_public_ip         }         children {             tun-to-rogaikopyta-1 {                 mode = tunnel                 local_ts = my_subnet                 remote_ts = remote_subnet1                 start_action = trap                 esp_proposals = aes256-sha1-modp1024             }             tun-to-rogaikopyta-2 {                 mode = tunnel                 local_ts = my_subnet                 remote_ts = remote_subnet2                 start_action = trap                 esp_proposals = aes256-sha1-modp1024             }             tun-to-rogaikopyta-3 {                 mode = tunnel                 local_ts = my_subnet                 remote_ts = remote_subnet3                 start_action = trap                 esp_proposals = aes256-sha1-modp1024            } ...

Одним из основных преимуществ перехода на новую модель конфигурирования ipsec туннелей многие называют лаконичность конфигов. С одной стороны это правда, ведь мы могли сделать так:

Скрытый текст
connections {     tun-to-rogaikopyta {         version = 1         local_addrs = my_private_ip         remote_addrs = remote_public_ip         proposals = aes256-sha1-modp1024         keyingtries = 1          local {             auth = psk             id = my_public_ip         }         remote {             auth = psk             id = remote_public_ip         }         children {             tun-to-rogaikopyta-1 {                 mode = tunnel                 local_ts = my_subnet                 remote_ts = remote_subnet1, remote_subnet2, remote_subnet3                 start_action = trap                 esp_proposals = aes256-sha1-modp1024

В моём случае это бы сильно помогло, но есть одно но, с другой стороны Cisco ASA. Она принадлежит сторонней компании, доступа к ней у меня нет. А просить тамошних сетевых инженеров перенастроить туннели с их стороны, потому что я гонюсь за лаконичностью такое себе. Новый конфиг был протестирован в тестовом окружении, для этого пришлось развернуть в облаке две машины, две VPC, две таблицы маршрутизации и т.д. После тестирования, в не рабочее время было организовано переключение.

Третья проблема — скупой README.md в проектах

После успешного поднятия туннелей на одном из серверов, решил запустить экспортер и посмотреть какие метрики он отдаёт. В README проекта на github есть пример запуска экспортера в docker:

docker run -it -p 8079:8079 -v $(pwd)/my-config.yaml:/config.yaml --rm torilabs/ipsec-prometheus-exporter:latest

Удивление вызвало то, что при внесении правок в config.yaml и перезапуска контейнера ничего не менялось, потому что в entrypoint не было упоминаний о config.yaml.

По умолчанию сокет vici в Ubuntu имеет следующий адрес socket = unix://var/run/charon.vici. А экспортер может подключаться по tcp, либо по udp. Для того чтобы заставить его работать по tcp привёл конфиги strongswan к следующему виду:

Скрытый текст
# cat /etc/strongswan.d/charon/vici.conf vici {      # Whether to load the plugin. Can also be an integer to increase the     # priority of this plugin.     load = yes      # Socket the vici plugin serves clients.     # socket = unix://var/run/charon.vici     socket = tcp://127.0.0.1:4502  }
# cat /etc/strongswan.d/swanctl.conf swanctl {      # Plugins to load in swanctl.      # VICI socket to connect to by default.     # socket = unix://var/run//charon.vici     socket = tcp://127.0.0.1:4502  }
# cat /etc/strongswan.conf # strongswan.conf - strongSwan configuration file # # Refer to the strongswan.conf(5) manpage for details # # Configuration changes should be made in the included files  charon {         load_modular = yes         plugins {                 include strongswan.d/charon/*.conf                 }  }  include strongswan.d/*.conf

Так как vici теперь работает на localhost хостовой машины, будем запускать docker контейнер с параметром network_mode: «host», финальный конфиг экспортера и docker-compose.yaml будет выглядеть следующим образом:

Скрытый текст
# cat config.yaml # Logger configuration logging:   level: DEBUG  # HTTP server configuration server:   port: 8079  # Vici configuration vici:   network: "tcp"   host: "127.0.0.1"   port: 4502
# cat docker-compose.yml services:   ipsec-exporter:     network_mode: "host"     image: torilabs/ipsec-prometheus-exporter:v0.2.1     command: ["--config=/config.yaml"]     restart: always     volumes:       - ./config.yaml:/config.yaml

Проверить метрики можно командой — curl http://localhost:8079/metrics

Далее осталось сделать дашборд в Grafana и настроить аллертинг, но это уже тема для отдельной статьи. За код на python, орфографию, пунктуацию и network_mode: «host» в docker-compose прошу сильно не пинать :).


ссылка на оригинал статьи https://habr.com/ru/articles/862506/