Заблокированные IP выгружаются из трёх источников: системного auth.log, nginx access.log и журнала systemd через journalctl. Дальше адреса приводятся к уникальному виду командой sort -u и сопоставляются с активными правилами iptables, nftables, ipset и fail2ban. Расхождение между тем, что видно в логах, и тем, что реально применяется в правилах, показывает, где защита работает вхолостую.
Ниже пошаговая методика: команды извлечения для каждого источника, обработка IPv6 и JSON-логов, сравнение списков через comm и diff, отчёт по частоте и источникам, готовый скрипт для cron и чек-лист регулярного аудита. Команды рассчитаны на стандартные форматы; для кастомных log_format и нестандартных сообщений сервисов проверяйте их на выборке из 50-100 строк своих логов перед полным прогоном.
Зачем выгружать заблокированные IP и что это даёт
Блокировка без анализа превращается в чёрный ящик: правила есть, а что они реально отсекают, неизвестно. Аудит заблокированных IP закрывает четыре задачи.
- Аномалии. Всплеск блокировок с одного адреса, появление новых подсетей и стран, которых раньше не было в логах, видны только при регулярном подсчёте.
- Проверка правил. Если fail2ban отчитался о бане, а в iptables или nftables правила нет, значит действие не применяется: не та цепочка, пустой ipset, отключённый action.
- Чистка правил. Устаревшие блокировки копятся, увеличивают время обхода цепочек и иногда режут легитимный трафик.
- Отчёт для аудита безопасности. Таблица с адресами, частотой, источником и статусом в правилах закрывает вопросы службы безопасности и ускоряет разбор инцидентов.
Источники различаются по содержанию: auth.log фиксирует SSH и sudo, nginx access.log показывает HTTP-запросы и коды ответа, journalctl отдаёт сообщения systemd-юнитов и ядра (например, блокировки nftables по правилу с log). Ручной просмотр трёх разных форматов съедает часы, поэтому собираем адреса командами и сводим в один файл.
Извлекаем IP из auth.log с помощью grep и awk
Записи о неудачных входах в auth.log выглядят предсказуемо:
Failed password for root from 192.0.2.1 port 22 ssh2
Invalid user admin from 2001:db8::1 port 51234 ssh2
Быстрый сбор IPv4 с подсчётом повторов:
grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' /var/log/auth.log | sort | uniq -c | sort -nr | head -20
Почему grep -oE, а не awk: ключ -o печатает только совпадение, а не строку целиком, и результат не зависит от позиции поля. awk выигрывает в другом сценарии, когда нужно сначала отсечь лишние события по ключевому слову:
awk '/Failed password|Invalid user/ {print $(NF-3)}' /var/log/auth.log | sort | uniq -c | sort -nr
Здесь $(NF-3) берёт четвёртое поле с конца: в строке "Invalid user admin from 192.0.2.1 port 51234 ssh2" это адрес. Позиция ломается, если сервис поменяет формат сообщения, поэтому перед полным прогоном сверьте вывод на десяти строках.
Ротация. logrotate оставляет auth.log.1, auth.log.2.gz и далее, а grep видит только текущий файл. zgrep читает и обычные, и сжатые файлы:
zgrep -hoE '([0-9]{1,3}\.){3}[0-9]{1,3}' /var/log/auth.log* | sort -u > /tmp/auth_ips.txt
Фильтр по дате через отдельный grep '2026-09-15' /var/log/auth.log не сужает перебор: файл всё равно читается целиком. На больших логах комбинируйте zgrep с awk по колонке времени или переносите период в journalctl. Промежуточный список сохраняйте в файл: сравнение с правилами и отчёт работают уже с ним.
Обрабатываем IPv6-адреса в auth.log без ошибок
Regex для IPv4 в строках с IPv6 даёт пустой результат или мусор. Простой шаблон для IPv6:
grep -oE '([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{1,4}' /var/log/auth.log | sort -u
Он захватывает части MAC-адресов вида 00:1a:2b:3c:4d:5e и фрагменты других строк. Точнее работает вариант с явными группами и сжатой записью:
grep -oP '\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,7}:(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\b' /var/log/auth.log | sort -u
grep -P доступен в сборках с PCRE; там, где его нет, шаблон придётся заменить расширенным ERE или разбирать поля через awk. Смешанный сбор IPv4 и IPv6 одной командой:
grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}|([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{1,4}' /var/log/auth.log | sort -u
Ложные срабатывания отсекаются вторым проходом: IPv4 проверяйте на попадание в частные диапазоны (10.0.0.0/8, 192.168.0.0/16), а IPv6 нормализуйте перед сравнением. Иначе 2001:db8::1 и 2001:0db8:0000:0000:0000:0000:0000:0001 попадут в отчёт как два разных адреса.
Извлекаем IP из nginx access.log: grep, awk и jq
В формате combined адрес стоит первым полем, поэтому awk здесь быстрее grep: строка разбивается по пробелам, и обработка сводится к печати одного поля.
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head -20
Для аудита блокировок интересны коды отказа. Nginx возвращает 403 при срабатывании deny или geo, 444 при закрытии соединения без ответа, 429 при отказе limit_req:
awk '$9 == 403 || $9 == 444 || $9 == 429 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr
Номер поля $9 верен для формата combined. Если в log_format добавлены свои переменные, номера смещаются, и фильтр начнёт молча собирать мусор. Сверяйте формат командой nginx -T и разбирайте нестандартные форматы осознанно: готовые приёмы для этого собраны в статье Практический анализ логов Nginx и Apache: готовые команды grep и awk.
Сжатые логи читаются потоково:
zcat /var/log/nginx/access.log.*.gz | awk '{print $1}' | sort -u
За балансировщиком или CDN реальный клиентский адрес лежит в X-Forwarded-For, а $1 содержит адрес прокси. Если переменная добавлена в log_format последним полем, разбор такой:
awk '{print $NF}' /var/log/nginx/access.log | cut -d, -f1 | sort | uniq -c | sort -nr
Заголовок подделывается клиентом, поэтому доверяйте ему только для адресов своих прокси и балансировщиков. Иначе получите список выдуманных IP и неверный отчёт.
Работа с JSON-логами nginx через jq
При log_format json_combined запись выглядит так:
{"time":"2026-09-15T12:00:00+00:00","remote_addr":"192.0.2.1","status":403,"request":"GET /admin HTTP/1.1"}
jq фильтрует по любому полю и не зависит от номеров позиций:
jq -r 'select(.status == 403) | .remote_addr' /var/log/nginx/access.log | sort | uniq -c | sort -nr
Составные условия дают точные выборки, например сканирование инструментами поиска уязвимостей или отказы только за сутки:
jq -r 'select(.status == 403 and (.http_user_agent | test("sqlmap|nikto"; "i"))) | .remote_addr' /var/log/nginx/access.log | sort -u
Адрес за прокси берётся из X-Forwarded-For с откатом на remote_addr, первое значение списка и есть клиент:
jq -r '(.http_x_forwarded_for // .remote_addr) | split(",")[0] | gsub("^ +"; "")' /var/log/nginx/access.log | sort -u
По скорости jq проигрывает awk на файлах в гигабайты: разбор JSON дороже разбиения строки по пробелам. Для разового отчёта разница незаметна, для ежедневного прохода берите awk с regex или ограничивайте выборку по времени, чтобы jq читал меньший объём.
Фильтрация IP в journalctl: от systemd-юнитов до ядра
journalctl хранит записи в бинарном журнале, поэтому фильтры применяются на уровне выборки, а адреса извлекаются из текста сообщения:
journalctl -u ssh.service --since "2026-09-01" --no-pager | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | sort | uniq -c | sort -nr
Структурированный вывод надёжнее текстового разбора: -o json отдаёт поля журнала, и jq может отфильтровать записи по процессу, юниту или приоритету до извлечения адреса.
journalctl -u ssh.service -o json --since "1 day ago" | jq -r 'select(._COMM == "sshd") | .MESSAGE' | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | sort -u
Ядро и сетевой фильтр: journalctl -k --since "1 hour ago" | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | sort -u покажет адреса, которые nftables или iptables записали в журнал по правилу с log. Проверка одного адреса по всем записям за день выглядит так:
journalctl --since "2026-09-15" | grep '192.0.2.1'
Подсчёт неудачных входов по маске сообщения:
journalctl --since "1 hour ago" -u ssh.service | awk '/Failed password/ {print $(NF-3)}' | sort | uniq -c | sort -nr
Regex остаётся надёжнее привязки к позиции поля: разные версии sshd и PAM ставят адрес в разные места строки. Про ограничения журнала помните отдельно: объём ограничен параметрами SystemMaxUse и RuntimeMaxUse в journald.conf, старые записи удаляются при ротации. Для анализа за месяцы включите постоянное хранилище (Storage=persistent) или настройте экспорт журнала, иначе выборка --since вернёт пустой результат без предупреждения. Как связать события с неизменяемым следом на диске, разобрано в материале Мониторинг и аудит безопасности Nginx: журналы и интеграция с auditd.
Получаем уникальные IP и сопоставляем с активными правилами блокировки
Три источника дают три файла. Сводим их в один список без дублей и отдельно сохраняем счётчики, они понадобятся для отчёта по частоте:
cat /tmp/auth_ips.txt /tmp/nginx_ips.txt /tmp/journal_ips.txt | sort | uniq -c | sort -nr > /tmp/blocked_counted.txt
awk '{print $2}' /tmp/blocked_counted.txt | sort -u > /tmp/blocked_ips.txt
Выгрузка активных правил по инструментам:
iptables -S | awk '/ -j (DROP|REJECT)/ {for (i=1; i<=NF; i++) if ($i == "-s") print $(i+1)}' | sort -u > /tmp/rules_iptables.txt
nft -a list ruleset | grep -E 'drop|reject' | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | sort -u > /tmp/rules_nft.txt
ipset list -name | while read s; do ipset list "$s" | awk '/^[0-9]/ {print $1}'; done | sort -u > /tmp/rules_ipset.txt
fail2ban-client status sshd | awk -F: '/Banned IP list/ {print $2}' | tr ' ' '\n' | grep -v '^$' | sort -u > /tmp/rules_fail2ban.txt
iptables -S предпочтительнее iptables -L -n: он отдаёт правила в разбираемом виде и не переносит строки по ширине терминала. В nftables адреса часто лежат не в правилах, а в именованных множествах, поэтому смотрите и правила, и наборы командой nft list set inet filter <name>. Расхождения трактуются так:
- Адрес есть в логах, но отсутствует в правилах: сервис отчитался о блокировке, а фильтр её не применяет. Причины - fail2ban без action, снятие бана по таймауту, отдельная цепочка со своим хуком.
- Адрес есть в правилах, но нет в логах: правило осталось от прошлой конфигурации либо блокировка ставилась вручную. Такие записи проверяйте на попадание легитимных клиентов.
- Адрес есть в fail2ban и в ipset, но нет в iptables: часть конфигураций работает через набор адресов, и это нормально. Убедитесь, что цепочка INPUT на этот набор ссылается.
Общая логика блокировок на уровне пакетного фильтра и прикладных сервисов разобрана в статье Блокировка IP-адресов в 2026 году: стратегии, инструменты и практическая автоматизация для DevOps.
Сравнение списков с помощью comm и diff
comm требует отсортированные входные файлы и одинаковый порядок сортировки, поэтому фиксируйте локаль:
LC_ALL=C comm -23 /tmp/blocked_ips.txt /tmp/rules_iptables.txt
LC_ALL=C comm -13 /tmp/blocked_ips.txt /tmp/rules_iptables.txt
LC_ALL=C comm -12 /tmp/blocked_ips.txt /tmp/rules_iptables.txt
Ключ -23 покажет адреса только из логов, -13 только из правил, -12 общие. Без сортировки comm выдаёт предупреждение и мусорный результат, а sort без LC_ALL=C в разных локалях по-разному упорядочивает строки. Альтернатива без подготовки файлов:
diff <(sort -u /tmp/blocked_ips.txt) <(sort -u /tmp/rules_iptables.txt)
Для больших списков быстрее проверка через grep: строки логов, которых нет среди правил, находятся так:
grep -vxF -f /tmp/rules_iptables.txt /tmp/blocked_ips.txt
Ключ -x сравнивает строку целиком, -F отключает regex (точки в адресах не превращаются в шаблон), -v оставляет несовпавшие строки. Сопоставляйте не только одиночные адреса, но и подсети: nftables и iptables часто оперируют блоками вида 192.0.2.0/24, и адрес из лога не совпадёт со строкой правила. Для точечной проверки используйте запрос к самому фильтру:
iptables -C INPUT -s 192.0.2.1 -j DROP; echo $?
Код возврата 0 означает, что правило существует, 1 - что его нет. Это единственный способ подтвердить блокировку без просмотра всего набора правил глазами.
Строим отчёт по частоте и источникам блокировок
Сырой список превращается в отчёт за три среза: по адресам, по подсетям, по времени. Топ адресов берётся из файла со счётчиками:
head -20 /tmp/blocked_counted.txt
Группировка по подсетям /24 показывает, что источник бьёт диапазоном, а не одним адресом:
awk '{print $1}' /tmp/blocked_counted.txt | awk -F. 'NF == 4 {print $1"."$2"."$3".0/24"}' | sort | uniq -c | sort -nr | head -20
Для IPv6 группируйте по /64, но учитывайте сжатую запись: awk -F: '{print $1":"$2":"$3":"$4"::/64"}' корректен только для полной нотации, поэтому адреса нормализуйте заранее.
Геопривязка через GeoIP:
cat /tmp/blocked_ips.txt | xargs -I{} geoiplookup {} | awk -F': ' '{print $2}' | cut -d, -f1 | sort | uniq -c | sort -nr
xargs запускает процесс на каждый адрес, поэтому на десятках тысяч строк операция занимает минуты. Для регулярного отчёта читайте базу один раз (mmdblookup или ipv6calc для нормализации) либо считайте страны пачками. Базы GeoIP устаревают, обновляйте их вместе с остальными справочниками, иначе отчёт покажет маршруты прошлых лет.
Временной срез: awk '{print $4}' /var/log/nginx/access.log | cut -d: -f2 | sort | uniq -c даёт распределение по часам, а cut -d: -f2,3 добавляет минуты для поиска коротких всплесков.
Выявление аномалий: всплески, новые источники, повторные попытки
Подозрительны три картины. Первая: резкий рост числа блокировок за минуту с одного адреса. Вторая: адреса из подсетей, которых не было в предыдущих отчётах. Третья: повторяющиеся попытки с ровным интервалом, характерным для автоматического перебора.
Порог по количеству событий с одного адреса:
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | awk '$1 > 1000' | sort -nr
Сравнение с предыдущим периодом делается по сохранённым отчётам: diff /var/log/ip-audit/report_2026-09-14.txt /var/log/ip-audit/report_2026-09-15.txt. Новые строки в выводе и есть новые источники. Автоматический алерт ставьте на два условия: адрес впервые попал в список блокировок и число событий с него превысило порог.
Поля отчёта, которые закрывают вопросы аудита:
| Поле | Источник данных | Зачем нужно |
|---|---|---|
| IP-адрес | grep, awk или jq по логам | идентификация источника активности |
| Число событий | sort | uniq -c | оценка интенсивности попыток |
| Первое и последнее появление | awk по колонке времени | длительность активности |
| Подсеть /24 или /64 | группировка awk | выявление сканирования диапазона |
| Страна | GeoIP-база | география, нетипичная для вашего сервиса |
| Источник записи | файл лога или systemd-юнит | какой сервис зафиксировал событие |
| Статус в правилах | comm или grep по выгрузке правил | подтверждение реальной блокировки |
Как встроить такой отчёт в общий контур мониторинга, показано в материале Мониторинг безопасности и обнаружение вторжений: auditd, Suricata и Wazuh на практике.
Автоматизируем аудит: скрипт-заготовка для регулярного запуска
Скрипт делает пять шагов: собирает IP из трёх источников, считает уникальные, выгружает активные правила, сравнивает списки и формирует отчёт, а при расхождениях пишет предупреждение.
#!/bin/bash
set -uo pipefail
SINCE="${SINCE:-1 day ago}"
OUT="/var/log/ip-audit"
DATE="$(date +%F)"
mkdir -p "$OUT"
grep -hoE '([0-9]{1,3}\.){3}[0-9]{1,3}' /var/log/auth.log* 2>/dev/null | sort -u > "$OUT/auth.txt"
awk '$9 == 403 || $9 == 444 || $9 == 429 {print $1}' /var/log/nginx/access.log 2>/dev/null | sort -u > "$OUT/nginx.txt"
journalctl -u ssh.service --since "$SINCE" --no-pager 2>/dev/null | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | sort -u > "$OUT/journal.txt"
cat "$OUT"/auth.txt "$OUT"/nginx.txt "$OUT"/journal.txt 2>/dev/null | sort | uniq -c | sort -nr > "$OUT/counted_$DATE.txt"
awk '{print $2}' "$OUT/counted_$DATE.txt" | sort -u > "$OUT/ips_$DATE.txt"
iptables -S 2>/dev/null | awk '/ -j (DROP|REJECT)/ {for (i=1; i<=NF; i++) if ($i == "-s") print $(i+1)}' | sort -u > "$OUT/rules_filter.txt"
nft -a list ruleset 2>/dev/null | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | sort -u >> "$OUT/rules_filter.txt"
sort -u -o "$OUT/rules_all.txt" "$OUT/rules_filter.txt"
LC_ALL=C comm -23 "$OUT/ips_$DATE.txt" "$OUT/rules_all.txt" > "$OUT/not_blocked_$DATE.txt"
COUNT="$(wc -l < "$OUT/not_blocked_$DATE.txt")"
if [ "$COUNT" -gt 0 ]; then
echo "ВНИМАНИЕ: $COUNT адресов заблокированы в логах и отсутствуют в правилах, файл $OUT/not_blocked_$DATE.txt"
curl -s -X POST "${WEBHOOK_URL:-}" -d "text=IP-аудит: $COUNT расхождений" > /dev/null
fi
echo "Отчёт: $OUT/counted_$DATE.txt, расхождений: $COUNT"
Почему set -uo pipefail без -e: grep и journalctl возвращают ненулевой код, когда совпадений нет, и с -e скрипт оборвётся на первой пустой выборке. Обработка ошибок строится на проверке результата, а не на коде выхода. Права на файл скрипта держите 750, запуск от root: чтение auth.log и правил пакетного фильтра требует привилегий. Расширение под свои сервисы сводится к добавлению своей строки сбора в блок источников и своей выгрузки правил.
Настройка cron и логирование работы скрипта
0 3 * * * /usr/local/bin/audit_blocked_ips.sh >> /var/log/audit_blocked_ips.log 2>&1
Перенаправление 2>&1 обязательно: без него сообщения об ошибках уйдут в почту root, которую на серверах часто не читают. Ротация лога скрипта настраивается так: /var/log/audit_blocked_ips.log { daily rotate 14 compress missingok notifempty }
Ограничивайте диапазон переменной SINCE (1 day ago, 2 hours ago). Прогон по журналу за месяцы на нагруженном сервере читает гигабайты и держит диск занятым, а отчёт по давно снятым банам всё равно не нужен. Дубль результата в syslog даёт команда logger -t ip-audit "расхождений: $COUNT": запись попадёт в journalctl и будет видна в общей картине сервера.
Типичные ошибки и подводные камни при парсинге логов
- grep без -o печатает строку целиком, и sort -u считает уникальными разные события с одним адресом. Решение: ключи -oE.
- Широкий regex для IPv6 захватывает MAC-адреса и части меток времени. Решение: проверить шаблон на выборке в 50 строк и ужесточить при мусоре.
- Просмотр только текущего файла: архивы logrotate остаются неучтёнными. Решение: zgrep или zcat по маске *.gz.
- Построчный grep по файлам в гигабайты вместо потоковой обработки. Решение: awk по нужному полю и ограничение периода через --since в journalctl.
- Путаница с номерами полей nginx при нестандартном log_format. Решение: сверить формат через nginx -T до фильтра по $9.
- Сравнение через comm без сортировки и без LC_ALL=C. Решение: sort -u с фиксированной локалью для обоих файлов.
- Игнорирование X-Forwarded-For за прокси или CDN. Решение: брать клиентский адрес из заголовка, но доверять ему только от известных прокси.
- Слишком частая автоматизация: аудит каждый час на большом логе тратит CPU. Решение: один прогон в сутки ночью плюс отдельный быстрый алерт по всплескам.
По производительности: на файлах свыше 1 ГБ заметную выгоду даёт awk с одним проходом, а JSON разбирайте через jq только там, где он действительно есть в логах. Параллельный запуск (parallel или xargs -P) помогает на независимых файлах, но осторожно с диском: несколько одновременных читателей дают случайный доступ и замедляют прогон.
Чек-лист для регулярного аудита заблокированных IP
- Перечислить источники: /var/log/auth.log с архивами, /var/log/nginx/access.log и его ротации, юниты в journalctl (ssh, nginx, ядро через -k).
- Проверить реальные форматы: log_format в nginx через nginx -T, строки sshd в auth.log, наличие JSON-полей в журнале.
- Собрать IP из каждого источника и сохранить список со счётчиками (uniq -c) отдельно от уникального списка.
- Выгрузить активные блокировки: iptables -S, nft list ruleset и именованные наборы, ipset list, fail2ban-client status по каждому jail.
- Сопоставить списки через comm или grep -vxF и зафиксировать оба типа расхождений.
- Построить отчёт: топ адресов, подсети, часы, страны, статус в правилах.
- Автоматизировать: скрипт в /usr/local/bin, запуск по cron, лог с ротацией, ограничение периода выборки.
- Настроить уведомления при всплеске и при появлении адресов, которых нет в правилах.
- Проверить защиту практикой: добавить тестовый адрес из документации (192.0.2.1), убедиться, что пакет отбрасывается, и подтвердить наличие правила командой iptables -C.
- Хранить отчёты по датам и раз в месяц пересматривать правила: снимать устаревшие баны и обновлять базы GeoIP.
Масштаб инфраструктуры меняет частоту и источники, а не методику. На одном сервере хватит суточного прогона, в парке из десятков машин те же команды собирают через централизованный сбор логов, а сравнение проводят на агрегированном списке.