# CrowdSec + MikroTik. Часть 3: ловим веб-атаки через Traefik

**URL:** <https://forum.samohosting.ru/t/crowdsec-mikrotik-chast-3-lovim-veb-ataki-cherez-traefik/2573>\
**Category:** Безопасность\
**Tags:** docker, mikrotik, reverse-proxy, crowdsec, homelab\
**Created:** [25.Июнь.2026 11:37:15 UTC](https://forum.samohosting.ru/t/crowdsec-mikrotik-chast-3-lovim-veb-ataki-cherez-traefik/2573 "2026-06-25T11:37:15Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![RaineDev](https://forum.samohosting.ru/user_avatar/forum.samohosting.ru/rainedev/32/1979_2.png) [@RaineDev](https://forum.samohosting.ru/u/RaineDev)\
**Post date:** [25.Июнь.2026 11:37:16 UTC](https://forum.samohosting.ru/t/crowdsec-mikrotik-chast-3-lovim-veb-ataki-cherez-traefik/2573/1 "2026-06-25T11:37:16Z")

</div>

# CrowdSec + MikroTik. Часть 3: ловим веб-атаки через Traefik

> Продолжение [части 1 (локальный детект)](https://forum.samohosting.ru/t/crowdsec-mikrotik-avtomaticheskij-ban-skanerov-pryamo-na-routere/2553) и [части 2 (community)](https://forum.samohosting.ru/t/crowdsec-mikrotik-chast-2-podklyuchaem-community-bloklist-i-ne-lomaem-lokalnyj-detekt/2565). До сих пор мы банили тех, кто стучится на закрытые порты. Но если вы публикуете сайты/сервисы через реверс-прокси, по ним идёт отдельный поток атак — сканеры CVE, перебор путей, попытки `/.env`, `/wp-login.php` и прочее. Научим CrowdSec читать access-логи **Traefik** и складывать веб-злодеев в отдельный список `crowdsec-traefik-http`, который роутер так же дропает на WAN.

> Также дополню что здесь только про traefik, если вы используете другой web-proxy то решения надо смотреть уже конкретно под вас

База — из первых двух частей. Здесь только то, что добавляется.

* * *

## Идея

```auto
браузер/сканер → Traefik (access.log json) → CrowdSec (коллекция traefik)
                                                   │
                                   решения origin=crowdsec (scenario http-*)
                                                   │
                              local-баунсер → crowdsec-traefik-http → MikroTik дропает

```

Веб-атаки — это **локальные** детекты (ваш CrowdSec сам их находит в логах), поэтому работают через тот же `local`-баунсер, что и сканы портов. Нужно лишь:

1. заставить Traefik писать access-лог в файл (json);
2. скормить этот лог CrowdSec;
3. научить `local`-баунсер раскладывать решения по двум спискам — сканы в `crowdsec-mikrotik-ff`, веб — в `crowdsec-traefik-http`.

* * *

## Шаг 1. Traefik пишет access-лог в файл

По умолчанию Traefik логирует в stdout — CrowdSec такое читать неудобно. Включаем файловый json-лог в `traefik.yml`:

```yaml
accessLog:
  filePath: /logs/access.log
  format: json
  bufferingSize: 100

```

И монтируем каталог логов в контейнер Traefik (`docker-compose.yml`):

```yaml
    volumes:
      - ./logs:/logs

```

После перезапуска Traefik в `./logs/access.log` пойдут строки вида (одна строка = один запрос):

```json
{"ClientHost":"77.50.255.120","RequestMethod":"PROPFIND","RequestPath":"/remote.php/dav/...","DownstreamStatus":207,"RequestHost":"cloud.example.ru", ...}

```

Нас интересует `ClientHost` — это IP клиента, по нему и будем банить.

> `bufferingSize: 100` — Traefik сбрасывает лог пачками; при отладке не пугайтесь, что строки появляются не мгновенно.

* * *

## Шаг 2. Скармливаем лог CrowdSec

Монтируем каталог логов Traefik в контейнер CrowdSec **только на чтение** (`docker-compose.yml` CrowdSec):

```yaml
    volumes:
      - /path/to/traefik/logs:/logs/traefik:ro

```

Acquisition — `cfg/acquis.d/traefik.yaml`:

```yaml
filenames:
  - /logs/traefik/access.log
labels:
  type: traefik

```

И ставим коллекцию сценариев под Traefik (там http-CVE, path-traversal, bad-user-agent, sqli/xss-probing и т.д.):

```bash
docker exec crowdsec cscli collections install crowdsecurity/traefik
docker restart crowdsec
docker exec crowdsec cscli metrics # в Acquisition появится источник file:/logs/traefik/access.log с растущим reads

```

* * *

## ⚠ Шаг 2.5. Реальный IP клиента — критично!

CrowdSec банит тот IP, что видит в `ClientHost`. Если перед Traefik стоит **Cloudflare в режиме «оранжевое облако» (проксирование)** или любой другой прокси/CDN — Traefik будет видеть IP **прокси** , а не атакующего. Итог: либо забаните диапазоны Cloudflare (и положите себе сайт), либо не забаните никого.

Варианты:

- **Cloudflare DNS-only (серое облако)** — Traefik видит реальный IP клиента напрямую, делать ничего не надо. (Я так и держу.)

- **Cloudflare orange / другой фронт** — нужно доверить заголовок `X-Forwarded-For` от прокси, тогда `ClientHost` станет реальным IP. В `traefik.yml` у нужного entryPoint:

Проверьте на живой строке лога: в `ClientHost` должен быть адрес реального клиента, а не CDN.

* * *

## Шаг 3. Local-баунсер учится раскладывать по спискам

В первой части `local`-баунсер писал всё в один список. Теперь добавим маршрутизацию по сценарию: сканы → `crowdsec-mikrotik-ff`, веб → `crowdsec-traefik-http`. Ключевое изменение — функция `list_for()` и работа с **набором** своих списков (community, как и раньше, живёт в отдельном контейнере из части 2):

```python
#!/usr/bin/env python3
"""CrowdSec -> MikroTik bouncer (local, multi-list): маршрутизация по сценарию."""
import os, time, json, random, 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("LOCAL_INTERVAL", "15"))
TTL = int(os.environ.get("TTL_LOCAL_SECONDS", str(6 * 3600)))
REFRESH = int(os.environ.get("REFRESH_AHEAD_SECONDS", "1800"))
RETRY = int(os.environ.get("RETRY_INTERVAL", "60"))
ORIGINS = os.environ.get("LOCAL_ORIGINS", "crowdsec,cscli")
STATE = "/app/state/state-local.json"
SENTINEL = f"{PREFIX}-meta-local"
SENTINEL_ADDR = "192.0.2.1"

SCAN = f"{PREFIX}-mikrotik-ff"
HTTP = f"{PREFIX}-traefik-http"
FALLBACK = f"{PREFIX}-local"
OWNED = {SCAN, HTTP, FALLBACK}

def log(*a): print(time.strftime("%H:%M:%S"), "[local]", *a, flush=True)
def jittered(t): j = t // 10; return t + random.randint(-j, j)

def list_for(scenario):
    s = (scenario or "").lower()
    if "mikrotik" in s: return SCAN
    if "http" in s or "nginx" in s or "traefik" in s: return HTTP
    return FALLBACK

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 = {} # addr -> list
    for d in data.get("new") or []:
        v = d.get("value")
        if d.get("scope", "Ip") in ("Ip", "Range") and v and ":" not in v:
            desired[v] = list_for(d.get("scenario"))
    return desired

def load_state():
    try:
        with open(STATE) as f: return json.load(f)
    except Exception: return {}
def save_state(st):
    os.makedirs(os.path.dirname(STATE), exist_ok=True)
    tmp = STATE + ".tmp"; json.dump(st, open(tmp, "w")); os.replace(tmp, STATE)

def connect():
    return librouteros.connect(host=MT_HOST, port=int(MT_PORT),
                               username=MT_USER, password=MT_PASS, timeout=20)

def present(al):
    """addr -> list только по НАШИМ спискам (по фильтру, не сканируя весь роутер)."""
    out = {}
    for lst in OWNED:
        for e in al.select("address").where(Key("list") == lst):
            out[str(e["address"])] = lst
    return out

def add(al, lst, addr, ttl):
    try: al.add(list=lst, address=addr, timeout=str(ttl), comment="crowdsec")
    except TrapError: set_to(al, lst, addr, ttl)
def set_to(al, lst, addr, ttl):
    for e in al.select(".id").where(Key("list") == lst, Key("address") == addr):
        al.update(**{".id": e[".id"], "timeout": str(ttl)})
def remove(al, lst, addr):
    for e in al.select(".id").where(Key("list") == lst, Key("address") == addr):
        al.remove(e[".id"])
def sentinel_ok(al):
    for _ in al.select(".id").where(Key("list") == SENTINEL, Key("address") == SENTINEL_ADDR):
        return True
    return False

def reconcile(al, state, now):
    desired = fetch()
    cur = present(al)
    added = moved = refreshed = removed = 0
    for addr, lst in desired.items():
        c = cur.get(addr)
        if c is None: # нет на роутере -> добавить
            t = jittered(TTL); add(al, lst, addr, t); state[addr] = {"list": lst, "exp": now + t}; added += 1
        elif c != lst: # сменился сценарий -> переложить в нужный список
            remove(al, c, addr); t = jittered(TTL); add(al, lst, addr, t)
            state[addr] = {"list": lst, "exp": now + t}; moved += 1
        else: # на месте -> рефреш у тех, кто скоро протухнет
            cur_exp = state.get(addr, {}).get("exp")
            if cur_exp is None or cur_exp - now <= REFRESH:
                t = jittered(TTL); set_to(al, lst, addr, t); state[addr] = {"list": lst, "exp": now + t}; refreshed += 1
    for addr, lst in cur.items():
        if addr not in desired: remove(al, lst, addr); removed += 1
    for addr in [a for a in state if a not in desired]: state.pop(addr, None)
    return len(desired), added, refreshed, moved, removed

def main():
    state = load_state(); api = None; first = True
    while True:
        try:
            if api is None: api = connect(); log("connected")
            al = api.path("ip", "firewall", "address-list"); now = time.time()
            if not sentinel_ok(al):
                try: al.add(list=SENTINEL, address=SENTINEL_ADDR, comment="crowdsec local marker (do not delete)")
                except TrapError: pass
                if not present(al): state = {}; log("router wiped -> re-pour")
            d, a, r, mv, rm = reconcile(al, state, now)
            if a or r or mv or rm or first: log(f"desired={d} +{a} ~{r} move={mv} -{rm} tracked={len(state)}")
            save_state(state); first = False
        except (ConnectionClosed, OSError) as e:
            log("mikrotik down:", e); api = None; time.sleep(RETRY); continue
        except urllib.error.URLError as e:
            log("LAPI down:", 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()

```

Главное отличие от версии из части 1 — `present()` опрашивает **только свои списки по фильтру** (`.where(Key("list")==...)`), а не перебирает весь `address-list`. Это важно: если у вас включён community из части 2 (десятки тысяч записей), перебор всего списка каждые 15 секунд снова всё затормозит.

* * *

## Шаг 4. Правило файрвола для веб-списка

```rsc
/ip firewall filter
add chain=input action=drop in-interface-list=WAN src-address-list=crowdsec-traefik-http comment="crowdsec-traefik-http in" place-before=0
add chain=forward action=drop in-interface-list=WAN src-address-list=crowdsec-traefik-http comment="crowdsec-traefik-http fwd"

```

> ⚠ Баунсер выше кладёт «прочие» локальные сценарии (не `mikrotik`/не веб — например `ssh-bf` из коллекции `crowdsecurity/linux`) в fallback-список **`crowdsec-local`**. Если такие источники у вас есть — заведите дроп и для него, иначе IP осядут в списке, но резаться не будут:
> 
> ```rsc
> /ip firewall filter
> add chain=input action=drop in-interface-list=WAN src-address-list=crowdsec-local comment="crowdsec-local in" place-before=0
> add chain=forward action=drop in-interface-list=WAN src-address-list=crowdsec-local comment="crowdsec-local fwd"
> 
> ```

* * *

## Шаг 5. Проверка

Стукнем по своему домену чем-нибудь заведомо «сканерным» с внешнего адреса (например, с телефона по мобильному интернету):

```bash
for p in /.env /.git/config /wp-login.php /vendor/phpunit; do
  curl -s -o /dev/null "https://ваш-домен$p"
done

```

И смотрим:

```bash
docker exec crowdsec cscli decisions list --limit 0 | grep http
docker logs --tail 20 cs-bouncer-local # увидите +N в move/added
# на роутере:
/ip firewall address-list print where list="crowdsec-traefik-http"

```

* * *

## ⚠ Не забаньте сами себя

Через Traefik идёт и **ваш** трафик. Если ходить на свои сервисы «снаружи» (например, через свой же VPN-выход) и случайно триггернуть сценарий — забаните свой IP и потеряете доступ. Поэтому держите вайтлист своей инфры (из части 1, `cfg/parsers/s02-enrich/my-whitelist.yaml`): WAN-IP дома, IP VPN-выхода, нужные ASN. Приватные сети (192.168/10/172.16) CrowdSec вайтлистит сам, но трафик из интернета на ваш публичный IP — нет.

* * *

## Бонус: плагин-баунсер прямо в Traefik

Есть и другой подход к веб-слою — **CrowdSec Bouncer Traefik Plugin** (`maxlerebourg/crowdsec-bouncer-traefik-plugin`): это middleware, который на каждый запрос спрашивает у LAPI «этот IP забанен?» и сам отдаёт **403** забаненным.

| | Баунсер → MikroTik (эта статья) | Плагин-middleware в Traefik |
| --- | --- | --- |
| Где режется | на WAN роутера (до сервера) | в Traefik (запрос дошёл по TCP) |
| Что видит атакующий | таймаут (пакет дропнут) | HTTP 403 |
| Покрытие | весь трафик на WAN (любой порт) | только HTTP(S) через Traefik |
| Зависит от роутера | да | нет |

Это **не взаимоисключающие** вещи — можно держать оба: плагин даёт мгновенный 403 на уровне приложения, а список на роутере режет того же злодея на краю по всем портам. Я предпочитаю баунсер на роутер (фильтрация на самом краю), но плагин — отличный вариант, если MikroTik в схеме нет.

* * *

## Итог цикла

За три части собрали самодостаточную систему: MikroTik кормит CrowdSec логами файрвола и веба, CrowdSec детектит локально и тянет глобальную репутацию, а баунсер раскладывает всех по `address-list`, которые роутер дропает на краю сети. Никаких внешних сервисов, всё своё.

> И снова оговорюсь: я показал свой вариант (Traefik + баунсер на роутер). Другой реверс-прокси, плагин-middleware вместо баунсера, свои сценарии — всё это собирается из тех же кирпичиков под вас. Каркас один, а как его масштабировать — зависит только от ваших задач.

Вопросы и замечания — велкам.
