Двойной VPN и пропавший TCP
VPN работает. DNS резолвится, SSH заходит, лёгкие запросы проходят. Но стоит открыть что-то «тяжёлое» — браузер думает несколько секунд, приходит пара килобайт, и всё. Таймаут. Следующая попытка — то же самое. Примечательно, что UDP-трафик при этом идёт нормально.
Если VPN-туннелей два (например, Pritunl на входе и ещё один relay-слой дальше по цепочке — трафик проходит через оба последовательно) — с вероятностью 90% это MTU.
Что происходит
TCP устроен так, что при установлении соединения стороны заранее договариваются о максимальном размере данных в одном сегменте — MSS (Maximum Segment Size). Сервер объявляет его в ответном пакете (SYN-ACK), исходя из MTU своего сетевого интерфейса:
MSS = MTU − 40 (20 байт IP + 20 байт TCP)
Идея в том, чтобы сегменты не приходилось дробить по пути — сервер сам говорит «я умещаюсь вот в столько, не шли больше». Клиент честно соблюдает.
С одним VPN это обычно работает: нормально написанный VPN-клиент выставляет MTU своего TUN-интерфейса с учётом накладных расходов туннеля. Сервер читает этот MTU, объявляет соответствующий MSS — и всё согласовано.
Сложнее, когда туннелей два, один за другим. Сервер за вторым туннелем объявляет MSS по MTU своего интерфейса — не зная ничего о первом туннеле в цепочке. Клиент следует этому значению, шлёт сегменты соответствующего размера, и они оказываются слишком большими для первого туннеля, через который должны пройти.
Конкретно: при MTU=1420 сервер анонсирует MSS=1380. Клиент начинает слать сегменты по 1380 байт данных. С TCP-заголовком (20 байт плюс 12 на Timestamps) и IP-заголовком пакет вырастает примерно до 1432 байт. Для первого туннеля в цепочке это слишком много — пакет дропается.
Дальше развилка. Если ICMP «Fragmentation Needed» долетает обратно до источника — тот сам снижает MSS и всё заработает. Но через двойной VPN этот ICMP нередко теряется на одном из NAT или фильтруется файрволом. Источник ни о чём не узнаёт и продолжает слать большие сегменты.
Отсюда характерная картина: рукопожатие (SYN, SYN-ACK) маленькое — проходит. Маленькие сегменты данных — проходят. Как только дело доходит до первого «тяжёлого» блока (сертификат в TLS handshake, полноразмерный HTTP-ответ) — сегмент дропается молча, TCP ждёт подтверждения которое не придёт, и через несколько минут всё падает по таймауту.
UDP при этом не страдает: у него нет MSS-механизма, приложение само выбирает размер пакета и обычно берёт консервативное значение.
Как убедиться
На маршрутизирующем сервере сразу смотрим MTU обоих интерфейсов:
ip link show
Если туннель ближе к серверу выставил MTU=1420, а туннель ближе к клиенту — 1380 или меньше, источник найден.
Подтвердить можно пингом с явным запретом фрагментации — запускать с клиентской машины (или любого третьего узла, который видит сервер через оба туннеля):
# Увеличиваем размер до ощутимого — если MTU-проблема, пакет дропнется:
ping -M do -s 1400 <адрес-сервера>
# Уменьшаем — если этот проходит, порог где-то между:
ping -M do -s 1300 <адрес-сервера>
Если 1400 не проходит, а 1300 проходит — это оно. Флаг -M do выставляет
DF в IP-заголовке; без него ping будет фрагментироваться и симптом не
воспроизведётся.
На самом сервере можно заодно глянуть согласованный MSS на живых соединениях:
ss -tipn | grep mss
В нормальной ситуации mss совпадает с тем, что сервер объявлял в SYN-ACK. Если там 1380 при проблемном внешнем туннеле с MTU=1380 — подтверждено: клиент будет слать 1432-байтные пакеты, которые во внешний туннель не влезут.
Как починить
Самый надёжный вариант — перехватить MSS прямо в рукопожатии и принудительно
снизить его до безопасного значения. iptables умеет это через цель TCPMSS.
Правило ставится на маршрутизирующем сервере, на интерфейсе внутреннего туннеля — оно срабатывает на SYN-ACK, который уходит из туннеля в сторону клиента, и переписывает MSS в нём:
iptables -t mangle -I FORWARD 1 \
-i <интерфейс-внутреннего-туннеля> \
-p tcp --tcp-flags SYN,RST SYN \
-j TCPMSS --set-mss 1280
Клиент получит SYN-ACK с MSS=1280 и будет слать сегменты не крупнее этого. Итоговый размер IP-пакета: 1280 + 32 (TCP+Timestamps) + 20 (IP) = 1332 байта. В любой разумный VPN-туннель влезает с запасом.
Правило применяется только к флагам SYN — все остальные пакеты (данные, ACK, FIN) идут как шли. На производительности это практически не сказывается: разница между 1380 и 1280 байтами в одном сегменте — около 7%, и при ненулевом RTT через VPN это не ощущается.
Снимать при остановке туннеля:
iptables -t mangle -D FORWARD \
-i <интерфейс-внутреннего-туннеля> \
-p tcp --tcp-flags SYN,RST SYN \
-j TCPMSS --set-mss 1280
Если значение 1280 кажется слишком консервативным — можно попросить ядро
посчитать самому на основе MTU исходящего интерфейса: --clamp-mss-to-pmtu
вместо --set-mss 1280. Это сработает если MTU внешнего туннеля выставлен
аккуратно; если нет — лучше явное число.