CrowdSec + MikroTik: автоматический бан сканеров прямо на роутере

CrowdSec + MikroTik: автоматический бан сканеров прямо на роутере

Как превратить домашний MikroTik в «руки» CrowdSec: роутер сам стучит логами файрвола в CrowdSec, а CrowdSec в ответ раскладывает атакующих по address-list, которые роутер дропает на WAN. Никаких внешних бэкендов — всё крутится у себя.

Делюсь схемой, которую гоняю дома. Расскажу про оба направления потока, дам рабочие конфиги и сам баунсер на Python, а в конце — грабли, на которые я наступил (чтобы вы не наступали).

Стек: MikroTik RouterOS 7.x, CrowdSec 1.7.x в Docker, самописный баунсер на librouteros. Grafana/Prometheus в этой статье намеренно за скобками.

:warning: Кому это подходит. Схема имеет смысл прежде всего если у вас белый 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) роутер режет трафик
  1. Роутер шлёт логи дропов файрвола в CrowdSec.
  2. CrowdSec детектит «кто долбится» и отдаёт список IP баунсеру; баунсер кладёт их в address-list на роутере через API.
  3. Статические правила файрвола дропают всё из этого списка на входе с 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
}

:warning: Не используйте 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

:warning: Грабли №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   # появятся баны

:warning: Грабли №2. Если в cfg/config.yaml db_config.use_wal не включён — при больших объёмах записи SQLite залипает (database is locked), и быстрые решения не успевают записаться. Поставьте use_wal: true — это родная рекомендация CrowdSec.

:warning: 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 — это уже расширения, см. «Куда расти»):

Правила файрвола CrowdSec со счётчиками дропов

3.3. Ключевые идеи баунсера

Готовые баунсеры под MikroTik есть, но почти все либо плодят «список-на-каждое-изменение» с правилами по номерам (хрупко), либо переливают весь список целиком. Я написал свой, маленький, на librouteros. Главные принципы:

  • Один фикс-лист (crowdsec-mikrotik-ff). Чисто и наглядно.
  • TTL через нативный timeout RouterOS. Каждой записи ставится timeout = время жизни бана. Запись сама выпадает, даже если баунсер умер. Никакого отдельного «разбанера» не нужно.
  • Дифф, а не переливка. Каждый цикл забираем полный набор решений, сравниваем с тем, что реально на роутере, и пишем только дельту (добавить новые, убрать разбаненные).
  • Sentinel против ребута роутера. Кладём одну постоянную запись-маркер (192.0.2.1, это TEST-NET-1, никогда не маршрутизируется). Пропал маркер, а список пуст → роутеру вырубали свет/ребутили → переливаем весь набор заново.
  • Само-лечение. Сверяемся с реальным содержимым роутера, а не только со своим стейтом — тогда дрейф (что-то пропало/протухло) исправляется сам.

А вот сам address-list в работе: постоянный маркер crowdsec-meta-local / 192.0.2.1 (без таймаута, «do not delete») и живые баны с обратным отсчётом TTL:

Address-list: sentinel-маркер и баны с 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"

Грабли и уроки (самое ценное)

  1. Монтируйте каталог логов, а не файл. Иначе ночная ротация отвяжет контейнер от живого лога, и детект тихо умрёт до утра. (Часть 2.1)

  2. use_wal: true в CrowdSec. Без WAL большие объёмы записи лочат SQLite и убивают баны. (Часть 2.3)

  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>.

  4. BT-подобные порты своих сервисов на WAN дропайте БЕЗ лога. Любой torrent/Resilio/P2P-сервис заставляет пиров долбиться к вам напрямую. Если такие дропы логируются — port-scan забанит легитимных пиров и ваши же мобильные CGNAT-адреса (а это рубит вам с телефона вообще всё). Решение — raw-правило дропа на эти порты с log=no.

  5. TTL через timeout address-list — ваш друг. Баны само-выпадают даже при мёртвом баунсере; отдельный «разбанер» не нужен.

  6. 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 из двух контейнеров. Вопросы и замечания приветствуются.

Дополню тем как оно выглядит изнутри

Дерево каталога

crowdsec/
├── docker-compose.yml         
├── .env                        
├── bouncer/
│   ├── Dockerfile             
│   └── bouncer.py             
├── cfg/                        
│   ├── config.yaml             
│   ├── acquis/
│   │   └── mikrotik.yaml       
│   ├── parsers/
│   │   ├── s01-parse/
│   │   │   └── mikrotik.yaml   
│   │   └── s02-enrich/
│   │       └── my-whitelist.yaml   
│   └── scenarios/
│       └── mikrotik-port-scan.yaml 
└── data/                       

Плюс вне этого каталога, на самом хосте (их ставит система, не Docker):

/etc/rsyslog.d/50-mikrotik.conf  
/etc/logrotate.d/mikrotik           
/var/log/mikrotik.log              

.env.example

# Ключ баунсера. Сгенерировать командой:
#   docker exec crowdsec cscli bouncers add mikrotik-bouncer
BOUNCER_KEY=ВСТАВЬТЕ_КЛЮЧ_ОТ_CSCLI

# Пароль API-пользователя на роутере 
MIKROTIK_PASS=ДЛИННЫЙ_СЛУЧАЙНЫЙ_ПАРОЛЬ