CrowdSec + MikroTik: автоматический бан сканеров прямо на роутере
Как превратить домашний MikroTik в «руки» CrowdSec: роутер сам стучит логами файрвола в CrowdSec, а CrowdSec в ответ раскладывает атакующих по
address-list, которые роутер дропает на WAN. Никаких внешних бэкендов — всё крутится у себя.
Делюсь схемой, которую гоняю дома. Расскажу про оба направления потока, дам рабочие конфиги и сам баунсер на Python, а в конце — грабли, на которые я наступил (чтобы вы не наступали).
Стек: MikroTik RouterOS 7.x, CrowdSec 1.7.x в Docker, самописный баунсер на librouteros. Grafana/Prometheus в этой статье намеренно за скобками.
Кому это подходит. Схема имеет смысл прежде всего если у вас белый IP — именно на него ломятся сканеры со всего интернета, и именно его дропы мы ловим. За CGNAT/«серым» IP входящих сканов на ваш WAN практически нет, и пользы от этого будет сильно меньше.
Зачем это вообще
CrowdSec — это «fail2ban на стероидах»: читает логи, по сценариям детектит атаки (сканы портов, брут, веб-CVE), складывает «решения» (decisions) в локальную БД и умеет тянуть community-блоклист на десятки тысяч известных злодеев. Но сам по себе CrowdSec никого не банит — за блокировку отвечают bouncer-ы.
Идея простая и красивая: точка фильтрации должна быть на самом краю сети — на роутере. Зачем пускать сканера до сервера, если можно прибить его на WAN-интерфейсе MikroTik? Тогда дроп бесплатный (по address-list), а атакующий вообще не доходит до сервисов.
Получается двунаправленный конвейер:
(1) логи файрвола по syslog
┌──────────┐ ───────────────────────────► ┌───────────────┐
│ MikroTik │ │ CrowdSec │
│ (WAN) │ │ (парсер+сценар)│
│ │ ◄─────────────────────────── │ + LAPI │
└──────────┘ (2) баны в address-list └───────────────┘
▲ через RouterOS API + bouncer │
│ │
└───────── address-list drop на WAN ◄───────────┘
(3) роутер режет трафик
- Роутер шлёт логи дропов файрвола в CrowdSec.
- CrowdSec детектит «кто долбится» и отдаёт список IP баунсеру; баунсер кладёт их в
address-listна роутере через API. - Статические правила файрвола дропают всё из этого списка на входе с WAN.
В этой статье разбираем локальный детект (свой роутер сам ловит сканеров). Подключение глобального community-блоклиста и веб-детекта оставлю как «куда расти» в конце — база заводится теми же кирпичиками.
Часть 1. Учим роутер стучать логами в CrowdSec
CrowdSec должен видеть, кто ломится на ваш WAN. Источник — логи дропов файрвола MikroTik, отправленные по syslog на сервер с CrowdSec.
1.1. Правила файрвола с логированием
Нам нужно, чтобы «мусорный» входящий трафик дропался с логом и понятным префиксом. Префикс потом ловит парсер.
/ip firewall filter
# дроп + лог всего, что ломится на сам роутер с WAN
add chain=input action=drop in-interface-list=WAN \
log=yes log-prefix="wan_drop" comment="drop+log WAN -> router"
# дроп + лог трафика с WAN в LAN, который не был DST-NAT'нут (не наши проброшенные порты)
add chain=forward action=drop in-interface-list=WAN connection-nat-state=!dstnat \
log=yes log-prefix="wan_fwd_drop" comment="drop+log WAN -> LAN (not forwarded)"
Префикс (
wan_drop) произвольный, но должен совпадать с тем, что ждёт грок-парсер ниже.
1.2. Отправка логов на сервер по syslog
/system logging action
add name=to-crowdsec target=remote remote=SERVER_IP remote-port=514
/system logging
add topics=firewall action=to-crowdsec
Где SERVER_IP — адрес машины с CrowdSec в LAN.
1.3. Приём логов на сервере (rsyslog)
На сервере включаем приём UDP-syslog и отделяем поток от роутера в отдельный файл:
# /etc/rsyslog.d/50-mikrotik.conf
module(load="imudp")
input(type="imudp" port="514")
if $fromhost-ip == 'ROUTER_IP' then {
action(type="omfile" file="/var/log/mikrotik.log")
stop
}
sudo systemctl restart rsyslog
tail -f /var/log/mikrotik.log # проверяем, что капает
Строки будут примерно такие:
2026-06-21T20:41:02+03:00 ROUTER_IP firewall,info wan_drop input: in:ether1 out:(unknown 0), connection-state:new src-mac AA:BB:.., proto TCP (SYN), 45.155.X.X:52356->WAN_IP:9443, len 52
1.4. Ротация лога — ВАЖНЫЙ нюанс
# /etc/logrotate.d/mikrotik
/var/log/mikrotik.log {
daily
rotate 14
missingok
notifempty
create
postrotate
/usr/bin/systemctl kill -s HUP rsyslog.service 2>/dev/null || true
endscript
}
Не используйте copytruncate. rsyslog держит файл открытым; copytruncate сделает «дырявый» (sparse) файл с нулями. Правильно — create + HUP rsyslog (он переоткроет файл). Эта мелочь ещё аукнется в Части 2.
Часть 2. CrowdSec: парсер, сценарий, запуск
Я гоняю CrowdSec в Docker. Минимальный docker-compose.yml:
services:
crowdsec:
image: crowdsecurity/crowdsec:latest
container_name: crowdsec
restart: unless-stopped
environment:
COLLECTIONS: "crowdsecurity/linux"
# пропустить зависающий hub-upgrade при старте на нестабильном линке:
NO_HUB_UPGRADE: "true"
volumes:
- ./cfg:/etc/crowdsec
- ./data:/var/lib/crowdsec/data
# ВАЖНО: монтируем КАТАЛОГ, а не файл (см. ниже про ротацию)
- /var/log:/hostlog:ro
ports:
- "127.0.0.1:8080:8080" # LAPI (баунсер ходит сюда)
2.1. Acquisition — откуда читать логи
# cfg/acquis/mikrotik.yaml
filenames:
- /hostlog/mikrotik.log
labels:
type: mikrotik
Грабли №1 (главные). Сначала я монтировал сам файл: /var/log/mikrotik.log:/logs/mikrotik.log. Docker bind-mount файла прибивается к конкретному inode. Каждую ночь logrotate делал mikrotik.log → .log.1 (старый inode) + новый mikrotik.log (новый inode), а контейнер оставался прибит к старому, уже неживому файлу. Итог: с 00:0x CrowdSec читал мёртвый лог, баны переставали появляться, а к утру всё «само чинилось» (на самом деле я перезапускал контейнер). Лечится монтированием каталога (/var/log:/hostlog:ro) и указанием файла по имени внутри — тогда tail переоткрывает его при ротации.
2.2. Парсер: достаём IP атакующего
Кладём кастомный парсер в cfg/parsers/s01-parse/mikrotik.yaml:
onsuccess: next_stage
filter: "evt.Line.Labels.type == 'mikrotik'"
name: custom/mikrotik-firewall
description: "Parse MikroTik firewall drops, extract attacker source IP"
nodes:
- grok:
pattern: '%{TIMESTAMP_ISO8601:mt_time} %{NOTSPACE:mt_host} firewall,%{NOTSPACE:mt_sev} %{NOTSPACE:mt_prefix} %{WORD:mt_chain}: %{DATA:mt_mid}proto %{DATA:mt_proto}, %{IPV4:source_ip}:%{INT:mt_sport}->%{IPV4:mt_dip}:%{INT:dest_port}, len %{INT:mt_len}'
apply_on: Line.Raw
statics:
- meta: log_type
value: mikrotik_fw_drop
- meta: source_ip
expression: evt.Parsed.source_ip
- meta: dest_port
expression: evt.Parsed.dest_port
2.3. Сценарий: «кто настойчиво долбится — в бан»
cfg/scenarios/mikrotik-port-scan.yaml — «дырявое ведро» (leaky bucket): если с одного IP за окно набралось N дропов — создаётся решение о бане.
type: leaky
name: custom/mikrotik-port-scan
description: "Ban IPs repeatedly hitting the WAN (MikroTik firewall drops)"
filter: "evt.Meta.log_type == 'mikrotik_fw_drop'"
groupby: "evt.Meta.source_ip"
capacity: 2 # 3-й «стук» переполняет ведро -> бан
leakspeed: "60s" # ведро подтекает: 1 событие в минуту прощается
blackhole: 5m # не плодить дубли решений
labels:
type: scan
remediation: true
service: mikrotik
capacity: 2 + leakspeed: 60s — довольно агрессивно (бан после трёх стуков). Для дома норм: легитимный трафик на закрытые порты не ходит. Хотите мягче — поднимайте capacity/leakspeed.
Применяем и проверяем:
docker restart crowdsec
docker exec crowdsec cscli parsers list | grep mikrotik
docker exec crowdsec cscli scenarios list | grep mikrotik
docker exec crowdsec cscli metrics # Acquisition: reads должны расти
docker exec crowdsec cscli decisions list --limit 0 # появятся баны
Грабли №2. Если в cfg/config.yaml db_config.use_wal не включён — при больших объёмах записи SQLite залипает (database is locked), и быстрые решения не успевают записаться. Поставьте use_wal: true — это родная рекомендация CrowdSec.
cscli ... list режет вывод дефолтным лимитом ~100. Для точного счёта всегда --limit 0.
Часть 3. Баны обратно на роутер
Теперь самое интересное — отдать решения CrowdSec роутеру. CrowdSec отдаёт их баунсеру по HTTP (LAPI), баунсер кладёт IP в address-list, а файрвол их дропает.
3.1. Ограниченный API-пользователь на роутере
Не давайте баунсеру полный доступ. Заводим юзера, который умеет только работать с API и только с нужного IP:
/user group add name=crowdsec policy=api,read,write,test
/user add name=crowdsec-bouncer-user group=crowdsec password=СГЕНЕРЬ_ДЛИННЫЙ \
address=SERVER_IP/32
API (8728, plaintext) держим только в LAN. Хотите TLS — используйте api-ssl (8729).
3.2. Статические правила файрвола
Создаём правило-дроп по списку один раз руками. Баунсер его трогать не будет — он только наполняет сам список. Ставим повыше в цепочках (до established/fasttrack):
/ip firewall filter
add chain=input action=drop in-interface-list=WAN src-address-list=crowdsec-ЧТО-ТО-СВОЁ comment="crowdsec-ЧТО-ТО-СВОЁ in" place-before=0
add chain=forward action=drop in-interface-list=WAN src-address-list=crowdsec-ЧТО-ТО-СВОЁ comment="crowdsec-ЧТО-ТО-СВОЁ fwd"
Вот как это выглядит у меня в проде — счётчики дропов по спискам (главный по объёму — crowdsec-mikrotik-ff, 176k срезанных пакетов; списки traefik-http/community/ipsec — это уже расширения, см. «Куда расти»):
3.3. Ключевые идеи баунсера
Готовые баунсеры под MikroTik есть, но почти все либо плодят «список-на-каждое-изменение» с правилами по номерам (хрупко), либо переливают весь список целиком. Я написал свой, маленький, на librouteros. Главные принципы:
- Один фикс-лист (
crowdsec-mikrotik-ff). Чисто и наглядно. - TTL через нативный
timeoutRouterOS. Каждой записи ставитсяtimeout= время жизни бана. Запись сама выпадает, даже если баунсер умер. Никакого отдельного «разбанера» не нужно. - Дифф, а не переливка. Каждый цикл забираем полный набор решений, сравниваем с тем, что реально на роутере, и пишем только дельту (добавить новые, убрать разбаненные).
- Sentinel против ребута роутера. Кладём одну постоянную запись-маркер (
192.0.2.1, это TEST-NET-1, никогда не маршрутизируется). Пропал маркер, а список пуст → роутеру вырубали свет/ребутили → переливаем весь набор заново. - Само-лечение. Сверяемся с реальным содержимым роутера, а не только со своим стейтом — тогда дрейф (что-то пропало/протухло) исправляется сам.
А вот сам address-list в работе: постоянный маркер crowdsec-meta-local / 192.0.2.1 (без таймаута, «do not delete») и живые баны с обратным отсчётом TTL:
Рабочая (локальная, одно-цикловая) версия баунсера:
#!/usr/bin/env python3
"""CrowdSec -> MikroTik bouncer (local): локальные решения -> address-list с TTL."""
import os, time, json, urllib.request, urllib.error
import librouteros
from librouteros.query import Key
from librouteros.exceptions import TrapError, ConnectionClosed
LAPI = os.environ["CROWDSEC_URL"].rstrip("/")
KEY = os.environ["CROWDSEC_BOUNCER_API_KEY"]
MT_HOST, MT_PORT = os.environ["MIKROTIK_HOST"].split(":")
MT_USER = os.environ["MIKROTIK_USER"]
MT_PASS = os.environ["MIKROTIK_PASS"]
PREFIX = os.environ.get("LIST_PREFIX", "crowdsec")
INTERVAL = int(os.environ.get("INTERVAL", "15"))
TTL = int(os.environ.get("TTL_SECONDS", str(6 * 3600)))
RETRY = int(os.environ.get("RETRY_INTERVAL", "60"))
# берём только СВОИ детекты (не community); community -> отдельный цикл, см. "Куда расти"
ORIGINS = os.environ.get("ORIGINS", "crowdsec,cscli")
LIST = f"{PREFIX}-mikrotik-ff"
SENTINEL_LIST = f"{PREFIX}-meta-local"
SENTINEL_ADDR = "192.0.2.1" # TEST-NET-1, никогда не маршрутизируется
def log(*a): print(time.strftime("%H:%M:%S"), *a, flush=True)
def fetch():
"""Полный активный набор ЛОКАЛЬНЫХ решений (диффим локально)."""
url = f"{LAPI}/v1/decisions/stream?startup=true&origins={ORIGINS}"
req = urllib.request.Request(url, headers={"X-Api-Key": KEY})
with urllib.request.urlopen(req, timeout=60) as r:
data = json.load(r) or {}
desired = set()
for d in data.get("new") or []:
v = d.get("value")
if d.get("scope", "Ip") not in ("Ip", "Range") or not v or ":" in v:
continue # только IPv4
desired.add(v)
return desired
def connect():
return librouteros.connect(host=MT_HOST, port=int(MT_PORT),
username=MT_USER, password=MT_PASS, timeout=20)
def present(al):
"""Адреса, реально присутствующие в нашем списке на роутере."""
return {str(e["address"]) for e in al.select("address").where(Key("list") == LIST)}
def add(al, addr):
try:
al.add(list=LIST, address=addr, timeout=str(TTL), comment="crowdsec")
except TrapError: # уже есть -> просто обновим timeout
for e in al.select(".id").where(Key("list") == LIST, Key("address") == addr):
al.update(**{".id": e[".id"], "timeout": str(TTL)})
def remove(al, addr):
for e in al.select(".id").where(Key("list") == LIST, Key("address") == addr):
al.remove(e[".id"])
def sentinel_ok(al):
for _ in al.select(".id").where(Key("list") == SENTINEL_LIST,
Key("address") == SENTINEL_ADDR):
return True
return False
def main():
api = None
while True:
try:
if api is None:
api = connect(); log("connected to mikrotik")
al = api.path("ip", "firewall", "address-list")
if not sentinel_ok(al):
try: al.add(list=SENTINEL_LIST, address=SENTINEL_ADDR,
comment="crowdsec bouncer alive marker (do not delete)")
except TrapError: pass
log("sentinel restored (router reboot?)")
desired = fetch()
cur = present(al)
added = removed = 0
for addr in desired:
if addr not in cur: add(al, addr); added += 1
else: add(al, addr) # освежить TTL
for addr in cur:
if addr not in desired: remove(al, addr); removed += 1
log(f"desired={len(desired)} +{added} -{removed}")
except (ConnectionClosed, OSError) as e:
log("mikrotik unreachable, retry:", e); api = None; time.sleep(RETRY); continue
except urllib.error.URLError as e:
log("LAPI unreachable, retry:", e); time.sleep(RETRY); continue
except Exception as e:
log("error:", repr(e)); api = None; time.sleep(RETRY); continue
time.sleep(INTERVAL)
if __name__ == "__main__":
main()
Dockerfile:
FROM python:3.12-alpine
RUN pip install --no-cache-dir librouteros
COPY bouncer.py /app/bouncer.py
CMD ["python3", "/app/bouncer.py"]
Сервис в docker-compose.yml (рядом с crowdsec):
bouncer:
build: ./bouncer
container_name: cs-bouncer
restart: unless-stopped
environment:
CROWDSEC_URL: "http://crowdsec:8080"
CROWDSEC_BOUNCER_API_KEY: "${BOUNCER_KEY}"
MIKROTIK_HOST: "ROUTER_IP:8728"
MIKROTIK_USER: "crowdsec-bouncer-user"
MIKROTIK_PASS: "${MIKROTIK_PASS}"
LIST_PREFIX: "crowdsec"
INTERVAL: "15"
TTL_SECONDS: "21600"
depends_on: [crowdsec]
Ключ для баунсера генерится в CrowdSec:
docker exec crowdsec cscli bouncers add mikrotik-bouncer
# полученный ключ -> BOUNCER_KEY в .env
Поднимаем и смотрим, как наполняется список:
docker compose up -d --build bouncer
docker logs -f cs-bouncer
# на роутере:
/ip firewall address-list print where list~"crowdsec"
Грабли и уроки (самое ценное)
-
Монтируйте каталог логов, а не файл. Иначе ночная ротация отвяжет контейнер от живого лога, и детект тихо умрёт до утра. (Часть 2.1)
-
use_wal: trueв CrowdSec. Без WAL большие объёмы записи лочат SQLite и убивают баны. (Часть 2.3) -
Вайтлистите СВОЮ инфру. CrowdSec с радостью забанит ваш же VPN-выход, ZeroTier-пир или CDN-подсеть. Я ловил бан собственного КВН-выхода по
http-probingи контент-IP YouTube по port-scan после ребута роутера (пустой conntrack → обратный трафик дропается с логом → «скан»). Заведите локальный парсер-вайтлистcfg/parsers/s02-enrich/my-whitelist.yaml:name: me/my-whitelist description: "Не банить свою инфраструктуру" whitelist: reason: "my own infra" ip: - "WAN_IP_ДОМА" - "IP_ЧЕГО_ТО_ЕЩЁ_ПО_НЕОБХОДИМОСТИ" expression: - "evt.Enriched.ASNumber == '15169'" # напр. Google/YouTube-контентВайтлист гасит только новые баны; existing снимать руками:
cscli decisions delete --ip <IP>. -
BT-подобные порты своих сервисов на WAN дропайте БЕЗ лога. Любой torrent/Resilio/P2P-сервис заставляет пиров долбиться к вам напрямую. Если такие дропы логируются — port-scan забанит легитимных пиров и ваши же мобильные CGNAT-адреса (а это рубит вам с телефона вообще всё). Решение —
raw-правило дропа на эти порты сlog=no. -
TTL через
timeoutaddress-list — ваш друг. Баны само-выпадают даже при мёртвом баунсере; отдельный «разбанер» не нужен. -
Sentinel-маркер надёжно отрабатывает пропажу списка после ребута роутера — дёшево и сердито.
Куда расти
- Подключить community-блоклист CrowdSec. Это десятки тысяч известных злодеев из глобальной репутации — отдельный
address-list(crowdsec-community), наполняется решениями сorigin=CAPI,lists. Нюанс: список огромный и почти не меняется, поэтому его нельзя рефрешить каждые 15 секунд вместе с быстрым локальным циклом (длинный community-проход будет «голодить» быстрый local). У себя я держу один образ баунсера и два контейнера:ROLE=local(каждые 15 с) иROLE=community(раз в ~12 ч, с «refresh-ahead» — освежать только записи, которым осталось мало времени). У каждого свой коннект к роутеру, свой стейт и свой sentinel — тогда они физически не мешают друг другу. - Веб-детект (Traefik/Nginx). Скармливаете CrowdSec access-логи реверс-прокси, добавляете коллекцию
crowdsecurity/traefik(или nginx) — и тот же баунсер кладёт веб-атаки в отдельный списокcrowdsec-traefik-http. На скрине файрвола выше он уже есть. - Enroll в CrowdSec Console (app.crowdsec.net) — единая веб-панель по всем нодам.
- Шаринг сигналов в community (CAPI) — ваши локальные баны улетают в общий блоклист, а вы получаете чужие. Win-win.
Если будет интересно — могу отдельно расписать веб-конвейер (Traefik → CrowdSec → тот же баунсер) и связку local+community из двух контейнеров. Вопросы и замечания приветствуются.

