IPv6-зоны в URL: почему Go падает на fe80::%eth0 и как это чинить

Если вы пишете на Go сервис, который ходит по локальной сети через IPv6, и ловите странную ошибку парсинга URL — дело в зонах. Разбираем, почему %eth0 ломает стандартную библиотеку и как это чинить по RFC 6874.

Обложка: IPv6-зоны в URL: почему Go падает на fe80::%eth0 и как это чинить

В 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 — числовой идентификатор интерфейса. Полный адрес выглядит так:

			fe80::4%eth0
[fe80::4]:80
[fe80::4%eth0]:80
		

Квадратные скобки отделяют хост от порта — иначе двоеточия IPv6-адреса спутаются с разделителем порта.

Конфликт зон и URL

Теперь вставим этот адрес в URL. На первый взгляд всё просто:

			http://[fe80::4%eth0]:80
		

Но попробуем распарсить его в Go:

			package main

import "net/url"

func main() {
    if _, err := url.Parse("http://[fe80::4%eth0]:80"); err != nil {
        panic(err)
    }
}
		

Получаем ошибку:

			panic: parse "http://[fe80::4%eth0]:80": invalid URL escape "%et"
		

Что произошло? В URL любой символ, не входящий в разрешённый набор, должен быть percent-encoded. Пробел превращается в %20, кириллица — в последовательности вроде %D0%90. Парсер видит %e и пытается декодировать его как hex-последовательность. et — не валидный байт, поэтому URL отклоняется.

Почему Go падает и как это чинить

С точки зрения стандарта Go ведёт себя корректно. RFC 3986 определяет URL-грамматику, а RFC 6874 специально дополняет её для IPv6-зон: символ % перед zone ID должен быть сам закодирован как %25.

Правильный URL выглядит так:

			http://[fe80::4%25eth0]:80
		

Проверяем в Go:

			package main

import (
    "fmt"
    "net/url"
)

func main() {
    u, err := url.Parse("http://[fe80::4%25eth0]:80")
    if err != nil {
        panic(err)
    }
    fmt.Println(u.Hostname())
}
		

Вывод:

			fe80::4%eth0
		

Go корректно декодирует %25 обратно в % при извлечении хоста. То есть библиотека поддерживает RFC 6874, но требует от вызывающего кода заранее закодировать зону.

RFC 6874: это не баг, а фича

В RFC 6874 формально описан синтаксис IPv6-адресов с зонами в литералах URL. Ключевой фрагмент:

			IP-literal    = "[" ( IPv6address / IPv6addrz / IPvFuture ) "]"
ZoneID        = 1*( unreserved / pct-encoded )
IPv6addrz     = IPv6address "%25" ZoneID
		

То есть зона записывается не как %eth0, а как %25eth0. Это выглядит ужасно с точки зрения пользовательского опыта, но таково решение стандартизации: совместимость с существующей URL-грамматикой важнее эргономики.

Наша индустрия меня удивляет. Стандарт говорит: чтобы записать обычный символ процента в адресе, нужно его самого закодировать процентами. Это ужасно, но, похоже, это граничный случай, который касается не только Go.
Xe Iaso (Cadey)Разработчик, автор блога xeiaso.net

И действительно, та же проблема есть и в других инструментах:

  • nginxтикет #623, созданный более десяти лет назад; проблема отсутствия поддержки link-local адресов с зонами до сих пор актуальна.
  • Python requestsissue #6808: даже при ручном кодировании % как %25 библиотека некорректно обрабатывает IPv6-зоны в URL, потому что urllib3 декодирует %25 обратно в %.
  • Браузеры — draft Schinazi объясняет, почему зоны ломают концепцию origin, и рекомендует использовать mDNS вместо прямого указания link-local адресов в URI.

Что делать разработчику

Если ваше Go-приложение работает с локальными IPv6-адресами — например, подключается к сервисам в Docker-сети, IoT-устройствам или внутренним API через link-local — учитывайте следующее:

  1. Перед передачей IPv6-адреса с зоной в url.Parse всегда экранируйте % как %25.
  2. Используйте net.JoinHostPort для сборки host:port — он корректно оборачивает IPv6 в скобки, но не кодирует зону. Дополнительное кодирование остаётся на вас.
  3. Если адрес приходит от пользователя, валидируйте его до парсинга: зона должна содержать только допустимые символы (имя интерфейса в Linux, числовой ID в Windows).
  4. Тестируйте на реальных интерфейсах с разными зонами, чтобы убедиться, что кодирование работает корректно в вашей среде.
			package main

import (
    "fmt"
    "net"
    "strings"
)

// ipv6ZoneURL собирает URL с IPv6-адресом и зоной интерфейса.
func ipv6ZoneURL(host, zone, port string) string {
    addr := net.JoinHostPort(host+"%25"+zone, port)
    return "http://" + addr
}

// Альтернатива: если адрес уже содержит %zone, экранируйте %.
func escapeZone(addr string) string {
    return strings.ReplaceAll(addr, "%", "%25")
}
		
На заметку:
Если вы пишете HTTP-клиент для embedded-устройств или промышленных контроллеров, которые общаются через link-local IPv6, ручное кодирование зоны — не костыль, а необходимость. Большинство библиотек не делают этого автоматически.

FAQ

Часто задаваемые вопросы
1
Что такое link-local адрес в IPv6?

Это адрес из диапазона fe80::/10, который действует только в пределах одного сегмента локальной сети. Он не маршрутизируется через интернет и используется для автоконфигурации, neighbour discovery и локального обмена данными.

2
Почему в Linux зона — это имя интерфейса, а в Windows — число?

RFC не диктует конкретный формат зоны, а лишь определяет её как непрозрачную строку. Linux традиционно использует имена интерфейсов (eth0, wlan0), потому что они человекочитаемы и стабильны в пределах сессии. Windows использует числовые идентификаторы, назначаемые операционной системой.

3
Можно ли использовать зоны в браузере?

В целом — нет. Браузеры не поддерживают IPv6-зоны в URL, потому что зона ломает модель origin (同一来源策略), на которой строится безопасность веба. RFC 6874 (2013) определял синтаксис зон в URI, но его реализация в браузерах оказалась слишком сложной. Draft Schinazi (2024) объявляет RFC 6874 устаревшим и предлагает альтернативу через mDNS.

4
Go единственный язык с этой проблемой?

Нет. Любая библиотека, строго следующая RFC 3986, столкнётся с тем же. Проблема известна в nginx, Python requests и многих других инструментах. Разница лишь в том, насколько явно документировано это поведение.

5
Как проверить, правильно ли закодирован 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