WhatsApp Web через IPv6-прокси: почему голосовые звонки рвутся и как стабилизировать RTP-поток
Содержание
- Почему WhatsApp Web вообще ломается через прокси
- ICE, STUN и TURN: что реально делает прокси
- MTU и фрагментация: главный убийца RTP
- nginx как TCP-релей для TURN-over-TCP
- Как выглядит деградация в цифрах
- Почему IPv6-прокси рвёт соединение на 30-й секунде
- Диагностика: что смотреть в webrtc-internals
- Сравнение транспортов для RTP через прокси
- Кейс: 40% потерь на стыке операторов
- Кейс: keepalive не проходит через NAT провайдера
- Кейс: джиттер из-за буферизации на прокси
- Что делать с WhatsApp Desktop
- Чек-лист стабильного RTP через IPv6-прокси
Почему WhatsApp Web вообще ломается через прокси
WhatsApp Web — это не просто веб-приложение. Это WebRTC-клиент с сигнализацией поверх WebSocket, медиа-потоком через SRTP и кучей эвристик для NAT traversal. Когда вы ставите между браузером и серверами WhatsApp прокси, вы ломаете сразу три вещи: ICE-кандидатов, тайминги и MTU.
Браузер формирует ICE-кандидатов из локальных интерфейсов. За прокси он видит только один адрес — адрес прокси. STUN-запросы уходят на серверы Meta (обычно 31.13.x.x и 157.240.x.x), возвращают внешний IP прокси. Дальше начинается лотерея: если прокси не пропускает UDP, остаётся только TCP relay через TURN — а это +80-150 мс к задержке и просадка качества.
Голосовые звонки в WhatsApp Web идут через SRTP поверх UDP. Пакеты маленькие (обычно 80-160 байт payload), но их много — 50 в секунду при Opus 48 кГц. Любая потеря или джиттер больше 30 мс слышны как «робот» или провалы.
ICE, STUN и TURN: что реально делает прокси
ICE — это фреймворк сбора кандидатов. Браузер пробует host-кандидаты, srflx (через STUN), relay (через TURN). За IPv6-прокси host-кандидаты бесполезны — они указывают на локальную сеть. Остаются srflx и relay.
Если прокси поддерживает UDP-ассоциации (SOCKS5 с UDP ASSOCIATE по RFC 1928), браузер может получить srflx-кандидата и пробить прямой путь. Если только TCP — всё уходит в TURN, и звонок живёт за счёт relay-серверов Meta.
Проверить, что видит браузер, можно через chrome://webrtc-internals. Смотрим секцию ICE candidate grid: если там только relay-кандидаты — вы на TCP-релее. Задержка вырастет на 60-120 мс в каждую сторону.
```bash
curl -6 -x socks5h://[2001:db8::1]:1080 \
-H "Content-Type: application/sdp" \
--data-binary @offer.sdp \
https://web.whatsapp.com/rtc
```
Этот запрос покажет, доходит ли сигнализация. Если 200 OK, но ICE не собирается — проблема в UDP.
MTU и фрагментация: главный убийца RTP
IPv6 требует минимум 1280 байт MTU. Прокси-туннели часто дают меньше — 1420, 1380, иногда 1280 с деградацией. SRTP-пакеты маленькие, но ICE-пакеты и DTLS handshake — нет. Фрагментированный DTLS handshake = потеря одного фрагмента = полный ретрансмит всего handshake.
Классическая картина: звонок устанавливается за 3-5 секунд вместо 1, потом рвётся через 20-30 секунд. В логах прокси видно ICMPv6 Packet Too Big. Браузер не всегда корректно обрабатывает PMTUD для WebRTC.
Решение — зафиксировать MTU на 1280 для туннеля и включить MSS clamping на прокси. Для TCP-релея это критично, для UDP — помогает избежать фрагментации DTLS.
```nginx
nginx как TCP-релей для TURN-over-TCP
stream {
upstream turn_servers {
server turn1.example.com:443;
server turn2.example.com:443 backup;
}
server {
listen [::]:443;
proxy_pass turn_servers;
proxy_timeout 600s;
proxy_socket_keepalive on;
}
}
```
MSS clamping на уровне ядра делается через iptables/nftables, в nginx stream его нет. Для UDP-релея нужен отдельный слой.
Как выглядит деградация в цифрах
Разница между прямым UDP и TCP-релеем измерима. Ниже — усреднённые значения с реального стенда: клиент в Москве, прокси в Амстердаме, собеседник в Берлине. Замеры через webrtc-internals, метрика `jitterBufferDelay` и `packetsLost`.
| Параметр | Прямой UDP | SOCKS5 UDP | TCP relay (TURN) |
|---|---|---|---|
| Установка звонка | 0.9 с | 1.2 с | 3.8 с |
| RTT медиа | 42 мс | 58 мс | 131 мс |
| Потери пакетов | 0.3% | 1.1% | 4.7% |
| Jitter | 8 мс | 14 мс | 39 мс |
| MOS (оценка) | 4.3 | 3.9 | 2.8 |
MOS ниже 3.5 — уже слышно. Ниже 3.0 — разговор превращается в пытку. TCP-релей в этой таблице честно плох, но он единственный путь, если UDP заблокирован.
Почему IPv6-прокси рвёт соединение на 30-й секунде
Типичный сценарий: звонок идёт нормально полминуты, потом собеседник пропадает. Причина — keepalive. WhatsApp шлёт STUN binding requests каждые 15 секунд для поддержания NAT-ассоциации. Если прокси закрывает UDP-сессию раньше (по таймауту 30 с), ассоциация умирает, и медиа уходит в никуда.
Смотрим настройки таймаута UDP на прокси. Для SOCKS5 UDP ASSOCIATE стандартный таймаут — 60-120 секунд, но многие реализации ставят 30. Меняем на 300 секунд как минимум. Для nginx stream UDP (с версии 1.25) — `proxy_timeout 300s`.
Второй момент — conntrack. На Linux UDP-сессии в conntrack по умолчанию живут 30 секунд (`nf_conntrack_udp_timeout`). При активном трафике обновляется, но при паузе в разговоре — умирает. Правим:
```bash
sysctl -w net.netfilter.nf_conntrack_udp_timeout=180
sysctl -w net.netfilter.nf_conntrack_udp_timeout_stream=300
```
Это не костыль, а базовая настройка для любого UDP-релея.
Диагностика: что смотреть в webrtc-internals
Открываем chrome://webrtc-internals во время звонка. Нас интересуют графики `packetsReceived`, `packetsLost`, `jitterBufferDelay`, `nackCount`. Если `nackCount` растёт — идут ретрансмиты, значит потери. Если `jitterBufferDelay` растёт вместе с ним — буфер раздувается, задержка ползёт вверх.
Отдельно смотрим `candidate-pair` — какой путь выбран. Если `relay`, а хочется `srflx` — надо чинить UDP. Если `srflx` есть, но RTT высокий — проблема в маршруте до прокси.
Для программного сбора метрик из WebRTC есть библиотека aiortc на Python. Она позволяет поднять тестовый клиент и снять цифры без браузера.
```python
import asyncio
from aiortc import RTCPeerConnection, RTCSessionDescription
async def probe(offer_sdp):
pc = RTCPeerConnection()
await pc.setRemoteDescription(RTCSessionDescription(offer_sdp, "offer"))
answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
await asyncio.sleep(5)
for stats in pc.getStats():
if stats.type == "candidate-pair" and stats.state == "succeeded":
print(f"path={stats.local_candidate_id} rtt={stats.current_round_trip_time}")
if stats.type == "inbound-rtp":
print(f"loss={stats.packets_lost} jitter={stats.jitter}")
await pc.close()
```
Этот скрипт запускается на стороне клиента за прокси и даёт сырые цифры без UI.
Сравнение транспортов для RTP через прокси
Не все прокси одинаково полезны. SOCKS5 с UDP ASSOCIATE — лучший вариант, но требует поддержки на сервере. HTTP CONNECT туннелирует только TCP, и весь медиатрафик идёт через TURN-over-TCP. WireGuard и IPsec дают полноценный L3, но добавляют overhead и требуют root.
| Транспорт | UDP | Overhead | Сложность | Годится для звонков |
|---|---|---|---|---|
| SOCKS5 UDP | да | 10 байт | средняя | да |
| HTTP CONNECT | нет | 0 | низкая | только через TURN |
| WireGuard | да | 60 байт | высокая | да |
| Shadowsocks | да | 30 байт | средняя | да |
SOCKS5 UDP ассоциация — оптимум. Overhead минимальный, поддержка в браузерах через расширения или системный прокси. WireGuard лучше по стабильности, но требует настройки маршрутов и часто блокируется провайдерами.
Кейс: 40% потерь на стыке операторов
Сервер с nginx 1.24 в качестве TCP-релея, клиент за IPv6-прокси в сети российского оператора, собеседник на мобильном в Казахстане. Звонки устанавливались, но через 10-15 секунд потери достигали 40%. Метрика `packetsLost` в webrtc-internals росла лавинообразно.
Причина оказалась в MTU: туннель до прокси давал 1380 байт, а промежуточный хоп на стыке операторов резал до 1280 без отправки ICMPv6. DTLS handshake фрагментировался, часть фрагментов терялась. ICE переключался на TURN, но и там фрагментация добивала поток.
Решение: принудительный MTU 1280 на клиентском интерфейсе плюс MSS clamping на прокси. Потери упали до 1.2%, звонки перестали рваться. Цена — небольшой рост overhead на TCP, но для медиа это неважно.
Кейс: keepalive не проходит через NAT провайдера
Другой стенд: прокси на выделенном сервере в Финляндии, клиент за двойным NAT (роутер + провайдерский CGNAT). Звонки стабильно умирали на 28-32 секунде. В логах прокси — тишина после 30 секунд, потом RST от клиента.
Причина — CGNAT провайдера закрывал UDP-маппинг через 30 секунд, а STUN keepalive от WhatsApp шёл раз в 15 секунд и... терялся. Потому что прокси не пропускал исходящие UDP-пакеты от клиента к STUN-серверам Meta — только к TURN. Ассоциация не обновлялась.
Решение: разрешить исходящий UDP на порты 3478 и 19302 (STUN Google) через прокси. После этого keepalive пошёл, маппинг держался, звонки стали жить по 10-15 минут без обрывов.
Кейс: джиттер из-за буферизации на прокси
Прокси-сервер с 4 ГБ RAM и включённым `net.core.rmem_max=212992`. При 20 одновременных звонках через один прокси джиттер подскочил до 80-110 мс. Пакеты копились в очереди сокета и уходили пачками.
Причина — дефолтные размеры буферов слишком малы для агрегированного UDP-трафика. Каждый звонок даёт ~50 пакетов в секунду по 120 байт, 20 звонков — 120 КБ/с, но пиками до 500 КБ/с. Буфер 208 КБ заполняется за доли секунды при всплеске.
Решение: поднять `rmem_max` и `wmem_max` до 4 МБ, включить `SO_RCVBUFFORCE` в приложении прокси, ограничить число одновременных звонков на инстанс до 15. Джиттер вернулся к 12-18 мс. Для продакшена с сотнями звонков нужен пул прокси, а не один жирный сервер.
Что делать с WhatsApp Desktop
Десктопный клиент ведёт себя иначе. Он использует собственный стек WebRTC (Electron), хуже реагирует на смену сетевых условий. Через прокси часто зависает на «Connecting» дольше минуты. Лечится тем же — UDP-ассоциация через SOCKS5 и корректный MTU.
Если стоит выбор между Web и Desktop за прокси — Web предсказуемее. Браузерный ICE-стек лучше обрабатывает fallback на TURN и быстрее восстанавливается после потерь. Desktop-клиент любит цепляться за неудачного кандидата и не переключаться.
Чек-лист стабильного RTP через IPv6-прокси
Перед тем как жаловаться на качество, проверьте базовое. UDP-ассоциация работает? MTU 1280 на всех хопах? Keepalive до STUN проходит? Conntrack-таймауты подняты? Буферы сокетов увеличены?
Если всё зелёное, а звонки всё равно рвутся — смотрите маршрут. IPv6 часто идёт через туннели (6in4, Teredo), которые добавляют 40-80 мс и теряют пакеты при перегрузке. Traceroute6 покажет, где собака зарыта.
```bash
traceroute6 -n -q 3 -w 2 turn.example.com
ping6 -c 100 -i 0.05 -s 1200 turn.example.com | tail -3
```
Второй командой смотрим потери и джиттер на пути. Если потери больше 2% — прокси не спасёт, надо менять маршрут или провайдера. Медиа через плохой канал не лечится настройками, только сменой пути.
Для мультиплексирования звонков через один IPv6-адрес удобно держать пул портов и раскидывать сессии по разным UDP-сокетам. Это снижает конкуренцию за буфер и упрощает диагностику — каждая сессия видна отдельно в ss -u -p.