Crowdsec BGP Bouncer (Crowdsec -> Bird -> Mikrotik)

Приветствую всех.
Решил поделится своим видением интеграции Crowdsec с роутером Mikrotik (и не только).

Предисловие.

В официальной документации нам предлагают используя fetch постоянно дергать файлик со списком во влешку роутера, потом этот список загружать в address list.

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

К сути.
Я сетевик, не особо программист, строго не судите.
Реализовал скрипт со следующей архитектурой.

  1. на сервере установлен crowdsec и bird →
  2. скрипт по api забирает и разбирает список адресов для блокировки из crowdsec →
  3. скрипт формирует файлы для bird →
  4. bird анонсирует по протоколу bgp на роутер mikrotik пул адресов →
  5. в роутере фильтрами метим полученный пул в blackhole

Все происходит в realtime, можно подключать сколько угодно роутеров для централизованного управления с одного места.

Установка.

Сильно рассказывать про установку Crowdsec не буду, всё описано на сайте

https://app.crowdsec.net/security-engines/setup?distribution=linux

Я устанавливал его в LXC контейнере на Debian 13 в Proxmox

curl -s https://install.crowdsec.net | sh
apt update && apt install crowdsec -y

Далее связываем с нашим аккаунтом
Ищем в кабинете Enrol Command

cscli console enroll cm*********************9v

Далее получаем ключ для нашего “вышибалы”

cscli bouncers add bird-stream-bouncer

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

Первая часть по настройке Crowdsec окончена, далее настраиваем устанавливаем и настраиваем Bird.

Устанавливаем Bird2

apt install bird2 -y

Создаем два файла, где будут хранится наши листы с адресами, полученные из Crowdsec

touch /etc/bird/blackhole_dynamic_v4.conf
touch /etc/bird/blackhole_dynamic_v6.conf

Правилом хорошего тона делаем резервную копию конфигурации

mv /etc/bird/bird.conf /etc/bird/bird.conf.back

Настраиваем Bird

nano /etc/bird/bird.conf
router id 192.168.140.11;

log syslog all;

protocol device {
    scan time 10;
}

protocol kernel {
    persist;
    scan time 20;

    ipv4 {
        import none;
        export all;
    };
}

protocol kernel {
    persist;
    scan time 20;

    ipv6 {
        import none;
        export all;
    };
}

protocol direct {
    interface "*";
}

protocol static blackhole_dynamic {
    ipv4;

    include "/etc/bird/blackhole_dynamic_v4.conf";
}

protocol static blackhole_dynamic_v6 {
    ipv6;

    include "/etc/bird/blackhole_dynamic_v6.conf";
}

filter export_blackhole {
    if proto = "blackhole_dynamic" || proto = "blackhole_dynamic_v6" then {
        bgp_community.add((65535,666));
        accept;
    }
    reject;
}

template bgp crowdsec_template {
    local as 65101;

    hold time 240;
    keepalive time 80;

    # passive on;

    ipv4 {
        import none;
        export filter export_blackhole;
        next hop self;
    };

    ipv6 {
        import none;
        export filter export_blackhole;
        next hop self;
    };
}

protocol bgp mikrotik from crowdsec_template {
    neighbor 192.168.140.1 as 65100;     
#   neighbor range 192.168.140.0/24, 10.10.10.0/24, 172.16.0.0/16 as 65100;
}

router id - адрес нашего сервера
neighbor - адрес соседа (нашего роутера)
local as - номера автономных станции сервера
neighbor as - номера автономных станции роутера
AS не могут быть одинаковыми, так как мы используем ebgp

Устанавливаем права на файлы

chown -R bird:bird /etc/bird/

Проверяем конфигурацию

birdc configure
BIRD 2.17.1 ready.
Reading configuration from /etc/bird/bird.conf
Reconfigured

Далее поднимаем BGP сессию с Mikrotik
В Mikrotik в терминале

/routing bgp instance
add as=65100 name=bgp-instance-crowdsec router-id=192.168.140.1

/routing bgp connection
add afi=ip,ipv6 as=65100 connect=yes disabled=no\
hold-time=4m input.filter=blackhole-chain instance=bgp-instance-crowdsec\
keepalive-time=1m local.address=192.168.140.1 .role=ebgp\
name=crowdsec remote.address=192.168.140.11/32 .as=65101 routing-table=main

/routing filter rule
add chain=blackhole-chain disabled=no\
rule="if (bgp-communities includes blackhole) { set blackhole yes; set gw 0.0.0.0; accept; }"

router-id и local.address - адрес роутера
remote.address - адрес сервера

Смотрим, что наша сессия поднялась

/routing/bgp/session/print 
Flags: E - established 

 0 E name="crowdsec-1" instance=bgp-instance-1 
     remote.address=192.168.140.11 .as=65101 .id=192.168.140.11 .capabilities=mp,rr,gr,as4,err,llgr .afi=ip,ipv6 .hold-time=4m .messages=2001 .bytes=124914 .gr-time=120 .eor=ip 
     local.address=192.168.140.1 .as=65100 .id=192.168.140.1 .cluster-id=192.168.140.1 .capabilities=mp,rr,enhe,gr,as4 .afi=ip,ipv6 .messages=2289 .bytes=129155 .eor="" 
     output.procid=20 
     input.procid=20 .filter=blackhole-chain .last-notification=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0015030602 ebgp 
     hold-time=4m keepalive-time=1m uptime=1d13h46m47s590ms last-started=2025-12-21 00:06:12 last-stopped=2025-12-21 00:06:03 prefix-count=0

Так же проверяем со стороны сервера

birdc show protocols all

Далее сама суть затеи, наш Bouncer

nano /usr/local/bin/crowdsec-bird-bouncer.py

Вставляем и настраиваем скрипт под себя в блоке НАСТРАИВАЕМЫЕ ПЕРЕМЕННЫЕ

#!/usr/bin/env python3

import requests
import json
import time
import subprocess
import sys
import ipaddress
import fcntl
import os

# ====================== НАСТРАИВАЕМЫЕ ПЕРЕМЕННЫЕ ======================
# Здесь собраны все параметры, которые можно (и нужно) менять под свою среду.
# Не трогайте код ниже этого блока — только эти переменные!

# URL локального API CrowdSec (LAPI). Обычно работает на том же хосте.
# Если CrowdSec на другом сервере — укажи его IP/хост.
LAPI_URL = "http://127.0.0.1:8080"   # Примеры: "http://localhost:8080" или "https://crowdsec.example.com:8080"

# API-ключ для доступа к LAPI. 
# ОБЯЗАТЕЛЬНО задаётся через переменную окружения CROWDSEC_API_KEY в systemd-юните!
# Хардкод здесь оставлен только как fallback для тестов (рекомендуется закомментировать в проде).
# API_KEY = "qBbJ**************gkSAKs"  # ← раскомментировать только для локального теста

# Пути к генерируемым конфиг-файлам BIRD
CONFIG_FILE_V4 = "/etc/bird/blackhole_dynamic_v4.conf"   # IPv4 blackhole-роуты
CONFIG_FILE_V6 = "/etc/bird/blackhole_dynamic_v6.conf"   # IPv6 blackhole-роуты
# Если IPv6 не нужен — поставь CONFIG_FILE_V6 = "" (пустая строка)

# Команда перезагрузки конфигурации BIRD
# Варианты:
#   ["/usr/sbin/birdc", "configure"]          — обычная полная перезагрузка (надёжная)
#   ["/usr/sbin/birdc", "configure", "soft"]  — мягкая (быстрее, но иногда не сразу применяет include-файлы)
BIRD_RELOAD = ["/usr/sbin/birdc", "configure", "soft"]

# Тип роутов для blackhole
# Варианты:
#   "blackhole"    — стандартный blackhole (рекомендуется, трафик просто дропается, MikroTik без gateway)
#   "unreachable"  — отвечает ICMP unreachable
#   "prohibit"     — отвечает ICMP prohibited
BLACKHOLE_TYPE = "blackhole"

# Добавлять ли BGP-community прямо в генерируемый конфиг
# Обычно False — потому что community добавляется в основном bird.conf через фильтр export_blackhole
# Поставь True только если в bird.conf нет добавления community
ADD_COMMUNITY_IN_CONFIG_V4 = False
COMMUNITY_V4 = "65535:666"          # Community для IPv4 (NO_EXPORT + твой тег)
ADD_COMMUNITY_IN_CONFIG_V6 = False
COMMUNITY_V6 = "65535:666"          # Community для IPv6 (можно другую, если хочешь разделить)

# Интервал опроса LAPI CrowdSec (в секундах)
# 30 — хороший баланс между актуальностью и нагрузкой
# Можно уменьшить до 10-15 сек, если нужна сверхбыстрая реакция
POLL_INTERVAL = 30

# Какие источники банов обрабатывать (origin)
# Варианты:
#   []               — все источники (рекомендуется)
#   ["CAPI"]         — только сообщество CrowdSec (Community Blocklist)
#   ["crowdsec"]     — только локальные сценарии
#   ["cscli"]        — только ручные баны через cscli
#   ["crowdsec", "cscli"] — локальные + ручные
ALLOWED_ORIGINS = []   # [] = брать все origin'ы

# Какие типы решений обрабатывать
# Обычно только "ban". "captcha" и другие не нужны для blackhole.
DECISION_TYPES = ["ban"]

# Какие scope обрабатывать
# Варианты:
#   "ip"         — только отдельные IP (/32 или /128)
#   "range"      — только подсети (например, 1.2.3.0/24)
#   "ip,range"   — и IP, и подсети (рекомендуется)
SCOPES = "ip,range"

# Фильтровать только IPv4 (игнорировать IPv6-баны)
# True — если IPv6 ещё не настроен в BIRD/MikroTik
# False — когда IPv6 полностью готов
IPV4_ONLY = True

# Нормализовать подсети (приводить к каноническому виду)
# Например: 1.2.3.4/24 → 1.2.3.0/24
# Почти всегда True — BIRD ругается на ненормализованные префиксы
NORMALIZE_RANGES = True

# Логировать каждый опрос LAPI, даже если изменений нет
# False — тихий режим (только изменения и ошибки)
# True  — подробный лог (удобно при отладке)
VERBOSE_NO_CHANGES = False

# Максимальное общее количество префиксов (IPv4 + IPv6)
# Защита от переполнения таблицы маршрутов и памяти роутера
# 0 или None — без лимита
# Рекомендуется 20000-50000 в зависимости от мощности MikroTik
MAX_TOTAL_PREFIXES = 30000

# Защита от анонса "плохих" префиксов
# Блокировать приватные адреса (RFC1918, ULA, localhost и т.д.)
# Почти всегда True — чтобы не заблочить случайно свою сеть
BLOCK_PRIVATE_PREFIXES = True

# Минимальная длина маски для IPv4 (игнорировать слишком широкие подсети)
# 24 — безопасно: пропускает /24-/32, блокирует /23 и шире
# Можно уменьшить до 20-22, если доверяешь банам крупных подсетей (облака и т.п.)
MIN_PREFIXLEN_V4 = 24

# Минимальная длина маски для IPv6
# 48 — безопасно (пропускает /48-/128)
# CrowdSec обычно банит /128, иногда /48 от облаков
MIN_PREFIXLEN_V6 = 48

# ===================================================================

API_KEY = os.getenv('CROWDSEC_API_KEY')
if not API_KEY:
    print("ОШИБКА: Переменная окружения CROWDSEC_API_KEY не задана!", file=sys.stderr)
    print("   Укажи её в systemd-юните через Environment= или EnvironmentFile=", file=sys.stderr)
    sys.exit(1)

for cfg in [CONFIG_FILE_V4] + ([CONFIG_FILE_V6] if CONFIG_FILE_V6 else []):
    dir_path = os.path.dirname(cfg)
    os.makedirs(dir_path, exist_ok=True)

headers = {"X-Api-Key": API_KEY}
current_prefixes = set()

def reload_bird():
    try:
        subprocess.run(["/usr/sbin/birdc", "configure", "check"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        subprocess.run(BIRD_RELOAD, check=True)
        print(f"[{time.strftime('%H:%M:%S')}] BIRD перезагружен успешно")
    except subprocess.CalledProcessError as e:
        print(f"[{time.strftime('%H:%M:%S')}] ОШИБКА BIRD: конфиг не применён (синтаксис или reload): {e}", file=sys.stderr)

def is_valid_prefix(prefix_str):
    try:
        net = ipaddress.ip_network(prefix_str, strict=False)
        if BLOCK_PRIVATE_PREFIXES and net.is_private:
            return False
        if net.version == 4 and net.prefixlen < MIN_PREFIXLEN_V4:
            return False
        if net.version == 6 and net.prefixlen < MIN_PREFIXLEN_V6:
            return False
        return True
    except ValueError:
        return False

def update_config():
    total_prefixes = len(current_prefixes)

    if MAX_TOTAL_PREFIXES and total_prefixes > MAX_TOTAL_PREFIXES:
        print(f"[{time.strftime('%H:%M:%S')}] ВНИМАНИЕ: Превышен лимит ({total_prefixes} > {MAX_TOTAL_PREFIXES}). Конфиг НЕ обновляется.", file=sys.stderr)
        return

    ipv4_count = ipv6_count = 0

    ipv4_prefixes = [p for p in current_prefixes if ipaddress.ip_network(p, strict=False).version == 4]
    if ipv4_prefixes:
        with open(CONFIG_FILE_V4, "w") as f:
            try:
                fcntl.flock(f.fileno(), fcntl.LOCK_EX)
            except ImportError:
                pass

            f.write("# Автогенерируемые IPv4 blackhole-роуты от CrowdSec\n")
            f.write(f"# Обновлено: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
            f.write(f"# Всего префиксов: {len(ipv4_prefixes)}\n\n")

            for prefix_str in sorted(ipv4_prefixes):
                if not is_valid_prefix(prefix_str):
                    continue
                net = ipaddress.ip_network(prefix_str, strict=False)
                normalized = str(net) if NORMALIZE_RANGES else prefix_str
                line = f"route {normalized} {BLACKHOLE_TYPE}"
                if ADD_COMMUNITY_IN_CONFIG_V4:
                    line += f" community [{COMMUNITY_V4}]"
                f.write(line + ";\n")

            f.flush()
            os.fsync(f.fileno())
        ipv4_count = len(ipv4_prefixes)

    if CONFIG_FILE_V6:
        ipv6_prefixes = [p for p in current_prefixes if ipaddress.ip_network(p, strict=False).version == 6]
        if ipv6_prefixes:
            with open(CONFIG_FILE_V6, "w") as f:
                try:
                    fcntl.flock(f.fileno(), fcntl.LOCK_EX)
                except ImportError:
                    pass

                f.write("# Автогенерируемые IPv6 blackhole-роуты от CrowdSec\n")
                f.write(f"# Обновлено: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
                f.write(f"# Всего префиксов: {len(ipv6_prefixes)}\n\n")

                for prefix_str in sorted(ipv6_prefixes):
                    if not is_valid_prefix(prefix_str):
                        continue
                    net = ipaddress.ip_network(prefix_str, strict=False)
                    normalized = str(net) if NORMALIZE_RANGES else prefix_str
                    line = f"route {normalized} {BLACKHOLE_TYPE}"
                    if ADD_COMMUNITY_IN_CONFIG_V6:
                        line += f" community [{COMMUNITY_V6}]"
                    f.write(line + ";\n")

                f.flush()
                os.fsync(f.fileno())
            ipv6_count = len(ipv6_prefixes)

    reload_bird()
    print(f"[{time.strftime('%H:%M:%S')}] Обновлены конфиги: IPv4={ipv4_count}, IPv6={ipv6_count}, всего={total_prefixes}")

print("Запускаю CrowdSec → BIRD blackhole bouncer (v4/v6)")

full_url = f"{LAPI_URL.rstrip('/')}/v1/decisions/stream"
startup = True

while True:
    params = {"scopes": SCOPES}
    if startup:
        params["startup"] = "true"

    try:
        if VERBOSE_NO_CHANGES or not startup:
            print(f"[{time.strftime('%H:%M:%S')}] Опрос LAPI (startup={startup})...")

        response = requests.get(full_url, headers=headers, params=params, timeout=15)
        response.raise_for_status()
        data = response.json()

        deleted_count = new_count = 0

        if data.get("deleted"):
            for d in data["deleted"]:
                if d.get("type") in DECISION_TYPES and (not ALLOWED_ORIGINS or d.get("origin") in ALLOWED_ORIGINS):
                    val = d.get("value")
                    if val and val in current_prefixes and is_valid_prefix(val):
                        if not IPV4_ONLY or ipaddress.ip_network(val, strict=False).version == 4:
                            current_prefixes.remove(val)
                            deleted_count += 1

        if data.get("new"):
            for d in data["new"]:
                if d.get("type") in DECISION_TYPES and (not ALLOWED_ORIGINS or d.get("origin") in ALLOWED_ORIGINS):
                    val = d.get("value")
                    if val and val not in current_prefixes and is_valid_prefix(val):
                        if not IPV4_ONLY or ipaddress.ip_network(val, strict=False).version == 4:
                            current_prefixes.add(val)
                            new_count += 1

        if new_count or deleted_count:
            print(f"[{time.strftime('%H:%M:%S')}] Обработано: +{new_count} новых, -{deleted_count} удалено")
            update_config()
        else:
            if VERBOSE_NO_CHANGES:
                print(f"[{time.strftime('%H:%M:%S')}] Изменений нет")

        if startup:
            print(f"[{time.strftime('%H:%M:%S')}] Изначально загружено {len(current_prefixes)} префиксов")
            startup = False

    except requests.exceptions.RequestException as e:
        print(f"[{time.strftime('%H:%M:%S')}] Ошибка соединения с LAPI: {e}. Жду 10 сек...")
        time.sleep(10)
    except json.JSONDecodeError as e:
        print(f"[{time.strftime('%H:%M:%S')}] Некорректный JSON: {e}")
        time.sleep(10)
    except Exception as e:
        print(f"[{time.strftime('%H:%M:%S')}] Неожиданная ошибка: {e}", file=sys.stderr)
        time.sleep(10)

    time.sleep(POLL_INTERVAL)

Разрешаем запуск скрипта

chmod +x /usr/local/bin/crowdsec-bird-bouncer.py

Создаем systemd unit для автозапуска
Указываем ключ, полученный ранее из cscli bouncers в CROWDSEC_API_KEY

nano /etc/systemd/system/crowdsec-bird-bouncer.service
[Unit]
Description=CrowdSec BIRD Streaming Bouncer (blackhole routes)
Documentation=https://crowdsec.net
After=network.target crowdsec.service bird.service
Requires=crowdsec.service bird.service
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/crowdsec-bird-bouncer.py
Restart=always
RestartSec=10
RestartPreventExitStatus=0
User=root
Group=root
StandardOutput=journal
StandardError=journal
MemoryAccounting=yes
MemoryMax=256M
Environment="CROWDSEC_API_KEY=qBbJ**************gkSAKs"
StartLimitIntervalSec=60
StartLimitBurst=5

[Install]
WantedBy=multi-user.target

Перезапускаем systemd и стартуем

systemctl daemon-reload
systemctl enable --now crowdsec-bird-bouncer.service
systemctl status crowdsec-bird-bouncer.service

Если всё хорошо, то идем в роутер и смотрим, что нам прилетели префиксы в сессии BGP

:put [/routing/bgp/session/get [find name~"crowdsec"] prefix-count]
16472

Проверяем что всё работает на лету, забаним и разбаним пару адресов

cscli decisions add --ip 1.2.3.4 --type ban --duration 1h
cscli decisions add --range 4.4.4.0/24 --type ban --duration 1h

Идем в ip->routes в роутере и смотрим, что прилетели наши маршруты

Далее удаляем

cscli decisions delete --ip 1.2.3.4
cscli decisions delete --range 4.4.4.0/24

И так же наблюдаем изменения.

На этом всё!

Это мой первый пост для комьюнити.
Буду рад предложениям, пожеланиям, комментариям.
Спасибо за внимание!

3 лайка

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

Я смотрел, что вроде как можно подключать mikrotik bouncer, который через API управляет списками т.е. crowdsec отправляет списки, а не mikrotik принимает.

Хотя BGP по идее, попроизводительней будет

Я, если честно, сам в него в ходил, пока разрабатывал bgp bouncer.

По поводу mikrotik bouncer, мне реализация не зашла.
Да, соглашусь, для микротека проще дропать листы в raw, исходя из пакетной диаграммы у нас пакет умирает сразу же на входе, не доходя до connection tracking.
Но опять таки, если мы включим rp-filter, то drop происходит на этапе Unicast Reverse Path Forwarding (uRPF) — это очень рано, сразу после ingress interface, до prerouting и conntrack (kernel-level check против routing table).

Меня во всей этой истории с использованием api больше всего напрягает, что какая то внешняя система будет управлять микротиком, постоянные коннекты, роутер будет формировать списки для сверки, потом в роутере в адрес листах будет вариться куча (от 16к) адресов, если они динамические, то еще и счетчик считать время “протухания” записи, а если записи статичные, то они пишутся в конфиг роутера на флешку, что еще хуже, как в официальном примере через fetch, постоянно скачивать файлы, парсить и грузить в конфу.

кстати что бы включить rp-filter в mikrotik (по желанию)

/ip settings set rp-filter=loose

Чуть допилил местами и заработала такая связка

services:
  bird:
    image: vnxme/bird:latest
    container_name: bird
    network_mode: host
    volumes:
      - ./data:/etc/bird
    restart: unless-stopped

  bouncer:
    image: git.domain.com/docker/crowdsec-bird-bouncer:latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/etc/bird/
    env_file: secrets/bouncer.env
    restart: unless-stopped

В .env вынес еще и адрес crowdsec

Работает быстро и не потребляет много ресурсов :flexed_biceps:

1 лайк