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, входящие соединения, устаревание записей, расследование злоупотреблений) растут из неё.
Роутер меняет в 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 — все построены на исходящем соединении от клиента именно поэтому.
Фаерволы: разница между «молча» и «с отказом»
Три поколения, и каждое отвечает за свой класс отказов:
- Пакетный фильтр без состояния (ACL на маршрутизаторе, AWS Network ACL) смотрит на каждый пакет отдельно. Дёшев и быстр, но чтобы разрешить исходящее TCP, приходится руками открывать обратный трафик на весь диапазон эфемерных портов.
- Stateful-фильтр (netfilter/nftables, pf, AWS Security Group) помнит соединения: правило «разрешить исходящее» автоматически разрешает ответы. Ценой той же таблицы состояний со всеми её таймаутами.
- 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, но каждая называет свою цифру, исходя из своего локального интерфейса. Про узкое место посередине не знает никто.
Дальше вступает 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.
Дерево решений
в правильный адрес?"} Q1 -->|нет| DNS["split-horizon, VPN-резолвер,
DoH в браузере, search-домены"] Q1 -->|да| Q2{"TCP-соединение
устанавливается?"} Q2 -->|"таймаут ~127 с"| DROP["пакеты выбрасываются:
DROP, NACL, security group,
нет маршрута"] Q2 -->|"refused сразу"| REJ["дошли до хоста:
сервис не слушает
или явный REJECT"] Q2 -->|да| Q3{"большие пакеты
проходят?"} Q3 -->|"висит на первом большом"| MTU["MTU: ping -M do, tracepath,
MSS clamping, tcp_mtu_probing"] Q3 -->|да| Q4{"рвётся после
простоя?"} Q4 -->|да| IDLE["idle timeout NAT или LB:
keepalive короче таймаута"] Q4 -->|нет| Q5{"TLS проходит
проверку?"} Q5 -->|нет| MITM["TLS-инспекция, свой корневой CA,
блокировка по SNI"] Q5 -->|да| APP["проблема выше сети —
идти в логи приложения"]
Правило, экономящее больше всего времени: двигайтесь снизу вверх и подтверждайте каждый уровень дампом, а не рассуждением. «Фаервол точно открыт, я вчера смотрел» — не подтверждение. Растущий счётчик пакетов на правиле 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 — восемь команд, закрывающих подавляющее большинство инцидентов этого класса.
Источники
- RFC 3022 — Traditional IP Network Address Translator — базовая механика NAPT.
- RFC 4787 — NAT Behavioral Requirements for Unicast UDP и RFC 5382 для TCP — язык, на котором стоит описывать поведение NAT вместо «конусов».
- RFC 6888 — Common Requirements for CGN, RFC 6598 — Shared Address Space.
- RFC 1191 — Path MTU Discovery, RFC 4821 — Packetization Layer PMTUD, RFC 8899 — DPLPMTUD.
- RFC 8445 — ICE, RFC 8489 — STUN, RFC 8656 — TURN и Tailscale: How NAT traversal works — лучшее популярное объяснение пробивания NAT с реальными деталями.
- WireGuard: Next Generation Kernel Network Tunnel — статья автора протокола, читается за вечер.
- nftables wiki — практический справочник по современным правилам.
- Cloudflare: Path MTU Discovery in practice — разбор чёрных дыр MTU на реальном трафике.
- Why TCP Over TCP Is A Bad Idea — короткий классический текст про VPN поверх TCP.
Что дальше
Диагностика сети: методика, инструменты, разбор реальных инцидентов — превращает разрозненные команды из этой статьи в воспроизводимую методику: как локализовать проблему за минимальное число шагов, какие инструменты применять на каждом уровне и как выглядят разборы реальных инцидентов от первой жалобы до постмортема.