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

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

Продолжение части 1 (локальный детект) и части 2 (community). До сих пор мы банили тех, кто стучится на закрытые порты. Но если вы публикуете сайты/сервисы через реверс-прокси, по ним идёт отдельный поток атак — сканеры CVE, перебор путей, попытки /.env, /wp-login.php и прочее. Научим CrowdSec читать access-логи Traefik и складывать веб-злодеев в отдельный список crowdsec-traefik-http, который роутер так же дропает на WAN.

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

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


Идея

браузер/сканер → 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:

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

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

    volumes:
      - ./logs:/logs

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

{"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):

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

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

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

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

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

:warning: Шаг 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:

    entryPoints:
      websecure:
        address: ":443"
        forwardedHeaders:
          trustedIPs:
            - "173.245.48.0/20"   # ... диапазоны Cloudflare
            - "103.21.244.0/22"
            # полный список: https://www.cloudflare.com/ips/
    

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


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

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

#!/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. Правило файрвола для веб-списка

/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"

:warning: Баунсер выше кладёт «прочие» локальные сценарии (не mikrotik/не веб — например ssh-bf из коллекции crowdsecurity/linux) в fallback-список crowdsec-local. Если такие источники у вас есть — заведите дроп и для него, иначе IP осядут в списке, но резаться не будут:

/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. Проверка

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

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

И смотрим:

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"

:warning: Не забаньте сами себя

Через 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 вместо баунсера, свои сценарии — всё это собирается из тех же кирпичиков под вас. Каркас один, а как его масштабировать — зависит только от ваших задач.

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