Заявка была скучная: клиент не проходит проверку по списку разрешённых адресов. Адрес его я вижу, адрес в списке есть, файрвол пакет пропускает. Приложение отвечает «доступ запрещён».

В логе стояло ::ffff:192.0.2.5, в конфиге — 192.0.2.5. Что строки разные, видно сразу. Но выглядит это как разное написание одного адреса: справа те же четыре октета, слева приписка, которую принимаешь за особенность формата. Поэтому проверять я пошёл конфиг, а не сравнение.

Откуда в логе берётся такая запись

Это IPv4-mapped IPv6 адрес, описанный в RFC 4291. Формат простой: 80 нулевых бит, потом 16 единичных, потом привычные четыре октета. Весь диапазон — ::ffff:0:0/96.

Появляется он не из-за клиента — клиент пришёл по обычному IPv4. Появляется он из-за того, как устроен слушающий сокет на вашей стороне: если он один и поднят на ::, IPv4-соединения попадают в него же.

Если процесс слушает :: и опция IPV6_V6ONLY выключена, ядро отдаёт ему и IPv6-, и IPv4-соединения через один сокет. Но поле адреса в sockaddr_in6 — ровно шестнадцать байт, короче туда положить нечего, поэтому IPv4-адрес дополняется префиксом ::ffff: до полной длины. Приложение получает то, что получает:

import socket

s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
print("IPV6_V6ONLY =", s.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY))
s.bind(("::", 8099)); s.listen(1)
conn, addr = s.accept()
print("peer =", addr)
print("family =", int(conn.family))
IPV6_V6ONLY = 0
peer = ('::ffff:127.0.0.1', 35244, 0, 0)
family = 10

Подключение пришло на 127.0.0.1, то есть по IPv4. Сокет отчитался о шестом семействе и о завёрнутом адресе.

Ноль в первой строке — это не чья-то настройка, а состояние по умолчанию в Linux:

$ cat /proc/sys/net/ipv6/bindv6only
0

Отсюда и весь сюжет. Пока в системе был только IPv4, слушатель поднимался на 0.0.0.0 и писал в лог четыре октета. Потом сервис переехал на :: — и формат адреса в логах поменялся у всех сразу, включая тех, кто про IPv6 не слышал.

Сразу оговорка про nginx, потому что подозрение падает на него первым: он с версии 1.3.4 ставит ipv6only=on по умолчанию, поэтому сам по себе listen [::]:443 мапленных адресов не даёт. Их даёт приложение, которое просто вызвало bind("::") и сокет-опции не трогало, — самописный сервис, JVM без java.net.preferIPv4Stack, средний веб-фреймворк в режиме «слушать везде».

Почему сравнение не срабатывает

Ожидаемо ломается сравнение строк — тут вопросов нет, "::ffff:192.0.2.5" != "192.0.2.5". Неожиданно другое: специализированная библиотека, которая разбирает адрес в объект, а не сравнивает строки, ведёт себя так же.

import ipaddress
a = ipaddress.ip_address('::ffff:192.0.2.5')
print(repr(a), a.version, a.ipv4_mapped)
print(a == ipaddress.ip_address('192.0.2.5'))
print(a in ipaddress.ip_network('192.0.2.0/24'))
print(a.ipv4_mapped in ipaddress.ip_network('192.0.2.0/24'))
IPv6Address('::ffff:192.0.2.5') 6 192.0.2.5
False
False
True

Разберу вывод по строкам.

version равен шести. Для библиотеки это полноценный IPv6-адрес, а не запись об IPv4.

Сравнение с 192.0.2.5 даёт False. Объекты разных классов, никакого приведения не происходит.

Проверка вхождения в сеть 192.0.2.0/24 тоже даёт False — и это самое неприятное место. Не исключение, не ошибка типов, а спокойный отрицательный ответ. Код отрабатывает штатно, тест на «адрес не из сети — отказать» проходит, ревью проходит тоже. Правильный ответ достаётся только через .ipv4_mapped, о котором нужно знать заранее.

Где расхождение ломает проверки

Списки разрешённых адресов в приложении. Сценарий из заявки. Проверка написана на строках или на ip_address, слушатель переехал на дуал-стек, доступ у половины партнёров пропал. Обратный случай хуже: если список запрещающий, то запись 192.0.2.5 в нём перестаёт совпадать с приходящим ::ffff:192.0.2.5, и заблокированный источник снова проходит.

Разбор логов по регулярным выражениям. fail2ban и его самописные аналоги ловят адрес шаблоном. Шаблон под четыре октета на ::ffff:192.0.2.5 либо не срабатывает вовсе, либо выхватывает из строки хвост 192.0.2.5 и банит его отдельно от того, что уже забанено. В свежих версиях fail2ban с этим разобрались, но регулярка в filter.d пишется руками, и правит её обычно тот, кто про маппинг не думал.

Корреляция в SIEM. Здесь потери самые тихие. Один источник приезжает под двумя ключами: с периметрового устройства — как 192.0.2.5, с приложения — как ::ffff:192.0.2.5. Правило «пять отказов с одного адреса за минуту» видит две группы по три и не срабатывает ни на одной. Обогащение по геолокации и по репутации тоже промахивается: многие фиды и обогатители завёрнутый адрес не разворачивают, и запрос уходит в IPv6-пространство, где про этот источник ничего нет.

Отчёты и дедупликация. В сводке за месяц один и тот же источник занимает две строки. Сортировка по строке разводит их: все ::ffff:… собираются в отдельный блок после числовых адресов, и в сводке это выглядит как два независимых источника, а не как одна строка с приставкой.

Почему файрвол при этом работает

Тот самый вопрос, на котором я и застрял: если адрес «другой», почему пакет проходит фильтр, написанный под IPv4?

Потому что в сети никакого IPv6 нет. По проводу идёт обычный IPv4-пакет с обычным двадцатибайтовым заголовком. iptables разбирает его до того, как дело доходит до сокета, и видит 192.0.2.5. Маппинг живёт исключительно в интерфейсе между ядром и приложением — это форма представления адреса в структуре sockaddr_in6, а не то, что летает по кабелю.

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

Как это чинить

Нормализовать адрес на входе, один раз. Не в каждой проверке, а в том месте, где адрес попадает в систему из сокета. Дальше по коду должен ходить уже приведённый вид:

def normalize(addr: str) -> str:
    ip = ipaddress.ip_address(addr)
    return str(ip.ipv4_mapped) if ip.ipv4_mapped else str(ip)

Отдельно проверьте X-Forwarded-For: если заголовок ставит прокси, работающий на дуал-стек сокете, завёрнутый адрес приедет и туда, а разбирают этот заголовок обычно вручную.

В базе хранить не строку. В PostgreSQL есть тип inet, в MySQL — INET6_ATON/INET6_NTOA: сравнение идёт по значению, а не по написанию, и вопросы про ведущие нули и регистр снимаются сами. Но маппинг типизированное хранение не лечит: inet '::ffff:192.0.2.5' не равен inet '192.0.2.5' и не входит в 192.0.2.0/24, потому что у них разные семейства адресов. Нормализацию всё равно надо делать до вставки. В MySQL к тому же нет готовой проверки вхождения в подсеть — её придётся выражать диапазоном.

Либо развести слушателей. IPV6_V6ONLY=1 (в nginx — listen [::]:443 ipv6only=on; плюс отдельная строка под IPv4) даёт два сокета и два формата адреса, каждый в своём привычном виде. Вариант менее гибкий, зато поведение перестаёт зависеть от значения sysctl на конкретном хосте.

Проверить регулярки в разборщиках логов. Шаблон адреса должен допускать необязательный префикс: (?:::ffff:)? перед четырьмя октетами. Правка механическая и делается за один заход по всем фильтрам, но проверьте, что префикс попал внутрь группы <HOST>, а не остался снаружи, — иначе тот же источник забанится вторым ключом.

Что посмотреть у себя

Открыть access-лог и поискать в нём подстроку ::ffff:. Если она есть — дальше проверять по списку выше. Если её нет, а IPv6 на балансировщике включали, стоит убедиться, что нормализация не потеряла информацию: обратная ошибка, когда завёрнутый адрес молча урезают до четырёх октетов и теряют настоящие IPv6-подключения, встречается не реже.

И заглянуть в SIEM: сгруппировать события за неделю по полю адреса источника и посмотреть, нет ли пар записей, отличающихся только префиксом. Такая пара — это правило корреляции, которое у вас уже не сработало. Просто вы об этом ещё не знаете.