IPv6-зоны в URL: почему Go падает на fe80::%eth0 и как это чинить
Если вы пишете на Go сервис, который ходит по локальной сети через IPv6, и ловите странную ошибку парсинга URL — дело в зонах. Разбираем, почему %eth0 ломает стандартную библиотеку и как это чинить по RFC 6874.
В URL с IPv6 link-local адресами символ % зоны интерфейса нужно кодировать как %25 — иначе парсер Go выбросит ошибку. Если вы пишете сервис, который ходит по локальной сети через IPv6, и ловите странную ошибку парсинга URL, скорее всего, вы столкнулись с одним из самых неочевидных граничных случаев современной работы с сетями.
В IPv6 каждый сетевой интерфейс получает link-local адрес из диапазона fe80::/10 (первые 10 бит фиксированы, остальное — адрес интерфейса). Если у машины два интерфейса — например, Ethernet и Wi-Fi — оба будут в одном и том же префиксе. Вопрос: как операционная система понимает, к какому именно интерфейсу адресовать пакет? Ответ — зоны (scopes).
Ключевые выводы
В IPv6 зона интерфейса записывается через %: fe80::4%eth0. Это нужно, чтобы различать link-local адреса на разных сетевых интерфейсах.
В URL зона попадает внутрь квадратных скобок: [fe80::4%eth0]:80. Но символ % в URL — это начало percent-encoding, поэтому парсер ломается.
Решение — экранировать % как %25: [fe80::4%25eth0]:80. Это поведение зафиксировано в RFC 6874.
Проблема затрагивает не только Go, но и nginx, Python requests и браузеры. Поддержка зон в HTTP-клиентах остаётся фрагментарной.
Как зоны работают в IPv6
Зона (или scope ID) — это механизм, позволяющий ядру отличать адреса из пересекающихся диапазонов. Для link-local адресов fe80::/10 он критичен: без него роутинговая таблица не поймёт, через какой интерфейс отправлять трафик.
Формат зоны зависит от ОС. В Linux это имя интерфейса — eth0, wlan0, ens192. В Windows — числовой идентификатор интерфейса. Полный адрес выглядит так:
Квадратные скобки отделяют хост от порта — иначе двоеточия IPv6-адреса спутаются с разделителем порта.
Конфликт зон и URL
Теперь вставим этот адрес в URL. На первый взгляд всё просто:
Но попробуем распарсить его в Go:
Получаем ошибку:
Что произошло? В URL любой символ, не входящий в разрешённый набор, должен быть percent-encoded. Пробел превращается в %20, кириллица — в последовательности вроде %D0%90. Парсер видит %e и пытается декодировать его как hex-последовательность. et — не валидный байт, поэтому URL отклоняется.
Почему Go падает и как это чинить
С точки зрения стандарта Go ведёт себя корректно. RFC 3986 определяет URL-грамматику, а RFC 6874 специально дополняет её для IPv6-зон: символ % перед zone ID должен быть сам закодирован как %25.
Правильный URL выглядит так:
Проверяем в Go:
Вывод:
Go корректно декодирует %25 обратно в % при извлечении хоста. То есть библиотека поддерживает RFC 6874, но требует от вызывающего кода заранее закодировать зону.
RFC 6874: это не баг, а фича
В RFC 6874 формально описан синтаксис IPv6-адресов с зонами в литералах URL. Ключевой фрагмент:
То есть зона записывается не как %eth0, а как %25eth0. Это выглядит ужасно с точки зрения пользовательского опыта, но таково решение стандартизации: совместимость с существующей URL-грамматикой важнее эргономики.
Наша индустрия меня удивляет. Стандарт говорит: чтобы записать обычный символ процента в адресе, нужно его самого закодировать процентами. Это ужасно, но, похоже, это граничный случай, который касается не только Go.
И действительно, та же проблема есть и в других инструментах:
- nginx — тикет #623, созданный более десяти лет назад; проблема отсутствия поддержки link-local адресов с зонами до сих пор актуальна.
- Python requests — issue #6808: даже при ручном кодировании
%как%25библиотека некорректно обрабатывает IPv6-зоны в URL, потому что urllib3 декодирует%25обратно в%. - Браузеры — draft Schinazi объясняет, почему зоны ломают концепцию origin, и рекомендует использовать mDNS вместо прямого указания link-local адресов в URI.
Что делать разработчику
Если ваше Go-приложение работает с локальными IPv6-адресами — например, подключается к сервисам в Docker-сети, IoT-устройствам или внутренним API через link-local — учитывайте следующее:
- Перед передачей IPv6-адреса с зоной в
url.Parseвсегда экранируйте%как%25. - Используйте
net.JoinHostPortдля сборкиhost:port— он корректно оборачивает IPv6 в скобки, но не кодирует зону. Дополнительное кодирование остаётся на вас. - Если адрес приходит от пользователя, валидируйте его до парсинга: зона должна содержать только допустимые символы (имя интерфейса в Linux, числовой ID в Windows).
- Тестируйте на реальных интерфейсах с разными зонами, чтобы убедиться, что кодирование работает корректно в вашей среде.
На заметку:
Если вы пишете HTTP-клиент для embedded-устройств или промышленных контроллеров, которые общаются через link-local IPv6, ручное кодирование зоны — не костыль, а необходимость. Большинство библиотек не делают этого автоматически.
FAQ
Часто задаваемые вопросы
Что такое link-local адрес в IPv6?
Это адрес из диапазона fe80::/10, который действует только в пределах одного сегмента локальной сети. Он не маршрутизируется через интернет и используется для автоконфигурации, neighbour discovery и локального обмена данными.
Почему в Linux зона — это имя интерфейса, а в Windows — число?
RFC не диктует конкретный формат зоны, а лишь определяет её как непрозрачную строку. Linux традиционно использует имена интерфейсов (eth0, wlan0), потому что они человекочитаемы и стабильны в пределах сессии. Windows использует числовые идентификаторы, назначаемые операционной системой.
Можно ли использовать зоны в браузере?
В целом — нет. Браузеры не поддерживают IPv6-зоны в URL, потому что зона ломает модель origin (同一来源策略), на которой строится безопасность веба. RFC 6874 (2013) определял синтаксис зон в URI, но его реализация в браузерах оказалась слишком сложной. Draft Schinazi (2024) объявляет RFC 6874 устаревшим и предлагает альтернативу через mDNS.
Go единственный язык с этой проблемой?
Нет. Любая библиотека, строго следующая RFC 3986, столкнётся с тем же. Проблема известна в nginx, Python requests и многих других инструментах. Разница лишь в том, насколько явно документировано это поведение.
Как проверить, правильно ли закодирован URL с зоной?
Убедитесь, что после открывающей скобки [ зона записана как %25, а не как просто %. Затем передайте строку в url.Parse: если ошибки нет — кодирование корректно. Также полезно проверить, что u.Hostname() возвращает адрес с незакодированным %, а u.String() — с %25.
Выводы
IPv6-зоны — редкий, но живучий граничный случай. Если вы пишете сетевой код на Go, который должен работать в гетерогенных средах — Docker, Kubernetes, embedded-системы, промышленные сети — знайте, что fe80::1%eth0 в URL превращается в fe80::1%25eth0. Это не баг парсера, а требование стандарта RFC 6874.
Инкапсулируйте кодирование зоны во вспомогательную функцию и всегда прогоняйте IPv6-адреса через неё перед сборкой URL. Экономия пяти минут сейчас обернётся часом отладки в продакшене, когда сервис внезапно не сможет достучаться до соседнего контейнера по link-local.
Источники:
• Xe Iaso — IPv6 zones in Go URLs
• RFC 6874 — Representing IPv6 Zone Identifiers in Address Literals and Uniform Resource Identifiers
• draft-schinazi-httpbis-link-local-uri-bcp-03 — IPv6 Link-Local URIs