Компьютерные сети NAT, фаерволы и VPN: почему «у меня работает, а у клиента нет»
0%

NAT, фаерволы и VPN: почему «у меня работает, а у клиента нет»

NAT, фаерволы и VPN: почему «у меня работает, а у клиента нет»

Есть фраза, после которой инженер теряет полдня: «у меня всё открывается». Сервис поднят, тесты зелёные, curl с ноутбука отвечает 200. А у клиента — вечная крутилка, обрыв на середине загрузки или ошибка через две минуты после успешного входа.

Почти всегда виноват один из трёх механизмов, стоящих между вами и клиентом и невидимых обеим сторонам: NAT переписывает адреса и хранит соответствие в оперативной памяти чужого устройства; фаервол молча выбрасывает пакеты, и разница между «молча» и «с отказом» решает, увидит клиент ошибку за 20 миллисекунд или за две минуты; туннели и VPN уменьшают MTU, а сообщение об этом уменьшении обычно отфильтровано тем самым фаерволом.

Аналогия на всю статью — офисный телефонный узел. Один городской номер, триста добавочных, секретарь на коммутаторе помнит, кто кому только что звонил, и по этой памяти соединяет обратный звонок. Это NAT, и вся его хрупкость видна сразу: снаружи набрать конкретного сотрудника напрямую нельзя, а если секретарь забыл запись — обратный звонок не дойдёт, и звонящий будет слушать гудки. Охрана на входе — фаервол. Служебный коридор в соседнее здание, где потолок ниже на десять сантиметров, — VPN: пройти можно, но не со всяким шкафом.

Дальше предполагается, что вы прошли IP и маршрутизацию, TCP и TLS. Карта уровней — в обзоре трека.

Симптом — это не диагноз

Первое, что нужно сделать, — перестать говорить «не работает» и назвать точную форму отказа. Она почти однозначно указывает на слой.

Что видит клиент Наиболее вероятная причина Первая проверка
Connection timed out через ~2 минуты пакеты выбрасываются: DROP, NACL, нет маршрута tcpdump на сервере: SYN доходит?
Connection refused мгновенно до хоста дошли, порт закрыт или REJECT с RST ss -ltn на сервере
No route to host ICMP admin-prohibited от промежуточного узла traceroute — ищем !X
Соединение установилось, TLS завис MTU: большой пакет не проходит, PMTUD сломана ping -M do -s 1472
Работает 5 минут, потом тишина таймаут записи NAT или idle timeout балансировщика keepalive, conntrack -L
Не работает у 5–10 % CGNAT, symmetric NAT, IPv6-путь, корпоративный прокси сегментировать по ASN и версии IP
Работает по IP, не работает по имени DNS: split-horizon, VPN-резолвер, поисковые домены resolvectl query, dig

Второе — воспроизвести с той стороны. Половина инцидентов этого класса неразрешима «изнутри» принципиально: вы стоите не в том месте топологии. Минимум — попросить у клиента curl -v и traceroute; максимум — снять дамп одновременно на клиенте и на сервере и сравнить, какие пакеты потерялись.

NAT: один адрес, таблица в памяти и потерянная адресуемость

К началу 1990-х стало ясно, что 32-битное пространство IPv4 кончится раньше, чем появится замена. RFC 1631 (1994) предложил временное решение: внутри сети живут адреса из RFC 1918 (10/8, 172.16/12, 192.168/16), а на границе стоит устройство, подменяющее адрес источника на один публичный. Развитие — NAPT, трансляция адреса вместе с портом, зафиксирована в RFC 3022. Именно NAPT все и называют «NAT».

Исчерпание адресов это решило. Чем платим — ломается базовое допущение интернета: любой хост может обратиться к любому. Появляется асимметрия, и все дальнейшие сложности (P2P, входящие соединения, устаревание записей, расследование злоупотреблений) растут из неё.

Что NAT делает с заголовком пакета и что хранит в таблице

Роутер меняет в IP-заголовке адрес источника, в TCP/UDP-заголовке — порт источника, пересчитывает обе контрольные суммы (сумма TCP считается по псевдозаголовку, куда входят адреса) и записывает соответствие в таблицу. Обратный пакет ищется в ней по внешнему кортежу и разворачивается назад. Важнейшее следствие: NAT — устройство с состоянием, а значит, у него есть конечная память, таймауты записей и поведение при переполнении.

В Linux эта таблица — nf_conntrack, и её видно напрямую:

sudo conntrack -L -p tcp | head -2
sudo conntrack -S | head -1
cat /proc/sys/net/netfilter/nf_conntrack_count /proc/sys/net/netfilter/nf_conntrack_max
tcp 6 431987 ESTABLISHED src=192.168.1.42 dst=93.184.216.34 sport=51234 dport=443
    src=93.184.216.34 dst=203.0.113.5 sport=443 dport=40001 [ASSURED] mark=0 use=1
tcp 6 118 SYN_SENT src=192.168.1.77 dst=198.51.100.9 sport=44120 dport=5432 [UNREPLIED]
    src=198.51.100.9 dst=203.0.113.5 sport=5432 dport=40002 mark=0 use=1

cpu=0 found=0 invalid=1841 insert=0 insert_failed=17 drop=0 early_drop=0 error=0
184213
262144

Первая строка каждой записи — прямое направление, вторая — как выглядит обратный пакет снаружи. Число после протокола (431987) — сколько секунд записи осталось жить; [UNREPLIED] — ответа ещё не было, [ASSURED] — соединение подтверждено в обе стороны и не будет вычищено первым при нехватке места. Следить в проде стоит за insert_failed (конфликт при выборе внешнего порта — признак исчерпания портов) и early_drop/drop (таблица переполнена). Переполнение видно и в ядре — dmesg -T | grep conntrack даёт nf_conntrack: table full, dropping packet. Это ровно тот случай, когда «у клиента нет»: новые соединения дропаются, старые живут, графики приложения зелёные.

Жизненный цикл записи и «молчаливая смерть» соединения

Ключевой переход — ESTABLISHED → NONE по таймауту. Обе стороны считают соединение живым: сокеты открыты, ss показывает ESTAB, приложение спокойно ждёт. А посередине запись уже стёрли, и первый пакет после паузы не найдёт соответствия и будет выброшен (вежливый NAT ответит RST — это лучше, приложение хотя бы узнает об обрыве). Жертвы: долгие запросы к базе через VPN, gRPC-стримы без пингов, WebSocket без ping/pong, SSH, оставленный на ночь.

Лечение — keepalive с интервалом меньше самого короткого таймаута на пути. Системные значения бесполезны по умолчанию:

sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes
net.ipv4.tcp_keepalive_time = 7200      # два часа до первого зонда
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9

Настройка глобальна и грубовата; правильнее задавать keepalive на конкретном сокете (SO_KEEPALIVE + TCP_KEEPIDLE) или использовать механизм самого протокола: HTTP/2 PING, gRPC keepalive_time, WebSocket ping (см. реальное время). Значение 30–60 секунд закрывает почти все реальные NAT.

Типы NAT и судьба P2P

Терминология «конусов» пришла из устаревшего RFC 3489, но остаётся удобной. Точный современный язык — RFC 4787: поведение описывается парой «как выбирается внешний порт» + «кого пускают обратно».

Тип Внешний порт для нового назначения Кто может ответить P2P-пробивание
Full cone тот же кто угодно тривиально
Restricted cone тот же тот же адрес, любой порт STUN достаточно
Port-restricted cone тот же тот же адрес и порт STUN достаточно
Symmetric новый на каждое назначение тот же адрес и порт STUN бесполезен, нужен TURN

Симметричный NAT — главный враг WebRTC. STUN сообщает, каким внешним портом вы выглядите для него самого; при симметричном NAT для другого пира порт будет другим, и обмен кандидатами через сигнальный канал даст мусор. Отсюда обязательный TURN-релей — и заметная доля пользователей, у которых звонок идёт через ретранслятор с лишними десятками миллисекунд. Проверяется за секунду:

stunclient --mode full stun.stunprotocol.org 3478
Mapped address: 203.0.113.5:40001
Behavior test: address and port dependent mapping
Filtering test: address and port dependent filtering

address and port dependent mapping — это и есть симметричный NAT.

CGNAT, hairpin и проброс портов

Публичных адресов не хватило и провайдерам: появился Carrier-Grade NAT (RFC 6888), где абонент получает адрес из 100.64.0.0/10 (RFC 6598), а трансляция идёт у оператора — NAT444, два уровня подряд. Следствия, обязательные к учёту, если у вас есть внешние клиенты:

  • Rate limit по IP банит целый квартал. За одним адресом CGNAT — тысячи абонентов. Ограничивайте по идентификатору сессии или пользователя, IP держите дополнительным сигналом.
  • Геолокация врёт: пул может быть зарегистрирован в другом городе или стране.
  • Бюджет портов на абонента конечен — обычно 512–4096. Страница с 40 параллельными соединениями плюс фоновые приложения телефона упираются в лимит; симптом — «часть картинок не грузится», случайным образом.
  • Логи без порта источника бесполезны. По RFC 6302 абонента идентифицируют IP + порт источника + точная метка времени. Пишите порт — это пара байт, которая однажды спасёт расследование.

Hairpin NAT даёт зеркальный симптом. Хост внутри сети обращается к внешнему адресу собственного роутера, ожидая попасть на проброшенный сервис. Роутер транслирует адрес назначения внутрь, сервер отвечает напрямую внутреннему клиенту со своего внутреннего адреса — клиент ждал ответ от публичного адреса и отбрасывает пакет. Требование поддерживать «шпильку» — REQ-9 в RFC 4787, но дешёвые роутеры его не выполняют. Разработчик из офиса видит поломку, которой нет у внешних клиентов, — или наоборот. Лечение не героическое: split-horizon DNS, где внутренняя зона отдаёт внутренний адрес (механика зон — в статье про DNS).

Пробить NAT снаружи «официально» можно статическим пробросом, UPnP IGD (устарел, дыряв) или PCP; в корпоративной среде обычно нельзя ничем. Практический вывод для архитектора: не проектируйте протокол так, чтобы сервер открывал соединение к клиенту. WebSocket, gRPC-стримы, MQTT, long polling — все построены на исходящем соединении от клиента именно поэтому.

Фаерволы: разница между «молча» и «с отказом»

Три поколения, и каждое отвечает за свой класс отказов:

  1. Пакетный фильтр без состояния (ACL на маршрутизаторе, AWS Network ACL) смотрит на каждый пакет отдельно. Дёшев и быстр, но чтобы разрешить исходящее TCP, приходится руками открывать обратный трафик на весь диапазон эфемерных портов.
  2. Stateful-фильтр (netfilter/nftables, pf, AWS Security Group) помнит соединения: правило «разрешить исходящее» автоматически разрешает ответы. Ценой той же таблицы состояний со всеми её таймаутами.
  3. L7 / DPI разбирает содержимое: HTTP-заголовки, SNI в TLS, сигнатуры. Умеет резать «всё, кроме HTTPS» и подменять сертификаты — отсюда отказы, необъяснимые с точки зрения L3/L4.

В Linux решение принимается в фиксированных точках: входящий пакет проходит prerouting (здесь DNAT), затем расходится на input (трафик к самому хосту) или forward (транзит), исходящий идёт через output, и всё сходится в postrouting (здесь SNAT и MASQUERADE). Из этой схемы сразу видны три типовые ошибки: правило в forward не влияет на трафик к самому хосту; после DNAT в forward вы увидите уже внутренний адрес назначения, а не публичный; «неправильный исходящий адрес» лечится маршрутизацией или snat to в postrouting, а не фильтром.

sudo nft list ruleset            # современный синтаксис
sudo iptables -L -n -v           # legacy; таблица NAT — отдельно, через -t nat
table inet filter {
  chain input {
    type filter hook input priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif "lo" accept
    tcp dport { 22, 443 } accept
    counter packets 18402 bytes 1104120 drop
  }
}

Счётчик в конце цепочки с policy drop — самый недооценённый инструмент диагностики: если он растёт синхронно с жалобами клиента, вопрос закрыт.

DROP против REJECT: одна буква — две минуты жизни клиента

Действие Что уходит клиенту Что видит приложение Сколько ждёт
DROP ничего таймаут ~127 с (6 ретраев SYN)
REJECT --reject-with tcp-reset TCP RST Connection refused мгновенно
REJECT (по умолчанию icmp-port-unreachable) ICMP type 3 code 3 Connection refused мгновенно
REJECT --reject-with icmp-admin-prohibited ICMP type 3 code 13 No route to host мгновенно
закрытый порт без фаервола TCP RST Connection refused мгновенно

Откуда 127 секунд: tcp_syn_retries = 6, интервалы удваиваются — 1, 2, 4, 8, 16, 32, 64 секунды до ETIMEDOUT. Разница видна невооружённым глазом:

curl -v --max-time 200 https://api.internal.example/health
*   Trying 10.20.30.40:443...
* connect to 10.20.30.40 port 443 failed: Connection timed out
curl: (28) Failed to connect to api.internal.example port 443 after 127310 ms

*   Trying 10.20.30.40:443...
* connect to 10.20.30.40 port 443 failed: Connection refused
curl: (7) Failed to connect to api.internal.example port 443 after 3 ms

Практическое правило: внутри своего периметра — REJECT, чтобы свои сервисы падали быстро и с внятной ошибкой; на внешнем периметре — DROP, чтобы не помогать сканерам отличать «закрыт» от «нет хоста». Различить «пакет не дошёл» и «дошёл и выброшен» помогает только дамп с обеих сторон: если SYN виден на сервере, а SYN-ACK не виден на клиенте — фильтр на обратном пути или асимметричная маршрутизация; если SYN не виден вовсе — проблема раньше.

sudo tcpdump -i any -nn "tcp port 443 and host 203.0.113.7" -c 10

Stateful или stateless: почему security group и NACL ведут себя по-разному

В облаках это самый частый источник «работает через раз». Security Group — stateful: разрешив исходящее, вы разрешили и ответы. Network ACL — stateless: обратный трафик на 1024–65535 нужно разрешать явно, иначе ответы отбрасываются. Картина в дампе узнаваемая: уходящий SYN и приходящий SYN-ACK, который не доходит до приложения.

Тот же принцип бьёт при асимметричной маршрутизации: если пакеты туда и обратно идут разными путями через разные stateful-устройства, второе увидит ответ без записи о соединении и выбросит его как invalid. В Linux это видно по ct state invalid и счётчику invalid в conntrack -S.

Исходящие правила и фильтрация ICMP

Входящие правила настраивают все, исходящие — забывают, а именно они дают самые загадочные отказы: egress-политика разрешает только список адресов, а API переехал на новый CIDR; блокировка исходящего 53/udp вешает приложение с собственным DNS-клиентом; блокировка 123/udp уводит часы, и через сутки ломается проверка сертификатов и подписей JWT; открыт только TCP, а вы включили HTTP/3 — браузер тихо откатится на TCP, ваш серверный клиент нет (см. UDP и QUIC).

nc -zv -w3 api.example.com 443
timeout 3 bash -c 'cat < /dev/null > /dev/udp/1.1.1.1/53' && echo "udp ok"

Отдельная беда — «ICMP это ping, ping не нужен, закроем целиком». ICMP несёт управляющие сообщения, без которых IP не работает:

Сообщение Тип/код Что сломается без него
Fragmentation needed IPv4 type 3 code 4 PMTUD: большие пакеты пропадают в чёрной дыре
Packet Too Big ICMPv6 type 2 то же в IPv6, где фрагментации на маршрутизаторах нет вовсе
Destination unreachable type 3 клиент вместо мгновенной ошибки ждёт 127 секунд
Time exceeded type 11 traceroute показывает * * *
Echo request/reply type 8/0 мониторинг, ping, часть health-check
sudo nft add rule inet filter input icmp type destination-unreachable accept
sudo nft add rule inet filter input icmpv6 type { packet-too-big, time-exceeded } accept
sudo nft add rule inet filter input icmp type echo-request limit rate 10/second accept

MTU: самая частая причина «зависает на середине»

Если соединение устанавливается, а потом замирает — с высокой вероятностью это MTU. MTU — максимальный размер IP-пакета, который канал передаёт без фрагментации; в Ethernet это 1500 байт, из них IP и TCP съедают по 20, значит MSS равен 1460. Стороны сообщают MSS друг другу в SYN, но каждая называет свою цифру, исходя из своего локального интерфейса. Про узкое место посередине не знает никто.

Бюджет MTU: что туннель забирает у полезной нагрузки

Дальше вступает Path MTU Discovery (RFC 1191): пакеты уходят с флагом DF, и если по дороге попадается узкий канал, маршрутизатор отбрасывает пакет и возвращает ICMP «fragmentation needed» с реальным MTU. Схема прекрасна ровно до момента, когда этот ICMP кто-то отфильтровал.

Портрет проблемы: рукопожатие TCP проходит, TLS — нет; маленькие запросы работают, большие (POST с телом, ответ с сертификатом, загрузка файла) — нет; в логах сервера пусто, потому что запрос не дошёл до приложения; сломано только у части клиентов — тех, чей путь идёт через туннель. В Wireshark это однозначно: один и тот же сегмент помечен [TCP Retransmission] несколько раз подряд с одинаковым seq и полным размером, между ретрансмиссиями пусто. Фильтры — tcp.analysis.retransmission && tcp.len > 1000, icmp.type == 3 && icmp.code == 4, icmpv6.type == 2. Если ICMP в дампе есть, а размер не уменьшился, виноват хост; если ICMP нет вовсе — виноват путь.

Измерение занимает полминуты (1472 = 1500 − 20 IP − 8 ICMP):

ping -M do -s 1472 -c 2 api.example.com
ping -M do -s 1400 -c 2 api.example.com
tracepath -n api.example.com
ip route get 198.51.100.9
ping: local error: message too long, mtu=1420        # узкий ваш собственный интерфейс
1408 bytes from 198.51.100.9: icmp_seq=1 ttl=54 time=18.7 ms

 1?: [LOCALHOST]                      pmtu 1500
 2:  100.64.12.1                       3.981ms
 3:  198.51.100.1                      9.114ms pmtu 1420
 4:  198.51.100.9                     18.702ms reached
     Resume: pmtu 1420 hops 4 back 4

198.51.100.9 via 192.168.1.1 dev wlan0 src 192.168.1.42
    cache expires 573sec mtu 1420

Разница принципиальна: local error: message too long означает узкий локальный интерфейс, а вот молчание (100% packet loss без ошибки) при работающем -s 1400 — признак чёрной дыры посередине. tracepath показывает узел, на котором путь сужается, ip route get — что ядро уже успело узнать.

Лечение состоит из двух частей. MSS clamping на границе туннеля переписывает поле MSS в проходящих SYN, вынуждая стороны сразу договориться на безопасный размер:

sudo nft add rule inet filter forward tcp flags syn tcp option maxseg size set rt mtu
sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360

Ограничение: работает только для TCP и только если SYN проходит через это устройство; для UDP и QUIC бесполезно. Вторая часть — PLPMTUD на хостах, определение MTU по факту потерь без всякого ICMP (RFC 4821, для датаграмм — RFC 8899):

sudo sysctl -w net.ipv4.tcp_mtu_probing=1   # 0 выкл, 1 при подозрении на чёрную дыру, 2 всегда
sudo sysctl -w net.ipv4.tcp_base_mss=1024

Значение 1 — разумный дефолт для любого сервера с разношёрстными клиентами: цена нулевая, класс инцидентов исчезает. QUIC решает вопрос лучше всех: ограничивает начальные пакеты 1200 байтами и делает DPLPMTUD собственными средствами — одна из недооценённых причин, почему HTTP/3 «просто работает» там, где HTTP/2 странно тормозит. И не занижайте MTU наугад: 1200 «на всякий случай» увеличивает число пакетов почти на 20 % и бьёт по пропускной способности и CPU.

VPN: служебный коридор и его побочные эффекты

Каждый VPN делает три вещи, и все три ломаются по-своему: инкапсулирует (отсюда MTU), шифрует (отсюда CPU и непрозрачность для NAT) и меняет маршруты и DNS (отсюда большинство жалоб).

Технология Транспорт Особенность Типичная боль
IPsec (IKEv2 + ESP) ESP — протокол 50, IKE — UDP 500 стандарт для site-to-site ESP не TCP/UDP, NAT его не транслирует; нужен NAT-T на UDP 4500
WireGuard UDP, один порт простой, быстрый, в ядре Linux UDP могут заблокировать; MTU 1420
OpenVPN UDP 1194 или TCP 443 режим TCP проходит везде TCP-over-TCP: наложение двух окон перегрузки
SSH-туннель, ssh -D TCP 22 не требует прав тот же TCP-over-TCP, нет UDP
L2TP/IPsec, PPTP UDP/GRE наследие PPTP криптографически сломан, GRE не проходит через многие NAT

Про TCP-over-TCP стоит знать отдельно: когда надёжный протокол несёт другой надёжный протокол, при потере ретрансмиссию делают оба уровня, их таймеры складываются, окна перегрузки конфликтуют, и на плохом канале скорость обваливается до нуля вместо плавной деградации. Классический разбор — «Why TCP Over TCP Is A Bad Idea». Жалоба «VPN работает, но всё очень медленно при плохом Wi-Fi» начинается с проверки, не включён ли режим TCP.

Full tunnel заворачивает весь трафик (AllowedIPs = 0.0.0.0/0), split tunnel — только корпоративные подсети; split удобнее, но создаёт классическую дыру, когда сервис переехал в облако с новым CIDR, а маршрут в конфиге остался старый. Инструмент проверки ровно один — спросить у ядра, куда пойдёт конкретный пакет:

ip route get 10.20.30.40 ; ip route get 8.8.8.8 ; ip rule show ; wg show
10.20.30.40 dev wg0 table 51820 src 10.8.0.3
8.8.8.8 dev wlan0 src 192.168.1.42

0:      from all lookup local
32764:  from all lookup main suppress_prefixlength 0
32765:  not from all fwmark 0xca6c lookup 51820
32766:  from all lookup main

interface: wg0
  listening port: 51820
  fwmark: 0xca6c
peer: 3Bx8Lq7WvY2cJ5nH0tRfP1aD6sK9eU4mZ8gQ7yXoI3s=
  endpoint: 198.51.100.9:51820
  allowed ips: 10.8.0.0/24, 10.20.0.0/16
  latest handshake: 1 minute, 12 seconds ago
  transfer: 12.44 MiB received, 3.21 MiB sent
  persistent keepalive: every 25 seconds

Здесь видно всё сразу: 10.20.30.40 уходит в туннель, 8.8.8.8 — мимо (split tunnel), правила ip rule реализуют трюк wg-quick с fwmark (сам зашифрованный трафик обязан идти в обход туннеля, иначе получится петля), allowed ips показывает, какие сети вообще считаются корпоративными. Два диагностических признака: latest handshake старше двух минут при наличии трафика означает, что рукопожатие не проходит (UDP-порт заблокирован, endpoint сменил адрес, ключи разошлись); transfer с нулём в одну сторону — почти всегда AllowedIPs не покрывает нужную подсеть либо на сервере нет обратного маршрута или SNAT. А persistent keepalive: every 25 seconds — не украшение: 25 секунд выбраны так, чтобы уложиться в самый короткий распространённый таймаут UDP-записи NAT (30 секунд).

DNS в VPN — место, где ломается чаще всего

Порядок отказов по частоте: резолвер не переключился (туннель поднят, маршруты верные, но systemd-resolved продолжает спрашивать домашний роутер, который про db.corp.internal не знает); переключился слишком сильно (весь DNS ушёл в корпоративный резолвер, включая публичные имена); утечка внутренних имён наружу — одновременно поломка и раскрытие структуры инфраструктуры; не пришёл поисковый домен, и запрос api уходит как есть вместо api.corp.internal.

resolvectl status wg0 ; resolvectl query db.corp.internal
Link 5 (wg0)
  Current DNS Server: 10.20.0.53
          DNS Domain: ~corp.internal corp.internal

db.corp.internal: 10.20.30.40
  -- link: wg0

Тильда в ~corp.internal — routing-домен: «отправлять сюда только запросы этой зоны», это и есть корректный split-DNS. Строка -- link: wg0 подтверждает, что запрос ушёл по туннелю. Ловушка последних лет: браузер с включённым DoH игнорирует системный резолвер целиком, и внутренние имена перестают резолвиться в браузере, продолжая работать в терминале (см. раздел про DoH/DoT в статье о DNS).

Последняя классика — пересекающиеся подсети. 192.168.1.0/24 одновременно самая популярная домашняя сеть в мире и любимая подсеть корпоративных филиалов. Если обе стороны используют один диапазон, маршрутизация неразрешима: ip route get 192.168.1.10 вернёт локальный интерфейс, потому что connected-маршрут всегда специфичнее. Лечение по возрастанию боли: перенумеровать свою сеть в случайную подсеть из 10.0.0.0/8; двойной NAT на шлюзе с трансляцией удалённой сети в свободный диапазон; в тяжёлом случае — раздавать сервисы по именам и проксировать. Правило проектирования: третий октет выбирайте случайным числом, это бесплатная страховка на годы.

Четыре боевых инцидента

1. TLS зависает у 6 % клиентов. В логах nginx нет ничего: ни 4xx, ни 5xx, ни записи о запросе. В дампе — завершённое рукопожатие TCP и повторяющийся сегмент 1500 байт с одинаковым seq.

sudo ss -tni state established '( sport = :443 )' | head -3
ESTAB 0 4380 198.51.100.9:443 203.0.113.7:51234
     cubic wscale:7,7 rto:1204 rtt:187.4/12.1 mss:1460 pmtu:1500
     bytes_sent:4380 bytes_retrans:4380 segs_out:4 segs_in:3

bytes_retrans равен bytes_sent — ушедшее не доставлено ни разу, а pmtu:1500 говорит, что ядро о сужении не знает. Причина: у этих клиентов провайдер использует туннель с MTU 1420 и режет ICMP на своём периметре. Немедленное лечение — tcp_mtu_probing=1 на фронтендах (симптом ушёл за минуты), постоянное — MSS clamping на границе и алерт на долю соединений с bytes_retrans / bytes_sent > 0.5. Урок: пустота в логах приложения — это сигнал, а не отсутствие информации: проблема ниже HTTP.

2. Пул соединений к базе умирает каждые шесть минут. Приложение ходит в managed-Postgres, раз в несколько минут — всплеск «server closed the connection unexpectedly». Всплески совпадают с периодами простоя пула. Улика: NAT Gateway провайдера с idle timeout 350 секунд против idle_timeout пула в 10 минут. Соединение простаивает дольше 350 секунд, запись трансляции удаляется, следующий запрос уходит в пустоту. Лечение: keepalives=1 keepalives_idle=60 в строке подключения (в Go — net.Dialer{KeepAlive: 60 * time.Second}) и max_idle_time пула в 240 секунд. Принцип: время жизни простаивающего соединения в приложении должно быть строго меньше минимального idle timeout всех устройств на пути — тот же приём для прокси разобран в статье про балансировку.

3. У 8 % пользователей видеозвонок с задержкой 300 мс. Статистика ICE показывает у этих клиентов кандидат типа relay вместо srflx: трафик идёт через TURN в другом регионе. Причина — симметричный NAT, типичный для мобильных операторов и корпоративных сетей. Решение: TURN-серверы в регионах присутствия пользователей плюс TURN поверх TCP/443 для тех, у кого UDP заблокирован; долю relay-кандидатов вывели в метрику как индикатор сетевого качества аудитории. Урок: NAT traversal — это распределение с хвостом, а не «работает или нет»; релей должен быть, и он должен быть близко (механика ICE — в статье про реальное время).

4. Сертификат «недействителен» только в офисе клиента. У вас A+ на SSL Labs, у клиента ошибка проверки. Одна команда закрывает вопрос:

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null | head -9
Certificate chain
 0 s:CN = api.example.com
   i:CN = Corporate Root CA, O = ACME Industries, C = DE
 1 s:CN = Corporate Root CA, O = ACME Industries
   i:CN = Corporate Root CA, O = ACME Industries
Verify return code: 19 (self signed certificate in certificate chain)

Издатель не ваш, а корпоративный: это TLS-инспекция, фаервол клиента расшифровывает трафик, подписывая своим корнем. Всё, что делает pinning, ломается по определению; ломаются и клиенты со своим набором корней (Java с собственным cacerts, контейнеры из scratch). Документируйте для клиентов, куда добавлять корпоративный CA (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, keytool -importcert), либо просите внести домен в исключения. Соседний симптом — блокировка по SNI: RST сразу после ClientHello, в дампе выглядит как обрыв через миллисекунду после успешного рукопожатия TCP. Детали — в статье про TLS и в треке security.

Дерево решений

Правило, экономящее больше всего времени: двигайтесь снизу вверх и подтверждайте каждый уровень дампом, а не рассуждением. «Фаервол точно открыт, я вчера смотрел» — не подтверждение. Растущий счётчик пакетов на правиле accept — подтверждение.

Чеклист проектирования

  • Никогда не требуйте входящего соединения к клиенту — только исходящие от клиента.
  • Прикладной keepalive 30–60 секунд на всех долгоживущих соединениях.
  • Idle timeout в пулах строго меньше минимального idle timeout на пути.
  • MSS clamping на каждом туннельном шлюзе, tcp_mtu_probing=1 на публичных фронтендах.
  • ICMP fragmentation needed и packet-too-big разрешены везде, где вы владеете фильтром.
  • Внутри периметра REJECT, снаружи DROP.
  • Rate limit не только по IP: за одним адресом CGNAT тысячи абонентов.
  • В логи пишется порт источника, а не только адрес.
  • Внутренние подсети — случайные из 10.0.0.0/8, не 192.168.1.0/24.
  • Split-DNS настроен как routing-домен, а не подмена всего резолвера.
  • Сервис доступен по IPv4 и IPv6, оба пути проверяются мониторингом.
  • Есть синтетический мониторинг из сетей клиентов: мобильные операторы, другие страны, корпоративные прокси.
  • Документирован сценарий с TLS-инспекцией: какой CA добавлять и куда.
  • Метрики: доля relay-кандидатов ICE, доля соединений с большим bytes_retrans, счётчики conntrack.

Как индустрия сюда пришла

Отдельная линия — IPv6. NAT в нём не предусмотрен: адресов хватает, каждый хост снова глобально адресуем. Это убирает половину описанных проблем (нет таблицы трансляций, нет исчерпания портов, нет NAT traversal) и обостряет другую: фаервол становится единственной защитой, а фрагментация на маршрутизаторах запрещена вовсе — PMTUD обязательна, и блокировка ICMPv6 packet-too-big ломает сеть гарантированно. Плюс свой класс «у клиента нет»: у домена есть AAAA, IPv6-путь до вас сломан, клиент зависает. Happy Eyeballs (RFC 8305) маскирует это переключением на IPv4 через ~250 мс, но только у клиентов, которые его реализуют, — браузеры да, самописный HTTP-клиент чаще нет.

curl -4 -sS -o /dev/null -w '%{http_code} %{time_total}\n' https://api.example.com/health
curl -6 -sS -o /dev/null -w '%{http_code} %{time_total}\n' https://api.example.com/health

Расхождение результатов — готовый диагноз; подробности адресации в статье про IP и маршрутизацию.

Мини-итог

NAT, фаервол и VPN объединяет одно свойство: они меняют поведение сети незаметно для обеих сторон соединения. Поэтому «у меня работает, а у клиента нет» — почти всегда не баг в коде, а разница в пути.

NAT — это состояние в чужой памяти. У записи есть таймаут, у устройства — предел по числу записей и портов. Отсюда весь класс «работало и вдруг перестало после паузы» и невозможность входящих соединений. Лекарство: keepalive короче самого короткого таймаута и архитектура, где соединение всегда открывает клиент.

Фаервол выбирает между тишиной и отказом, и этот выбор решает, потеряет пользователь 20 миллисекунд или две минуты. Фильтрация ICMP — самая дорогая «мера безопасности» в индустрии: она ломает PMTUD и превращает внятные ошибки в зависания.

Туннель всегда забирает байты. MSS clamping плюс tcp_mtu_probing закрывают почти все случаи, QUIC решает вопрос сам. Если рукопожатие TCP прошло, а дальше тишина — измеряйте MTU до того, как начнёте читать код.

Методический вывод: подтверждайте каждый уровень наблюдением. dig, ping -M do, tracepath, nc -z, tcpdump с обеих сторон, conntrack -L, ip route get, openssl s_client — восемь команд, закрывающих подавляющее большинство инцидентов этого класса.

Источники

Что дальше

Диагностика сети: методика, инструменты, разбор реальных инцидентов — превращает разрозненные команды из этой статьи в воспроизводимую методику: как локализовать проблему за минимальное число шагов, какие инструменты применять на каждом уровне и как выглядят разборы реальных инцидентов от первой жалобы до постмортема.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов