Настриваем fail2ban для блокировки ddos флуда по access логам Traefik-а.

Предистория
На нодах доступа где наши клиенты создают туннели, для терминации HTTP трафика сейчас мы используем Traefik. Он имеем динамическую конфигурацию, автоматически выпускает Let's Encrypt сертификаты и вообще решает много задач.
Cервис туннелей - это сотни пользовательских доменов, которые через обратный прокси уходят на машины клиентов. Но терминация и первичная маршрутизация HTTP трафика происходит всё таки у нас на ноде. И вот не так давно мы столкнулись с проблемой, на 1 из доменов приходили тысячи запросов в секунду, и Traefik уходил в полку по CPU, страдал весь клиентский трафик на ноде в данном регионе, а пользователи жаловались на зависания и таймауты.

Проанализировав access логи Traefik-а мы увидели, что все запросы шли на 1 домен c десятков IP адресов из AWS. Вообще у нас есть правила троттлинга, и при большом количестве запросов к 1 домену мы начинаем возвращать 429-е, но видимо один из наших пользователей запустил что-то в AWS, может какие-то лямбда функции, может что-то ещё, но троттлинг по 429-м никого не останавливал. Собственно все ресурсы тратились на установление https соединения и отдачу 429 или 404 ошибок (туннель клиента был неактивен).
Пример 1 сообщения access лога:
{"ClientAddr":"198.51.100.1:54536","ClientHost":"198.51.100.1","ClientPort":"54536","ClientUsername":"-","DownstreamContentSize":0,"DownstreamStatus":429,"Duration":16389705,"OriginContentSize":0,"OriginDuration":15588690,"OriginStatus":0,"Overhead":801015,"RequestAddr":"example.ru.tuna.am","RequestContentSize":0,"RequestCount":21820187,"RequestHost":"й.ru.tuna.am","RequestMethod":"GET","RequestPath":"/api","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"tuna-web@file","ServiceAddr":"127.0.0.1:8080","ServiceName":"tuna@file","ServiceURL":"http://127.0.0.1:8080","StartLocal":"2025-04-02T12:20:50.019075429+03:00","StartUTC":"2025-04-02T09:20:50.019075429Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-04-02T12:20:50+03:00"}
В итоге когда утром я проснулся и увидел, что ничего не работает, то пришлось разбираться и в итоге всех заблокировали примерно так:
for i in $(tail -n 10000 /var/log/traefik/access.log | jq -cr 'select(.RequestHost == "example.ru.tuna.am") | .ClientHost' | sort | uniq) ; do ufw deny from ${i} ; done
Блокировка помогла, на уровне firewall проблема решилась на 100%, но через неделю ситуация повторилась, стало понятно, что акция не разовая и надо автоматизировать это.
Решение
Немного исследовав вопрос выяснилось, что если это не DDOS операторского класса на терабайты, то BGP blackhole не обязателен и почти все решают проблему простым файерволом, а точнее с помощью fail2ban. Решение хорошо ещё тем, что помимо автоматической блокировки, разблокировка тоже будет автоматическая по истечению времени карантина.
Настройка fail2ban
fail2ban - это демон написанный на Python который анализирует любые журналы и по регулярному выражению инкрементирует счётчик попаданий в регулярку, далее при превышении определённого порога помещает вредителя в тюрьму (jail), добавляя тем самым IP в правило блокировки в firewall. Вообще проект огромный и настроить можно всё очень гибко. Плюс радует, что софт популярен и актуальная версия есть во всех дистрибутивах, где всё адаптировано под текущий firewall и не надо об этом думать, да ещё и с кучей преднастроенных фильтров, например после установки правило для sshd сразу работает и блокирует ботов, что пытаются подобрать доступ.
sudo apt install -y fail2ban
Устанавливаем пакет, в случае Debian 12 он чуть не актуален и из коробки демон не может стартовать, так как sshd больше не пишет лог в файл, и весь вывод идёт в journald. Чтобы исправить это, правим файл /etc/fail2ban/jail.d/defaults-debian.conf
и добавляем в конец строчку backend = systemd
:
# cat /etc/fail2ban/jail.d/defaults-debian.conf
[sshd]
enabled = true
backend = systemd
Теперь можно стартовать демона и проверить работает ли это вообще - fail2ban-client start
. К слову да, есть клиент которым удобно смотреть какие правила сейчас включены сколько нарушителей в тюрьмах и так далее. Вообще очень удобно, вызовете fail2ban-client --help
там куча подсказок.
Вернёмся к задаче. По сути вся настройка сводится к 2-м файлам. Есть фильтр, где мы описываем, что матчить по регулярке, все фильтры хранятся в каталоге /etc/fail2ban/filter.d
тут сразу из коробки куча всего от мейнтейнеров вашего дистрибутива, и есть тюрьмы, файлы хранятся в /etc/fail2ban/jail.d
, тут мы описываем правила по которым нарушители будут попадать в описанную тюрьму.
Исходя из формата логов пишем фильтр, создаём файл /etc/fail2ban/filter.d/traefik-429.conf
с таким содержимым:
[INCLUDES]
before = common.conf
[Definition]
# Регулярное выражение ищет:
# - "ClientHost": "<HOST>" – обязательная группа для IP (Fail2Ban использует <HOST>)
# - "DownstreamStatus":429 – статус 429
failregex = ^\{.*"ClientHost":"<HOST>".*"DownstreamStatus":\s*429.*\}$
ignoreregex =
тут вроде всё просто, ищем строчки с 429 HTTP ошибками и извлекаем IP клиента.
Теперь создадим тюрьму /etc/fail2ban/jail.d/traefik-429.conf
:
[traefik-429]
enabled = true
filter = traefik-429
logpath = /var/log/traefik/access.log
maxretry = 1000
findtime = 1m
bantime = 1m
Тут тоже ничего сложного, но распишу:
[traefik-429] - это название тюрьмы (jail), набора правил Fail2Ban, который будет применяться к логам. Обычно используется имя, связанное с фильтром или целью блокировки.
enabled - состояние, включено/выключено
filter - указывает имя фильтра, который будет использоваться для анализа логов.
logpath - путь к лог-файлу, который будет анализироваться Fail2Ban.
maxretry - число совпадений от одного IP-адреса (<HOST>), которое допускается до блокировки.
findtime - временное окно, в течение которого считаются попытки.
bantime - продолжительность блокировки IP-адреса (<HOST>).
Файлы создали, теперь применим правила - fail2ban-client reload --all
и посмотрим, что они применились:
# fail2ban-client status
Status
|- Number of jail: 2
`- Jail list: sshd, traefik-429
или только по нашей тюрьме:
# fail2ban-client status traefik-429
Status for the jail: traefik-429
|- Filter
| |- Currently failed: 0
| |- Total failed: 3647
| `- File list: /var/log/traefik/access.log
`- Actions
|- Currently banned: 0
|- Total banned: 3
`- Banned IP list:
Что ж, кажется всё готово, давайте проверять?
Тестирование
Я привык тестировать такое с помощью wrk, у меня и lua скрипт есть для детализации правильной.
report.lua
local os_name = io.popen("uname"):read("*l")
local file_path
if os_name == "Linux" then
file_path = "/dev/shm/responses.tmp"
elseif os_name == "Darwin" or os_name == "FreeBSD" then
file_path = "/tmp/responses.tmp"
else
file_path = "responses.tmp"
end
response = function(status, headers, body)
local file = io.open(file_path, "a") -- Открыть файл для добавления
if file then
file:write(status .. "\n") -- Записать статус-код в файл
file:close()
else
print("Failed to open file for writing")
end
end
-- Функция, вызываемая после завершения теста
done = function(summary, latency, requests)
io.write("------------------------------\n")
io.write(string.format("Requests: %d\n", summary.requests))
io.write(string.format("Duration: %.2f s\n", summary.duration / 1000000))
io.write(string.format("Bytes: %d\n", summary.bytes))
io.write(string.format("Requests/sec: %.2f\n", summary.requests / (summary.duration / 1000000.0)))
io.write(string.format("Transfer/sec: %.2f MB\n", (summary.bytes / 1048576) / (summary.duration / 1000000.0)))
io.write("------------------------------\n")
io.write(string.format("\nLatency Distribution (ms):\n"))
for _, p in pairs({ 50, 90, 99, 99.999 }) do
n = latency:percentile(p)
io.write(string.format(" %g%%: %d\n", p, n / 1000))
end
io.write("------------------------------\n")
io.write(string.format("\nSummary:\n"))
io.write(string.format(" Min Latency: %d ms\n", latency.min / 1000))
io.write(string.format(" Max Latency: %d ms\n", latency.max / 1000))
io.write(string.format(" Mean Latency: %.2f ms\n", latency.mean / 1000))
io.write(string.format(" Stdev Latency: %.2f ms\n", latency.stdev / 1000))
io.write(string.format(" Percentile 99.9: %d ms\n", latency:percentile(99.9) / 1000))
-- Это хак, так как почему то в функцию done не передаются глобальные переменные
local responses = {}
local file = io.open(file_path, "r")
if file then
for line in file:lines() do
local status = tonumber(line)
if status then
if responses[status] == nil then
responses[status] = 0
end
responses[status] = responses[status] + 1
end
end
file:close()
else
print("Failed to open file for reading")
end
-- Удалить временный файл после чтения
os.remove(file_path)
io.write("------------------------------\n")
io.write(string.format("\nHTTP Status Codes:\n"))
if next(responses) ~= nil then
for status, count in pairs(responses) do
io.write(string.format(" %d : %d\n", status, count))
end
else
io.write(" No status codes recorded.\n")
end
end
Покупаем в интернетах VPS, со своей машины конечно не будем тестировать, чтобы самому не оказаться в бане. Запускаем wrk и смотрим что будет:
# wrk -c 10 -t 10 -d 10s -s report.lua https://fail2ban.stage.tuna.am
Running 10s test @ https://fail2ban.stage.tuna.am
10 threads and 10 connections
...
HTTP Status Codes:
404 : 70
429 : 1104
Видим, что мы получили больше 1000 429-х за 10 секунд, ну наверное должно сработать:
# fail2ban-client status traefik-429
Status for the jail: traefik-429
|- Filter
| |- Currently failed: 1
| |- Total failed: 4751
| `- File list: /var/log/traefik/access.log
`- Actions
|- Currently banned: 1
|- Total banned: 4
`- Banned IP list: 65.21.241.57
На целевом хосте IP виртуалки упешно попал в бан. Попробуем снова запустить wrk:
wrk -c 10 -t 10 -d 10s -s report.lua https://fail2ban.stage.tuna.am
unable to connect to fail2ban.stage.tuna.am:https Connection refused
Действительно, получаем TCP reset, чтож, кажется всё работает как ожидается.
Вроде можно в прод, или чего то нехватает?
Мониторинг
Хочется знать когда были срабатывания и иногда посмотреть в историю, может пригодиться для решения инцидентов. К счастью нашёлся добрый человек, что уже написал fail2ban-prometheus-exporter, да ещё и дашборд нарисовал для grafana, храни тебя господь). В общем ставим его удобным нам способом и начинаем поучать метрики.

Ну наверное надо ещё и алерт написать, чтобы узнать когда пришёл ddos, хотя бы на первые пару раз, потом он наверное надоест:
- alert: F2BBannedIncrease
expr: increase(f2b_jail_banned_total{jail="traefik-429"}[1h]) > 10
for: 1s
labels:
severity: warning
annotations:
description: "Количество заблокированных IP в Fail2Ban увеличивается на {{ $value | humanize }} за последний час на {{ $labels.instance }}"
Сразу после внедрения долго ждать не пришлось, первый поклёв был уже через пару дней:

Теперь график загрузки CPU выглядел уже так:

Итоги
Решение получилось простым и надёжным и в добавок ко всему есть мониторинг. Наверное всё тоже самое уже проходили хостеры сайтов в 90-е и 00-е, так что интересно будет послушать ваши истории, а гайд надеюсь кому нибудь пригодится.
На этом у меня всё, спасибо что дочитали до конца 🙂
Тут я хочу напомнить, что Tuna - это платформа для разработчиков и их команд, нацеленная на ускорение разработки, упрощение командного взаимодействия и безопасность.
Туннели - сервис обратных реверс-прокси HTTP, TCP и SSHd туннелей, аналогично ngrok.
Шлюзы - всегда доступный облачный балансировщик или web сервер с гибкими политиками обработки трафика и простой и мгновенной настройкой в веб интерфейсе.
Пароли - абсолютно бесплатный облачный менеджер паролей. Работает в web интерфейсе личного кабинета, создан для безопасного хранения и удобного обмена паролями внутри рабочих команд и не только.
Бастион - SSH-сервер с ролевой мандатной моделью доступа, использующий принципы нулевого доверия.
Контакты
Подробнее можете посмотреть всё на сайте tuna, в документации и блоге надеюсь вам понравится работать с tuna.
Если возникли вопросы, можете задать их нам по почте info@tuna.am, тут в коментариях или нашем чате в telegram.