CrowdSec + MikroTik. Часть 2: подключаем community-блоклист (и не ломаем локальный детект)
Продолжение статьи про локальный детект. Там роутер сам ловил сканеров и складывал их в
crowdsec-mikrotik-ff. Теперь добавим вторую линию обороны — глобальную репутацию CrowdSec (community blocklist на десятки тысяч известных злодеев) — и сделаем это так, чтобы огромный список не «задушил» быстрый локальный цикл.
Если первую часть не читали — там вся база (логи MikroTik → CrowdSec → баунсер → address-list). Здесь только дельта.
Что такое community blocklist
CrowdSec бесплатно отдаёт подписчикам CAPI (Central API) агрегированный блоклист: IP, которых сообщество CrowdSec уже поймало на атаках по всему миру. Это десятки тысяч адресов (у меня в среднем ~25–30k). Идея — резать заведомых злодеев ещё до того, как они начнут стучаться лично к вам.
Это не обязательная часть. 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=CAPI (и lists). Проверить, что они есть:
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
- Метрика
cs_active_decisionsврёт для локальных решений. Если будете смотреть в Prometheus/Grafana — gaugecs_active_decisionsдля локальных origin (crowdsec) не декрементится корректно при истечении TTL и копит «протухшие». У меня рисовало 528 банов, когда на роутере было 15. Истина —cscli decisions list --limit 0и сам роутер. Для community-гейджа метрика точна (он переписывается целым сетом). --limit 0обязателен.cscli decisions listиcscli alerts listрежут вывод дефолтным лимитом ~100 — для точного счёта всегда--limit 0.- Чистите «осиротевших» детей баунсеров. Оба контейнера ходят под одним ключом → CrowdSec плодит дочерние записи
mikrotik-bouncer@<docker-ip>на каждый IP, с которого был pull. После пересозданий контейнеров их docker-IP меняются, копятся протухшие. Поштучно их не удалить (auto-created), но есть:cscli bouncers prune -d 24h --force— чистит неактивные, живых не трогает. use_wal: trueтут особенно важен: заливка 25k без WAL лочит SQLite.
И, как всегда, оговорюсь: это лишь один из вариантов. Числа (TTL, интервалы,
capacity/leakspeed) и само деление на два контейнера — каркас под мой кейс; крутите под своё железо и трафик. Схема масштабируется в любую сторону, всё зависит только от ваших задач.