Заявка была скучная: клиент не проходит проверку по списку разрешённых адресов. Адрес его я вижу, адрес в списке есть, файрвол пакет пропускает. Приложение отвечает «доступ запрещён».
В логе стояло ::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: сгруппировать события за неделю по полю адреса источника и посмотреть, нет ли пар записей, отличающихся только префиксом. Такая пара — это правило корреляции, которое у вас уже не сработало. Просто вы об этом ещё не знаете.

