Как стать автором
Обновить
54.24
Tuna
Dev-Team-Sec-Ops, сервисы для разработчиков

fail2ban + Traefik — блокируем HTTP ddos флуд

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров1.6K

Настриваем 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 }}"

Сразу после внедрения долго ждать не пришлось, первый поклёв был уже через пару дней:

Сработал алерт на блокировку DDOS
Сработал алерт на блокировку DDOS

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

Подудосил и бросил
Подудосил и бросил

Итоги

Решение получилось простым и надёжным и в добавок ко всему есть мониторинг. Наверное всё тоже самое уже проходили хостеры сайтов в 90-е и 00-е, так что интересно будет послушать ваши истории, а гайд надеюсь кому нибудь пригодится.


На этом у меня всё, спасибо что дочитали до конца 🙂

Тут я хочу напомнить, что Tuna - это платформа для разработчиков и их команд, нацеленная на ускорение разработки, упрощение командного взаимодействия и безопасность.

Туннели - сервис обратных реверс-прокси HTTPTCP и SSHd туннелей, аналогично ngrok.

Шлюзы - всегда доступный облачный балансировщик или web сервер с гибкими политиками обработки трафика и простой и мгновенной настройкой в веб интерфейсе.

Пароли - абсолютно бесплатный облачный менеджер паролей. Работает в web интерфейсе личного кабинета, создан для безопасного хранения и удобного обмена паролями внутри рабочих команд и не только.

Бастион - SSH-сервер с ролевой мандатной моделью доступа, использующий принципы нулевого доверия.

Контакты

Подробнее можете посмотреть всё на сайте tuna, в документации и блоге надеюсь вам понравится работать с tuna.

Если возникли вопросы, можете задать их нам по почте info@tuna.am, тут в коментариях или нашем чате в telegram.

Теги:
Хабы:
+8
Комментарии7

Публикации

Информация

Сайт
tuna.am
Дата регистрации
Дата основания
Численность
2–10 человек

Истории