Приветствую всех.
Решил поделится своим видением интеграции Crowdsec с роутером Mikrotik (и не только).
Предисловие.
В официальной документации нам предлагают используя fetch постоянно дергать файлик со списком во влешку роутера, потом этот список загружать в address list.
Мне такой подход не особо нравится, так как флеш память у роутеров не славится запредельным ресурсом, даже на enterprise решениях. Так же, сугубо личное мнение, что железный “роутер должен роутить”, это его прямая задача, а не вешать на него разного рода обработки, не связанные с сетевыми протоколами, маршрутизацией, файерволом и т.д.
К сути.
Я сетевик, не особо программист, строго не судите.
Реализовал скрипт со следующей архитектурой.
- на сервере установлен crowdsec и bird →
- скрипт по api забирает и разбирает список адресов для блокировки из crowdsec →
- скрипт формирует файлы для bird →
- bird анонсирует по протоколу bgp на роутер mikrotik пул адресов →
- в роутере фильтрами метим полученный пул в 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
И так же наблюдаем изменения.
На этом всё!
Это мой первый пост для комьюнити.
Буду рад предложениям, пожеланиям, комментариям.
Спасибо за внимание!