Почему IPv6-прокси не работают на сайтах с dual-stack: диагностика через AAAA-записи
Содержание
- Что происходит при подключении к dual-stack сайту
- Почему AAAA-запись ломает маршрутизацию через прокси
- Диагностика через dig и getent
- Настройка gai.conf и приоритетов
- Кейс: nginx 1.24 как reverse proxy перед IPv6-бэкендом
- server [::1]:8080; # убрать или закомментировать
- Python requests и urllib3: скрытый выбор
- Принудительно IPv4
- SOCKS5 и ATYP: где рвётся
- Прокси-серверы с поддержкой IPv6: сравнение
- Когда IPv6-прокси оправдан
- Практический чеклист диагностики
Что происходит при подключении к dual-stack сайту
Клиент резолвит домен. Получает A и AAAA. Дальше начинается самое интересное — поведение зависит от операционной системы, версии glibc, настроек резолвера и того, какой сокет открывает приложение.
Стандартная логика из RFC 6724 говорит: IPv6 в приоритете. Если у клиента есть глобальный IPv6-адрес и AAAA-запись резолвится — соединение пойдёт по IPv6. Прокси тут ни при чём, если он не умеет перехватывать IPv6-трафик.
Типичная картина: у вас настроен SOCKS5-прокси на IPv4-адресе, браузер или curl его используют, но при этом система имеет native IPv6. Приложение резолвит AAAA, видит IPv6-адрес назначения и... идёт напрямую. Прокси остаётся в стороне. Трафик утекает мимо.
```
\$ curl -v --socks5 127.0.0.1:1080 https://example.com
* Trying 127.0.0.1:1080...
* SOCKS5 connect to [2606:2800:220:1:248:1893:25c8:1946]:443
```
Вот оно. curl через SOCKS5 попытался подключиться к IPv6-адресу. Если прокси не поддерживает IPv6-направление — соединение отвалится с ошибкой `SOCKS5 connect failed` или зависнет на таймауте.
Почему AAAA-запись ломает маршрутизацию через прокси
Прокси-сервер получает от клиента команду CONNECT с адресом назначения. Адрес может быть в формате IPv4 или IPv6. SOCKS5 поддерживает оба типа через разные ATYP: 0x01 для IPv4, 0x04 для IPv6, 0x03 для доменного имени.
Проблема в том, что многие прокси-серверы (особенно старые сборки на базе Dante, 3proxy, squid) собираются без флага `--enable-ipv6` или его аналогов. Они физически не могут открыть исходящий сокет к IPv6-адресу. Клиент присылает ATYP=0x04, сервер возвращает `0x07 Command not supported` или просто рвёт соединение.
Второй сценарий — прокси работает, но на IPv4-only хосте. Клиент шлёт IPv6-адрес назначения, прокси не может его зарезолвить в IPv4 и отдаёт ошибку. При этом A-запись у домена есть, но клиент её уже не спрашивает — он выбрал AAAA.
Третий, самый коварный случай: прокси поддерживает и IPv4, и IPv6, но выходной интерфейс имеет только IPv4-адрес. Соединение к IPv6-назначению уходит в никуда, таймаут 30 секунд, потом ошибка. Пользователь видит «сайт не открывается», хотя проблема в одной строке конфига.
Диагностика через dig и getent
Первое, что нужно сделать — понять, что вообще резолвится.
```bash
dig A example.com +short
dig AAAA example.com +short
```
Если AAAA есть — половина проблем уже понятна. Дальше смотрим, что видит само приложение. `getent` использует ту же цепочку NSS, что и большинство программ.
```bash
getent ahosts example.com
```
Порядок вывода покажет приоритет. Если IPv6 идёт первым — приложение по умолчанию пойдёт туда.
Проверить, какой адрес реально выбирается, можно через `strace` на открытие сокета:
```bash
strace -f -e trace=connect curl -s https://example.com -o /dev/null 2>&1 | grep connect
```
Увидите `sin6_family=AF_INET6` или `sin_family=AF_INET`. Это снимает все догадки.
Настройка gai.conf и приоритетов
Linux использует `/etc/gai.conf` для управления порядком адресов из getaddrinfo. По умолчанию RFC 6724 даёт IPv6 приоритет 40, IPv4 — 10. Можно поменять.
```
precedence ::ffff:0:0/96 100
```
Эта строка заставляет glibc предпочитать IPv4-mapped адреса, то есть фактически IPv4. Костыль, но работает без пересборки приложений.
Проблема: не все программы используют getaddrinfo. Go, например, имеет собственный резолвер, если не собран с тегом `netgo`. Java тоже резолвит по-своему через `java.net.preferIPv4Stack`. Так что gai.conf — не серебряная пуля.
Проверить, какой резолвер использует бинарник:
```bash
ldd \$(which curl) | grep -i resolv
```
Если линковка с libc — gai.conf подействует. Если статическая сборка или Go — нет.
Кейс: nginx 1.24 как reverse proxy перед IPv6-бэкендом
Сервер на Ubuntu 22.04, nginx 1.24.0. Апстрим — приложение на `[::1]:8080`. Клиенты ходят через IPv4-прокси. Симптом: 502 Bad Gateway на половине запросов.
Причина: nginx резолвит имя апстрима через getaddrinfo, получает и A, и AAAA. В конфиге `proxy_pass http://backend;` без указания семейства. Nginx выбирает первый адрес из списка — иногда IPv4, иногда IPv6. Если выбран IPv6, а `resolver` не настроен для динамического резолва — соединение падает.
Решение: явно указать семейство.
```nginx
upstream backend {
server 127.0.0.1:8080;
server [::1]:8080; # убрать или закомментировать
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_next_upstream error timeout;
}
}
```
Либо использовать переменную с явным resolver:
```nginx
resolver 127.0.0.53 valid=30s ipv6=off;
set \$backend "http://backend.example.com";
proxy_pass \$backend;
```
Директива `ipv6=off` в resolver — ключевая. Без неё nginx будет пытаться резолвить AAAA и подключаться по IPv6.
Python requests и urllib3: скрытый выбор
`requests` использует `urllib3`, который через `socket.create_connection` вызывает getaddrinfo и перебирает адреса по порядку. Если первый — IPv6 и он недоступен, будет таймаут, потом fallback на IPv4. Но таймаут по умолчанию — 30 секунд на попытку.
```python
import socket
import requests
Принудительно IPv4
orig = socket.getaddrinfo
def ipv4_only(host, port, family=0, type=0, proto=0, flags=0):
return orig(host, port, socket.AF_INET, type, proto, flags)
socket.getaddrinfo = ipv4_only
r = requests.get("https://example.com", timeout=5)
print(r.status_code)
```
Этот хак работает, но ломает всё остальное в процессе. Лучше — использовать кастомный адаптер или `urllib3` с `socket_options`.
Более чистый путь — пропатчить resolver через `requests_toolbelt` или использовать `httpx` с явным указанием `local_address`:
```python
import httpx
transport = httpx.HTTPTransport(local_address="0.0.0.0")
client = httpx.Client(transport=transport, timeout=5.0)
r = client.get("https://example.com")
```
`local_address="0.0.0.0"` заставляет сокет биндиться на IPv4, что автоматически исключает IPv6-подключения.
SOCKS5 и ATYP: где рвётся
Разберём пакет SOCKS5 подробнее. Клиент отправляет:
```
VER=0x05, CMD=0x01, RSV=0x00, ATYP=0x04, ADDR=16 bytes, PORT=2 bytes
```
ATYP=0x04 означает IPv6. Прокси-сервер должен уметь это обработать. Если в коде сервера есть только ветка для ATYP=0x01 и 0x03 — соединение отклоняется с кодом 0x08 (Address type not supported).
Проверить, что именно шлёт клиент:
```bash
tcpdump -i lo -X 'tcp port 1080' -c 10
```
В дампе увидите байты после handshake. ATYP=0x04 — приговор для IPv4-only прокси.
Решение на стороне клиента: заставить резолвить только A-записи. В curl это `--ipv4`:
```bash
curl --ipv4 --socks5 127.0.0.1:1080 https://example.com
```
В браузере — флаг `network.dns.disableIPv6` в about:config для Firefox, или `--disable-ipv6` для Chromium.
Прокси-серверы с поддержкой IPv6: сравнение
| Прокси | IPv6 out | ATYP 0x04 | Резолв AAAA |
|--------|----------|-----------|-------------|
| Dante 1.4.2 | да (с --enable-ipv6) | да | да |
| 3proxy 0.9.4 | да | да | да |
| Squid 5.7 | да | да | да |
| Privoxy 3.0.34 | нет | нет | нет |
| Tinyproxy 1.11 | частично | нет | нет |
Tinyproxy до версии 1.11 не умел IPv6 вообще. В 1.11 добавили, но с оговорками — ATYP 0x04 обрабатывается только если собран с `--enable-ipv6`.
Dante — самый предсказуемый вариант. Конфиг:
```
socksmethod: none
clientmethod: none
external: eth0
internal: eth1
socks pass {
from: 0.0.0.0/0 to: 0.0.0.0/0
protocol: tcp udp
}
```
Строка `external: eth0` без указания семейства заставит Dante использовать оба стека. Если нужен только IPv4 — `external: 0.0.0.0`.
Когда IPv6-прокси оправдан
Если целевой сайт имеет только AAAA (например, некоторые CDN-ноды или внутренние сервисы), IPv4-прокси бесполезен. Нужен именно IPv6-выход. Такие прокси арендуют у провайдеров с native IPv6 — Hetzner, OVH, Scaleway.
Проверить, что прокси реально отдаёт IPv6-трафик:
```bash
curl -6 --proxy socks5h://user:pass@proxy:1080 https://ifconfig.co
```
Флаг `-6` заставляет curl использовать IPv6 для подключения к прокси. `socks5h` означает, что резолв делает прокси, а не клиент. Без `h` клиент резолвит сам и может выбрать не тот адрес.
Сервис lexic.ml даёт IPv6-прокси с 2015 года, и там как раз решена проблема dual-stack — трафик маршрутизируется через IPv6-пул, а клиент может быть на любом стеке. Это не реклама, а техническая деталь: при работе с dual-stack сайтами важно, чтобы прокси-сервер имел и IPv4, и IPv6 интерфейсы, иначе половина запросов будет отваливаться.
Практический чеклист диагностики
Порядок действий при проблеме «через прокси не открывается, без прокси открывается»:
1. `dig AAAA` — есть ли AAAA у домена
2. `getent ahosts` — что видит система
3. `strace -e connect` — куда реально идёт соединение
4. `tcpdump` на порту прокси — какой ATYP шлёт клиент
5. Проверка прокси на IPv6: `curl -6 --proxy socks5h://... https://ifconfig.co`
6. Логи прокси-сервера — есть ли ошибка «address type not supported»
Каждый шаг отсекает половину гипотез. В 80% случаев проблема в том, что клиент резолвит AAAA и идёт мимо прокси или шлёт IPv6-адрес в IPv4-only прокси.
Оставшиеся 20% — конфиг прокси без IPv6-выхода. Лечится либо пересборкой с `--enable-ipv6`, либо переключением клиента на IPv4-only режим. Второе быстрее, первое правильнее.