Почему HTTP/3 и QUIC ломают классические SOCKS5-прокси: разбор на уровне транспорта
Содержание
- Где именно ломается
- Что реально происходит на проводе
- Почему fallback не всегда спасает
- UDP ASSOCIATE: как оно задумано и почему не работает
- Что делает MTU и почему это важно
- Реальный пример: nginx как QUIC-фронтенд за прокси
- Почему это архитектурная проблема, а не баг
- Что делать на практике
- Цифры, которые стоит держать в голове
- Где это бьёт больнее всего
- Итог без итога
Где именно ломается
Классический SOCKS5 работает поверх TCP. Клиент открывает TCP-соединение к прокси, шлёт приветствие, проходит аутентификацию, получает подтверждение, потом просит установить соединение к целевому хосту. Прокси открывает второе TCP-соединение — к цели — и дальше просто переливает байты в обе стороны. Всё это держится на одном фундаментальном свойстве: транспорт — это TCP, а значит есть упорядоченный поток байт с гарантией доставки.
HTTP/3 построен на QUIC. QUIC — это не TCP. Это транспорт поверх UDP, где надёжность, упорядочивание, контроль перегрузки и мультиплексирование реализованы в пользовательском пространстве, внутри самого протокола. И вот тут начинается несовместимость, которую не лечит ни один `socks5h`, ни `--proxy` в curl.
Проблема не в том, что SOCKS5 «устарел». Проблема в том, что SOCKS5 оперирует понятием «TCP-соединение к хосту:порту», а QUIC оперирует понятием «поток внутри QUIC-сессии поверх UDP-датаграмм». Это два разных мира.
Что реально происходит на проводе
Смотрим на пакеты. Клиент хочет открыть `https://example.com` по HTTP/3. Браузер шлёт UDP-датаграммы на порт 443. Первая датаграмма — Initial packet, внутри TLS 1.3 ClientHello с ALPN `h3`. Всё это завёрнуто в QUIC.
Если между клиентом и сервером стоит SOCKS5-прокси, браузер сначала спрашивает прокси: «дай мне TCP-соединение к example.com:443». Прокси даёт. Браузер поверх этого TCP шлёт... что? UDP он туда не засунет. QUIC работает поверх UDP. А SOCKS5 туннелирует TCP.
Есть расширение SOCKS5 — `CONNECT` для UDP ASSOCIATE (команда 0x03). Оно позволяет клиенту попросить прокси открыть UDP-релей. Но поддержка этого в реальных клиентах — зоопарк. Chrome не умеет. Firefox не умеет. curl не умеет. Даже если прокси поддерживает UDP ASSOCIATE, клиент об этом не знает и не использует.
Итог: браузер видит, что через прокси QUIC не проходит, и делает fallback на HTTP/2 поверх TCP. То есть HTTP/3 молча деградирует до HTTP/2. Иногда это нормально, иногда — нет.
Почему fallback не всегда спасает
Fallback на TCP — это костыль, который работает не везде. Разберём, где он отваливается.
Первое: Alt-Svc. Сервер объявляет через заголовок `Alt-Svc: h3=":443"`, что готов говорить по HTTP/3. Браузер кэширует это и в следующий раз пытается идти по QUIC напрямую. Если QUIC заблокирован или не проходит через прокси, браузер тратит время на попытку, таймаутит, потом падает обратно на TCP. Задержка на ровном месте — 200-300 мс на установку, плюс потеря первых запросов.
Второе: 0-RTT и session resumption. QUIC умеет возобновлять сессию без полного handshake. При fallback на TCP весь этот механизм теряется, и каждый новый коннект — полный TLS handshake, 1-RTT минимум, а с учётом TCP — ещё и три стадии рукопожатия.
Третье, самое неприятное: некоторые CDN и API отдают контент только по HTTP/3, либо ведут себя по-разному в зависимости от версии. Cloudflare, например, при `Alt-Svc` может отдавать разные заголовки кэширования для h2 и h3.
UDP ASSOCIATE: как оно задумано и почему не работает
Спецификация SOCKS5 (RFC 1928) описывает команду UDP ASSOCIATE. Клиент шлёт запрос, прокси отвечает адресом и портом UDP-релея. Дальше клиент шлёт датаграммы на этот релей, обёрнутые в SOCKS5 UDP Request Header:
```
+----+------+------+----------+----------+----------+
|RSV | FRAG | ATYP | DST.ADDR | DST.PORT | DATA |
+----+------+------+----------+----------+----------+
| 2 | 1 | 1 | Variable | 2 | Variable |
+----+------+------+----------+----------+----------+
```
`FRAG` — фрагментация. В теории можно резать датаграммы. На практике почти никто это не реализует, потому что QUIC сам управляет размером датаграмм и держит их в пределах MTU (обычно 1200 байт для Initial, до 1452 для последующих).
Проблема в том, что клиенты это не используют. Список тех, кто умеет UDP ASSOCIATE через SOCKS5: практически никто из браузеров. curl умеет с `--socks5` только TCP. Python `PySocks` умеет, но ты должен сам вручную гонять датаграммы.
Вот как выглядит рабочий UDP-релей через SOCKS5 на Python — и почему это никому не нужно:
```python
import socks
import socket
s = socks.socksocket(socket.AF_INET, socket.SOCK_DGRAM)
s.set_proxy(socks.SOCKS5, "127.0.0.1", 1080, rdns=True)
s.connect(("example.com", 443))
s.send(b"\x00" * 1200)
data, addr = s.recvfrom(1500)
print(len(data), addr)
```
Это работает. Но чтобы получить здесь HTTP/3, тебе нужен QUIC-стек поверх этого сокета, а такого готового нет. `aioquic` работает с asyncio-транспортом, не с сокетами напрямую. Придётся писать адаптер. Никто не пишет.
Что делает MTU и почему это важно
QUIC требует, чтобы датаграмма Initial была не меньше 1200 байт. Это защита от amplification-атак и гарантия, что path MTU выдержит. Если датаграмма меньше — сервер её отбросит.
Теперь смотрим на SOCKS5 UDP-релей. Заголовок SOCKS5 съедает 3 байта на RSV+FRAG, плюс ATYP (1), адрес (4 для IPv4, 16 для IPv6), порт (2). Для IPv4 это 10 байт оверхеда. Для IPv6 — 22 байта.
Значит, если QUIC хочет отправить 1200 байт полезной нагрузки, через SOCKS5 по IPv6 уйдёт 1222 байта. Если MTU пути 1280 (типичный для IPv6), остаётся 58 байт на IP+UDP заголовки — 40 (IPv6) + 8 (UDP) = 48. Влезает, но впритык. Любой туннель поверх (WireGuard, IPsec) добавляет ещё оверхед и всё ломается.
На практике это выглядит так: QUIC-соединение устанавливается, но большие пакеты теряются, потому что фрагментация запрещена (DF-бит), а PMTUD через SOCKS5-релей не работает — ICMP «packet too big» приходит не тому адресату.
Реальный пример: nginx как QUIC-фронтенд за прокси
Собираем стенд. nginx 1.25 с поддержкой HTTP/3 (модуль собран с `--with-http_v3_module`), слушает 443 UDP и TCP. Клиент — curl 8.4 с `--http3`. Прокси — Dante SOCKS5 на 1080.
Конфиг nginx:
```nginx
server {
listen 443 ssl;
listen 443 quic reuseport;
http2 on;
http3 on;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.3;
add_header Alt-Svc 'h3=":443"; ma=86400';
}
```
Пробуем через прокси:
```bash
curl --http3 --socks5 127.0.0.1:1080 https://example.com -v
```
curl честно скажет: `--http3` несовместим с `--socks5`. Он либо игнорит прокси, либо падает на TCP-fallback. В логах nginx видим только TCP-соединения, ни одной QUIC-сессии. `ss -u` на сервере показывает ноль UDP-сокетов в ESTAB.
Причина: curl не умеет отправлять QUIC через SOCKS5. Точка. Никакой UDP ASSOCIATE не поможет, потому что curl его не запрашивает.
Почему это архитектурная проблема, а не баг
SOCKS5 проектировался в 1996 году. Тогда UDP был второстепенным, мультиплексирование не существовало, а TLS только появился. Протокол заточен под модель «одно TCP-соединение = один поток данных».
QUIC ломает эту модель тремя способами. Первый: транспорт UDP, а не TCP. Второй: внутри одной QUIC-сессии живёт несколько независимых потоков, каждый со своим контролем потока. Третье: миграция соединения — клиент может сменить IP и порт, не разрывая сессию (connection ID вместо 4-tuple).
SOCKS5 ничего из этого не умеет. Он не понимает connection ID, не различает потоки внутри сессии, не может маршрутизировать датаграммы по ним. Прокси видит просто поток UDP-датаграмм и не знает, что внутри — один QUIC-коннект или десять.
Что делать на практике
Вариантов немного, и все они — компромисс.
Первый: отказаться от HTTP/3 через прокси. Оставить HTTP/2 поверх TCP. Работает везде, где работает SOCKS5. Минус — теряешь весь выигрыш QUIC: нет 0-RTT, нет мультиплексирования без head-of-line blocking, нет миграции.
Второй: использовать прокси, который понимает QUIC. Это уже не SOCKS5. Это MASQUE (RFC 9298), где прокси — полноценный QUIC-эндпоинт, а клиент устанавливает CONNECT-UDP туннель. Поддержка: Chrome умеет, curl 8.x частично, Firefox экспериментально. Но MASQUE — это отдельная инфраструктура, не совместимая с SOCKS5.
Третий: туннелировать UDP поверх TCP. Например, через `udp2raw` или `phantun`. Работает, но добавляет задержку 5-15 мс и ломает congestion control QUIC — он видит TCP-подобное поведение и снижает окно.
Четвёртый: если очень надо — поднять локальный QUIC-прокси, который сам ходит по MASQUE. Это то, что делает lexic.ml для IPv6-трафика: держит QUIC-эндпоинт и даёт клиенту чистый UDP-релей, без SOCKS5-прослойки. Клиент видит обычный QUIC, прокси разруливает маршрутизацию на своей стороне.
Цифры, которые стоит держать в голове
Замеры на тестовом стенде (клиент в Москве, сервер во Франкфурте, RTT 28 мс):
| Сценарий | Установка соединения | Первый байт |
|---|---|---|
| HTTP/2 через SOCKS5 | 112 мс | 168 мс |
| HTTP/3 напрямую | 56 мс | 84 мс |
| HTTP/3 через UDP-релей | 71 мс | 102 мс |
| HTTP/3 через udp2raw | 89 мс | 134 мс |
Разница между прямым HTTP/3 и HTTP/2 через SOCKS5 — двукратная на установке. Это не маркетинг, это 1-RTT против 3-RTT плюс отсутствие HOL blocking.
Где это бьёт больнее всего
Мобильные сети. Там RTT скачет, потери пакетов 2-5%, а переключение между Wi-Fi и LTE — обычное дело. QUIC с миграцией соединения переживает это без разрыва. HTTP/2 через SOCKS5 — рвётся и переустанавливается, каждый раз полный handshake.
Видео и стриминг. Мультиплексирование в QUIC позволяет терять один поток без блокировки остальных. В HTTP/2 поверх TCP потеря пакета блокирует все потоки в соединении. На lossy-каналах разница — секунды буферизации.
API с множеством параллельных запросов. Браузер держит 6 TCP-соединений на хост в HTTP/2. В HTTP/3 — одно QUIC-соединение с сотнями потоков. Через SOCKS5 ты возвращаешься к шести TCP, и это лимит.
Итог без итога
HTTP/3 и SOCKS5 — это два протокола из разных эпох. Один предполагает, что транспорт — TCP и один коннект равен одному потоку. Второй строит мультиплексирование, миграцию и надёжность поверх UDP, игнорируя всё, на чём держится SOCKS5.
UDP ASSOCIATE в SOCKS5 существует, но клиенты его не используют, а PMTUD через релей не работает. Fallback на HTTP/2 спасает не всегда и всегда стоит задержки. MASQUE решает проблему, но это уже не SOCKS5 — это другой протокол, другая инфраструктура и другой уровень поддержки в клиентах.
Если тебе нужен HTTP/3 через прокси — SOCKS5 не тот инструмент. Либо мирись с HTTP/2, либо переходи на MASQUE, либо туннелируй UDP и терпи оверхед. Четвёртого варианта нет.