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-баунсер, что и сканы портов. Нужно лишь:
- заставить Traefik писать access-лог в файл (json);
- скормить этот лог CrowdSec;
- научить
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
Шаг 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"
Баунсер выше кладёт «прочие» локальные сценарии (не
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"
Не забаньте сами себя
Через 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 вместо баунсера, свои сценарии — всё это собирается из тех же кирпичиков под вас. Каркас один, а как его масштабировать — зависит только от ваших задач.
Вопросы и замечания — велкам.