Двойной 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 внешнего туннеля выставлен аккуратно; если нет — лучше явное число.