CrowdSec + MikroTik. Часть 2: подключаем community-блоклист (и не ломаем локальный детект)

CrowdSec + MikroTik. Часть 2: подключаем community-блоклист (и не ломаем локальный детект)

Продолжение статьи про локальный детект. Там роутер сам ловил сканеров и складывал их в crowdsec-mikrotik-ff. Теперь добавим вторую линию обороны — глобальную репутацию CrowdSec (community blocklist на десятки тысяч известных злодеев) — и сделаем это так, чтобы огромный список не «задушил» быстрый локальный цикл.

Если первую часть не читали — там вся база (логи MikroTik → CrowdSec → баунсер → address-list). Здесь только дельта.


Что такое community blocklist

CrowdSec бесплатно отдаёт подписчикам CAPI (Central API) агрегированный блоклист: IP, которых сообщество CrowdSec уже поймало на атаках по всему миру. Это десятки тысяч адресов (у меня в среднем ~25–30k). Идея — резать заведомых злодеев ещё до того, как они начнут стучаться лично к вам.

:information_source: Это не обязательная часть. Community-список большой (десятки тысяч IP), и слабый роутер на нём может задохнуться: первый пролив идёт минутами (ограничение RouterOS API ~10 IP/с), а сам разросшийся address-list нагружает CPU/память роутера. Если у вас скромное железо (hAP lite/mini, старые модели) — пользы от community меньше, чем риска подвесить роутер. Локального детекта из части 1 вполне достаточно. Включаете community — присматривайте за нагрузкой: /system resource print (CPU, free-memory).

Включается обычно «из коробки», но проверим, что регистрация в CAPI есть:

docker exec crowdsec cscli capi status
# если не зарегистрирован:
docker exec crowdsec cscli capi register
docker restart crowdsec

Решения community приходят с origin=CAPIlists). Проверить, что они есть:

docker exec crowdsec cscli decisions list --origin CAPI --limit 0 | head
docker exec crowdsec cscli metrics            # увидите рост decisions от CAPI/lists

Почему нельзя просто «досыпать» в тот же баунсер

В первой части баунсер каждые 15 секунд забирал свои решения, сверял с роутером и писал дельту. С локальным списком на полсотни IP это копейки.

А теперь представьте, что в тот же 15-секундный цикл попадает 25 000 записей community. Каждый проход:

  • тянет весь огромный сет по API,
  • сверяет с роутером,
  • рефрешит TTL у тысяч записей.

Проход растягивается на минуты и «голодит» быстрый локальный цикл — сканер, которого надо было прибить за 15 секунд, ждёт, пока отработает гигантский community-проход.

Решение — два независимых процесса из одного образа:

ROLE=local ROLE=community
origins crowdsec,cscli (свои детекты) CAPI,lists (репутация)
список crowdsec-mikrotik-ff crowdsec-community
цикл каждые 15 с раз в ~12 ч
TTL записи 6 ч 72 ч
sentinel crowdsec-meta-local crowdsec-meta-community
state-файл state-local.json state-community.json

У каждого — свой коннект к роутеру, свой стейт и свой sentinel, поэтому они физически не могут блокировать друг друга.

Два важных приёма для community:

  • refresh-ahead ≥ интервала. Community-список почти не меняется, переписывать его целиком каждый раз глупо. Поэтому TTL длинный (72 ч), а рефрешим запись только если ей осталось жить меньше REFRESH_AHEAD (14 ч). Главное правило: REFRESH_AHEAD должен быть больше интервала (14 ч > 12 ч), иначе запись успеет протухнуть между прогонами.
  • Сверка с реальным роутером (само-лечение). Диффим желаемое не против своего стейта, а против того, что по факту лежит на роутере. Тогда если запись потерялась (ребут роутера, ручная чистка) — она вернётся сама.

Role-aware баунсер

Один файл, поведение переключается переменной ROLE:

#!/usr/bin/env python3
"""CrowdSec -> MikroTik bouncer, role-aware.

ROLE=local      origins=crowdsec,cscli -> crowdsec-mikrotik-ff, быстрый цикл (15s)
ROLE=community  origins=CAPI,lists      -> crowdsec-community, медленный (12h) + refresh-ahead
"""
import os, time, json, random, urllib.request, urllib.error
import librouteros
from librouteros.query import Key
from librouteros.exceptions import TrapError, ConnectionClosed

ROLE   = os.environ.get("ROLE", "local").lower()
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")
RETRY   = int(os.environ.get("RETRY_INTERVAL", "60"))
SENTINEL_ADDR = "192.0.2.1"        # TEST-NET-1, никогда не маршрутизируется

if ROLE == "community":
    ORIGINS  = os.environ.get("COMMUNITY_ORIGINS", "CAPI,lists")
    LIST     = f"{PREFIX}-community"
    SENTINEL = f"{PREFIX}-meta-community"
    INTERVAL = int(os.environ.get("COMMUNITY_INTERVAL", str(12 * 3600)))
    TTL      = int(os.environ.get("TTL_REMOTE_SECONDS", str(72 * 3600)))
    REFRESH  = int(os.environ.get("REFRESH_AHEAD_SECONDS", str(14 * 3600)))  # >= INTERVAL!
    STATE    = "/app/state/state-community.json"
    LOOP     = 60                   # дешёвый heartbeat (проверка ребута), reconcile раз в INTERVAL
else:
    ROLE     = "local"
    ORIGINS  = os.environ.get("LOCAL_ORIGINS", "crowdsec,cscli")
    LIST     = f"{PREFIX}-mikrotik-ff"
    SENTINEL = f"{PREFIX}-meta-local"
    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"))
    STATE    = "/app/state/state-local.json"
    LOOP     = INTERVAL

def log(*a): print(time.strftime("%H:%M:%S"), f"[{ROLE}]", *a, flush=True)
def jittered(t): j = t // 10; return t + random.randint(-j, j)   # +/-10% от пиковых всплесков

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=120) as r:
        data = json.load(r) or {}
    out = set()
    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:
            out.add(v)
    return out

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=30)
def present(al):
    return {str(e["address"]) for e in al.select("address").where(Key("list") == LIST)}
def add(al, addr, ttl):
    try: al.add(list=LIST, address=addr, timeout=str(ttl), comment="crowdsec")
    except TrapError: set_to(al, addr, ttl)
def set_to(al, addr, ttl):
    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, Key("address") == SENTINEL_ADDR):
        return True
    return False

def reconcile(al, state, now):
    desired = fetch()
    cur = present(al)                       # router truth -> само-лечение
    added = refreshed = removed = 0
    for addr in desired:
        if addr not in cur:                 # нет на роутере -> добавить
            t = jittered(TTL); add(al, addr, t); state[addr] = now + t; added += 1
        else:                               # есть -> рефреш только если скоро протухнет
            exp = state.get(addr)
            if exp is None or exp - now <= REFRESH:
                t = jittered(TTL); set_to(al, addr, t); state[addr] = now + t; refreshed += 1
    for addr in cur:                        # на роутере, но уже не желателен -> убрать
        if addr not in desired: remove(al, 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, removed

def main():
    log(f"start origins={ORIGINS} list={LIST} interval={INTERVAL}s ttl={TTL}s refresh={REFRESH}s")
    state = load_state(); api = None; last = 0.0; 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=f"crowdsec {ROLE} marker (do not delete)")
                except TrapError: pass
                if not present(al): state = {}; log("router wiped -> re-pour full set")
            if first or now - last >= INTERVAL:
                d, a, r, rm = reconcile(al, state, now); last = now
                if a or r or rm or first: log(f"desired={d} +{a} ~{r} -{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(LOOP)

if __name__ == "__main__":
    main()

Это упрощённый вариант на один список на роль.


Два контейнера в compose

  bouncer-local:
    build: ./bouncer
    container_name: cs-bouncer-local
    restart: unless-stopped
    environment:
      ROLE: "local"
      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}"
    volumes: ["bouncer-state:/app/state"]
    depends_on: [crowdsec]

  bouncer-community:
    build: ./bouncer
    container_name: cs-bouncer-community
    restart: unless-stopped
    environment:
      ROLE: "community"
      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}"
    volumes: ["bouncer-state:/app/state"]
    depends_on: [crowdsec]

volumes:
  bouncer-state:

Оба контейнера используют один API-ключ — это нормально. Стейт-файлы у них разные (state-local.json / state-community.json) в общем volume, так что не пересекаются.


Правило файрвола для community

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

Запускаем и наблюдаем первую «заливку» (она единоразовая, дальше дельта почти нулевая):

docker compose up -d --build bouncer-local bouncer-community
docker logs -f cs-bouncer-community     
/ip firewall address-list print count-only where list="crowdsec-community"

Первый пролив ~25k записей идёт несколько минут (порядка 10 IP/с — это ограничение RouterOS API, не баунсера). После этого community-цикл почти ничего не делает.


Грабли community

  1. Метрика cs_active_decisions врёт для локальных решений. Если будете смотреть в Prometheus/Grafana — gauge cs_active_decisions для локальных origin (crowdsec) не декрементится корректно при истечении TTL и копит «протухшие». У меня рисовало 528 банов, когда на роутере было 15. Истина — cscli decisions list --limit 0 и сам роутер. Для community-гейджа метрика точна (он переписывается целым сетом).
  2. --limit 0 обязателен. cscli decisions list и cscli alerts list режут вывод дефолтным лимитом ~100 — для точного счёта всегда --limit 0.
  3. Чистите «осиротевших» детей баунсеров. Оба контейнера ходят под одним ключом → CrowdSec плодит дочерние записи mikrotik-bouncer@<docker-ip> на каждый IP, с которого был pull. После пересозданий контейнеров их docker-IP меняются, копятся протухшие. Поштучно их не удалить (auto-created), но есть: cscli bouncers prune -d 24h --force — чистит неактивные, живых не трогает.
  4. use_wal: true тут особенно важен: заливка 25k без WAL лочит SQLite.

И, как всегда, оговорюсь: это лишь один из вариантов. Числа (TTL, интервалы, capacity/leakspeed) и само деление на два контейнера — каркас под мой кейс; крутите под своё железо и трафик. Схема масштабируется в любую сторону, всё зависит только от ваших задач.

Как обычно ваши вопросы/пожелания приветствуются)