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

в 15:15, , рубрики: ipsec, prometheus, 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 > 1
    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 прошу сильно не пинать :).

Автор: trabl

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js