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

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

На этом всё!

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

У меня пока в планах добраться до настройки до настройки 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:

Не очень хочется делать отдельную тему, но я правильно понимаю суть Crowdsec?

Получается что третья сторона по сути управляет блоклистами микротика или другого устройства где используется Crowdsec?

Просто по старинке использую f2b, думал рассмотреть что-то современное, но пока что выглядит сомнительно.

да, третья сторона, она может это делать на основе собственных списков (до 3 штук на бесплатном тарифе), либо же на основе локальной обработки - ей скармвливаются логи и правила их обработки, условно, если видит 3 ошибки входа с одного ip, то он добавляется в бан на 2 часа
я все никак не доберусь до интеграции с authentik, чтобы при его бане этот бан распространялся и на все остальное.

То есть локальная обработка присутствует и в случае невозможности получить списки извне, списки блокировки будут формироваться?

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

Да, эти процессы работают независимо, причем, локальные источники являются обязательными для конфигурирования, облачные списки становятся доступны после регистрации своего инстанса в облаке и подписке в облаке же на источники, на данный момент в облачных списках 8590 префикса

Может быть как закончу настройку своего инстанса crowdsec выложу что-то на форуме

Буду очень ждать этого события :face_holding_back_tears:

немного переделал скрипт
вынес все настройки в env
ну и под докер на поиграться, сам использую в lxc