Пробиваем дыры в NAT
NAT - механизм, создающий множество проблем для P2P коммуникации, в силу того, что нередко пир может не иметь доступного из любой точки мира, "белого" адреса. Существует ряд способов обхода NAT, но их документация, равно как и данные об их надежности, достоинствах и недостатках оставляет желать лучшего, а потому мы рассмотрим наиболее простой, и в то же время надежный метод - "hole punching".
Некоторая терминология
Эндпоинт - пара адрес - порт. прим. пере.: перевод этого как "конечная точка" уж очень сильно режет мой слух и напоминает небезызвестное "киборг - убийца", в дальнейшем, по ходу статьи, начиная с конца части "UDP hole punching" этот термин сменится на "адрес", так, как это не будет вызывать путаницы в контексте.
Сессия - соединение, однозначно идентифицируемое двумя эндпоинтами.
Пир - участник однорангового соединения.
1. Введение
Невероятный рост, вкупе с постоянно растущими требованиями к безопасности привели к тому, что эволюция интернета пошла путями, заметно усложняющими жизнь для множества приложений. Изначальная система адресов, в которой каждый узел имел собственный уникальный IP-адрес и мог общаться напрямую с любым другим узлом сети была фактически заменена на архитектуру новую, состоящую из глобальной сети и множества локальных, связанных внутри с помощью NAT. В условиях этой новой архитектуры лишь узлы, что находятся в "глобальной" сети могут беспрепятственно напрямую общаться друг с другом. Те же из них, что находятся под NAT имеют возможность напрямую общаться только в рамках своей локальной сети и обычно могут используя TCP или UDP общаться с адресами, расположенными в "глобальном" пуле. NAT, в свою очередь предоставляет временные эндпоинты для исходящих соединений и преобразует адреса и номера портов в пакетах, при этом обычно блокируя любые входящие пакеты, что не относятся к активным соединениям.
Эта новая архитектура очень удобна для соединений формата клиент - сервер, когда сервер находится в глобальном пуле адресов, а клиент - под NAT, но она не позволяет двум пирам, что находятся в разных локальных сетях коммуницировать друг с другом, а потому метод преодоления NAT необходим. Одним из наиболее известных способов является упомянутый "hole punching", часто применяющийся в приложениях, использующих UDP, хотя де-факто аналогичный прием может быть спокойно с TCP. Несмотря на устрашающее название, "hole punching" не ухудшает безопасность сети, так, как работает сугубо в рамках политики безопасности большинства NAT.
К сожалению, в силу того, что NAT это в целом технология не стандартизированная, ни одна из техник его преодоления не гарантирует стопроцентного успеха, хотя рассматривая hole punching мы будем периодически смотреть и на более сложные его вариации, что могут работать с бо́льшим количеством разнообразных NAT.
Несмотря на то, что IPv6 обещает избавить нас от NAT, в краткосрочной перспективе его внедрение лишь увеличивает спрос, так, как NAT - простейший способ достижения совместимости IPv4 и IPv6. Также, маловероятно, что при отсутствии дефицита адресов пропадут фаерволы - IPv6 фаерволы будут так же блокировать большую часть поступающего трафика, делая hole punching актуальным даже в надвигающуюся эру IPv6.
2. Общие концепции
2.1. Немного терминологии
Исходящий NAT - наиболее популярный вариант NAT, предоставляющий асимметричный мост между локальной и публичной сетями, по умолчанию пропускает лишь исходящие соединения - входящие пакеты, за исключением тех, что он распознает, как относящиеся к активным подключениям через подобный NAT не проходят. Исходящий NAT плохо стыкуется с концепцией peer-to-peer соединений, так, как оба пира находятся под NAT и ни один из них не может начать общение из-за того, что NAT собеседника не пропустит входящие пакеты, не относящиеся к уже начатым соединениям.
Существует два вида исходящего NAT - стандартный, что транслирует только IP адреса и NAPT, транслирующий адрес и порт. На данный момент NAPT - наиболее распространенный вариант NAT, так как он позволяет всем, кто находится в одной локальной сети использовать один публичный адрес, в этой статье мы будем рассматривать все относительно NAPT, но на деле те же техники применимы и к обычному NAT.
2.2. Ретрансляция
Ретрансляция, или же использование реле - наиболее надежный, но в то же время наименее эффективный способ обхода NAT, заключающийся в создании сервера - ретранслятора.
Назовем наш сервер S. Пусть два клиента - A и B желают обмениваться сообщениями, и для этого инициируют TCP или UDP соединение с общеизвестным S, допустим на адрес 18.181.0.31 и порт 1234. Клиенты находятся под разными NAT, что не дает им соединиться друг с другом. Потому вместо того, чтобы пытаться соединиться напрямую клиенты могут просто соединиться с S, который будет пересылать сообщения, полученные от каждого из них собеседнику. Например, чтобы передать сообщение клиенту B клиенту А потребуется лишь переслать сообщение серверу S, а тот в свою очередь передаст его клиенту В.
Основным минусом подобной техники является необходимость держать сервер и тратить его мощности, также необходимость подключаться к серверу замедляет соединение между клиентами. Тем не менее, это все еще один из лучших способов обхода NAT, если требуется максимальная надежность.
2.3. Обратное соединение
Некоторые P2P приложения используют простой, но ограниченный метод, известный, как "обратное соединение", чтобы сделать возможной коммуникацию между двумя клиентами, один из которых находится под NAT, в то время, как другой имеет публичный IP-адрес, используя сервер S.
Если клиент А, что находится под NAT желает соединиться с клиентом B, он может сделать это напрямую, так, как NAT A распознает это как исходящее соединение, в случае же если В желает инициировать соединение он не сможет этого сделать напрямую, так, как NAT A будет блокировать входящие пакеты. Вместо этого B может соединиться с сервером S, с которым А уже соединен и "попросить" A инициировать соединение. Несмотря на очевидные ограничения этой техники, сама идея использования сервера для помощи в создании прямых подключений является фундаментальной для ряда иных способов обхода NAT.
3. UDP hole punching
3.1. Сервер обратной связи
Для hole punching необходимо, чтобы А и В уже имели активную UDP сессию с S. Когда клиент обращается к S, тот записывает две пары из адреса и порта - то, как клиент видит себя, и то, как его видит сервер. Первая пара - локальный эндпоинт, вторая - глобальный, или можно сказать публичный. Первая пара отправляется клиентом серверу в теле сообщения, а вторую сервер получает из заголовков UDP. Если же клиент не находится под NAT, то его публичный и локальный адреса будут совпадать.
3.2. Установление P2P сессий
Предположим клиент А желает установить прямое соединение с В. Тогда последовательность действий для осуществления hole punching будет следующей:
А изначально не имеет понятия о том, как соединиться с B, а потому просит сервер помочь в установке UDP соединения.
S отвечает на это сообщение публичной и локальной парами адрес - порт клиента В, в то же время S использует уже имеющуюся UDP сессию с В чтобы отправить В сообщение с запросом соединения от А, содержащее публичный и локальный эндпоинты А. Как только эти сообщения получены, оба клиента знают обе пары адрес - порт друг друга.
Когда А получает два эндпоинта В от S, А начинает отправлять пакеты в обе точки, и затем уже работает с той, что вернет валидный ответ от В. Аналогично поступает и В, начиная отправлять пакеты на известные ему публичную и локальную пары А и В, пока не получит удовлетворительного ответа от одной из них, после чего перестанет отправлять пакеты другому адресу.
Теперь же мы оценим как UDP hole punching работает в трех разных сценариях. Первая ситуация, она же простейшая из всех трех - оба клиента находятся в одной локальной сети. Во втором случае оба клиента находятся под одним уровнем NAT. В третьем сценарии оба клиента находятся под двумя слоями NAT: NAT "первого уровня", широко используемый интернет - провайдерами и NAT "второго уровня", обычно представленный обычными роутерами клиентов провайдера.
В общем и целом приложению очень сложно либо совсем невозможно определить физическую архитектуру некой сети, и соответственно столь же тяжело определить, в каком из трех сценариев оно оказалось. Протоколы, такие как STUN конечно дают немного информации о NAT, стоящих на пути, но эта информация не всегда полная или точная, особенно когда имеется больше одного уровня NAT. Тем не менее, hole punching работает во всех трех сценариях без необходимости указывать приложению, в каком из них мы оказались.
3.3. Пиры находятся под одним и тем же NAT
Сначала мы рассмотрим самый простой из случаев, в котором оба клиента, вероятно не зная этого, находятся под одним и тем же NAT, в одной и той же локальной сети. На иллюстрации показан пример, в котором клиент А начал UDP сессию с S, которой его NAT присвоил публичный порт 62000. B так же инициализировал сессию с S, которой NAT присвоил публичный порт 62005.
Предположим, что клиент А использует описанную выше технику hole punching для того, чтобы установить соединение с B, используя сервер S. А сообщает S о своем желании установить соединение с В, S отвечает ему локальной и публичной парами адрес - порт В, и передает оба эндпоинта А клиенту В. Затем оба из них начинают пытаться отправить друг другу датаграммы в обе из известных каждому точек. Сообщения, отправленные на публичные адреса друг друга могут достичь или не достичь своей цели, в зависимости от того, поддерживает ли NAT технологию NAT hairpin. И так, как пакеты, отправленные по локальным адресам очень вероятно придут быстрее, чем отправленные по публичному адресу, оба пользователя скорее всего выберут именно эти две точки для последующей коммуникации.
Если мы предположим, что NAT поддерживает NAT hairpin, мы можем избавиться от необходимости передавать локальные адреса, если для нас допустим тот факт, что все пакеты будут проходить через NAT.
3.4. Пиры находятся под разными NAT
Предположим, что локальные адреса А и В находятся под разными NAT, как показано на рис.5. А и В инициировали UDP сессии между своим локальным портом 4321 и портом сервера S 1234. Транслируя эти исходящие сессии, NAT A приписал порт 62000 на своем адресе 155.99.25.11 для использования в рамках сессии с S, а NAT B приписал порт 31000 138.76.29.7.
В своем сообщении серверу, А передает серверу, что его локальная пара 10.0.0.1:4321, где 10.0.0.1 - адрес А в локальной сети. S записывает локальный эндпоинт А, и в то же время читает из заголовков UDP публичную пару адрес - порт А, допустим 155.99.25.11:62000, являющуюся временным эндпоинтом, которую NAT приписал этому исходящему соединению. Аналогично, когда В начинает общение с сервером, S записывает его локальный endpoint как 10.1.1.3:4321, а публичный как 138.76.29.7:31000.
Теперь А проходит всю процедуру hole punching, описанную выше для того, чтобы установить UDP соединение с В. Во-первых А отправляет S запрос, сообщая о желании соединиться с В. В ответ S отправляет А публичную и локальную пары адрес - порт клиента В, а клиенту В отправляет эндпоинты клиента А, после чего А и В начинают отправлять датаграммы в обе известных каждому точки.
Так, как А и В находятся в двух разных локальных сетях, сообщения, отправленные по локальным адресам либо не найдут получателя, либо будут доставлены неверному получателю. Так, как многие NAT также являются DHCP серверами, распределяющими IP-адреса определенным образом из пула, определяемого поставщиком NAT, на практике довольно вероятно, что сообщение, отправленное А локальному эндпоинту В может найти неверного получателя в локальной сети, в которой находится А, так как чисто случайно сложилось, что его адрес совпал с адресом В в локальной сети В. Потому приложениям следует аутентифицировать все сообщения, дабы фильтровать подобный трафик. Например, сообщения могут содержать криптографические токены, или хотя бы случайный одноразовый номер сессии, заданный с помощью S.
Представим, что первое сообщение А отправляется В. Когда оно проходит через NAT A, этот NAT замечает, что что это первый UDP пакет в новой сессии. Эндпоинт - источник новой сессии(например, 10.0.0.1:4321) совпадает с источником уже существующей сессии между А и S, но направление новой сессии уже другое. Если NAT ведет себя как нужным образом, то он сохраняет настройки локального эндпоинта А, постоянно транслируя все исходящие сессии из 10.0.0.1:4321 на соответствующий свой порт, например 62000. Таким образом первое сообщение от А В "пробивает дыру" в NAT A для нового UDP соединения, описываемое парой (10.0.0.1:4321, 138.76.29.7:31000) в локальной сети А и парой (155.99.25.11:62000, 138.76.29.7:31000) в глобальном интернете.
Если сообщение А, направленное по публичному адреса В дойдет раньше, чем В "пробьет дыру", NAT, находящийся над B может интерпретировать его как нежелательный трафик и не пропустить его. Первое сообщение от В, отправленное на публичный адрес А тоже "открывает дыру" для новой UDP сессии, описываемой парами (10.1.1.3:4321, 155.99.25.11:62000) и (138.76.29.7:31000, 155.99.25.11:62000). Как только первые сообщения каждого из клиентов прошли соответствующие NAT "дыры" открыты в оба направления и позволяют полноценно коммуницировать с помощью UDP. Как только клиенты верифицируют публичные эндпоинты, они прекращают попытки передачи сообщений локальным адресам друг друга.
3.5. Пиры находятся под многоуровневым NAT
В некоторых кейсах, когда топология сети содержит больше одного NAT, клиенты не могут установить "оптимальный" P2P маршрут без знания конкретной топологии сети. Предположим, что существует NAT C, огромный NAT, развернутый интернет - провайдером, для того, чтобы использовать лишь несколько адресов для огромного количества клиентов, в то время, как А и В клиенты этого провайдера. В этой модели только сервер S и NAT C имеют глобально доступные IP- адреса. "публичные" адреса NATов A и B на деле же являются локальными адресами в сети под NAT C, а адреса самых клиентов А и В являются локальными адресами в сетях под NAT A и NAT B соответственно. Каждый из клиентов обращается к серверу S, заставляя NAT A и NAT B делать трансляцию адреса локального в публичный, и заставляя NAT C сделать трансляцию локального адреса в публичный для двух сессий.
Теперь представим, что А желает соединиться напрямую с В с помощью UDP методом hole punching. Наиболее оптимальной стратегией для клиента А будет отправлять свои пакеты по "полу-публичному" адресу В, допустим эндпоинту 10.0.1.2:55000 в адресном пространстве интернет - провайдера, и для клиента В соответственно наиболее оптимально отправлять свои пакеты на "полу-публичный" адрес А, допустим 10.0.1.1:45000. К сожалению, А и В не имеют никакого способа узнать эти адреса, так, как сервер S видит только эндпоинты на адресе NAT C, допустим 155.99.25.11:62000 и 155.99.25.11:62005. Даже если А и В узнают адреса своих NAT, вероятно они все равно будут бесполезны, так, как адреса в сети провайдера могут конфликтовать с адресами в локальных сетях клиентов.
Потому у клиентов нет выбора, кроме как использовать глобально доступные эндпоинты, которые видит сервер S для P2P коммуникации, и полагаться на то, что NAT C использует технологию hаirpin или loopback. когда А отправляет эндпоинту В, 155.99.25.11:62005, UDP пакет, NAT А сначала транслирует локальный адрес А 10.0.0.1:4321 на "полу-глобальный" адрес 10.0.1.1:45000. Затем датаграмма достигает NAT C, который понимает, что эндпоинт, в который должен прийти этот UDP пакет - та, которую этот NAT сам и транслирует. Если NAT C работает должным образом, он "понимает" это и отправляет датаграмму назад в свою сеть, но уже эндпоинту NAT B - 10.0.1.2:55000, выставляя 155.99.25.11:62000 как источник. NAT В же передает датаграмму по эндпоинту В. Аналогично этот путь работает и в обратную сторону. Многие NAT не имеют технологии hairpin, но она становится все более и более популярной, так, как все больше и больше поставщиков NAT понимают эту проблему.
3.6. Таймауты простоя UDP
Так, как UDP не предоставляет NAT надежного метода узнать длительность сессии, NAT вешает таймер простоя на транслируемые адреса для UDP, "закрывая дыру" в случае если таймер истек. К сожалению не существует стандартного значения этого таймера, у некоторых NAT он может быть не больше 20 секунд. Если приложению требуется поддерживать сессию активной после ее начала методом hole punching, то ему придется периодически отправлять "keep alive" пакеты, содержание которых безразлично лишь ради поддержания самого существования "дыры".
К сожалению, многие NAT сопоставляют каждой сессии свой таймер, а потому отправка "keep alive" пакетов для одной сессии не сохранит "дыру" для остальных сессий на том же порту. Дабы избежать бесполезного трафика из огромного количества "keep alive" пакетов для множества сессий приложениям может быть проще распознавать когда UDP сессия становится неактивной и проходить процесс "пробивания дыры" заново.
4. TCP hole punching
TCP hole punching не сильно отличается от установки P2P соединений с помощью UDP hole punching, но имеет ряд своих нюансов и поддерживается меньшим количеством NAT.
4.1. Сокеты и повторное использование портов TCP
Основная трудность при имплементации TCP hole punching кроется не в протоколе, а в устройстве API. Так, как API сокетов был создан вокруг парадигмы клиент\сервер, API позволяет использовать метод connect()
для инициации исходящих соединений и методы listen()
и accept()
для прослушивания входящих соединений, но не делать оба этих действия одновременно. Более того, обычно TCP сокеты имеют однозначное соответствие портам localhost
: после того, как один сокет привязан к одному порту, другой сокет к этому порту привязать уже нельзя.
Для того чтобы TCP hole punching мог работать, нам следует использовать один сокет для прослушивания входящих соединений и множество сокетов для исходящих. К счастью, все основные современные операционные системы поддерживают опцию, обычно называемую SO_REUSEADDR
, что позволяет привязать к одному локальному эндпоинту множество сокетов, при условии, что каждый из них имеет эту настройку. Системы семейства BSD имеют опцию SO_REUSEPORT
, что позволяет контролировать повторное использование портов отдельно от повторного использования адреса.
4.2. Открытие P2P TCP потоков
Предположим, что клиент А желает установить P2P соединение с помощью протокола TCP с клиентов В. Как и до этого, мы предполагаем, что оба клиента уже имеют активное TCP соединение с сервером S. Сервер записывает локальный и публичный эндпоинты точно так же, как это делалось для UDP. На уровне протокола TCP hole punching работает примерно так же как и версия с использованием UDP:
Клиент А использует активное соединение с сервером S, дабы попросить S помочь ему с соединением с В.
S отвечает на запрос публичным и локальным адресами В, и в то же время отправляет В адреса А.
Из тех же портов, из которых они соединялись с S, клиенты асинхронно делают попытки установить соединение с публичный и локальным эндпоинтами друг друга, в то же время прослушивая на предмет входящих попыток соединения свои соответствующие порты.
А и В ждут, момента, когда их попытки установить соединение увенчаются успехом или когда они получат входящее соединение. Если же попытки установить исходящее соединение не удается из-за ошибки сети, "host unreachable" или "connection reset", хост снова пытается установить соединение по прошествии небольшой задержки длительностью, например, в секунду.
Когда TCP соединение установлено, клиенты должны аутентифицировать друг друга, чтобы подтвердить, что соединение действительно установлено с нужным хостом. В дальнейшем клиенты используют первый успешно аутентифицированный TCP поток, полученный из этого процесса.
В отличие от UDP, которому требуется лишь один сокет для коммуникации с S и любым количеством пиров одновременно, с использованием TCP каждому клиенту требуется несколько сокетов, привязанных к одному порту, как показано на рис.7. Каждому клиенту нужен сокет, для соединения с S, сокет, для входящих попыток соединения и два сокета для исходящих попыток соединиться с публичным и локальным эндпоинтами пира.
В общем случае, когда клиенты А и В находятся под разными NAT, как показано на рис.5, попытки соединиться с локальными адресами обернутся для клиентов неудачей, или достигнут неверного хоста, а потому как и в ситуации с UDP, приложениям требуется аутентифицировать пиров, дабы избежать случайного соединения с другим хостом, находящимся в локальной сети.
В то же время, попытки клиентов соединиться с публичными адресами друг друга "пробьют дыры" в NAT, сделав коммуникацию между А и В с помощью TCP возможной. Если первый SYN пакет А достигнет В до того, как SYN пакет В пройдет через NAT В, NAT с большой вероятностью распознает его как нежелательный трафик и отвергнет, в то же время, первый SYN пакет В достигнет А, так, как на стороне А NAT уже "пробит" и NAT А видит этот пакет как часть уже активной сессии.
4.3. Поведение, наблюдаемое приложением
То, что увидят приложения, наблюдая за тем, что происходит с их сокетами во время TCP hole punching зависит от таймингов и используемой имплементации TCP. Предположим, что как и в предыдущем примере первый пакет, отправленный от А В NAT последнего заблокировал, но первый SYN пакет, отправленный В таки достиг А, пройдя через NAT A, до того, как А отправил свой SYN заново. В зависимости от операционной системы, может произойти два сценария:
А замечает, что адреса сессии, которой принадлежит входящий SYN совпадают с адресами сессии, которую пытается начать А. Тогда TCP стек А будет ассоциировать новую сессию с сокетом, который приложение на машине А использовало для
connect()
с публичным адресом В. Вызовconnect()
выполняется, и с прослушивающим сокетом ничего не происходит.В другом же случае, А может заметить, что у А есть прослушивающий сокет, что ожидает входящих попыток соединения. Так, как SYN пакет от В выглядит как входящая попытка соединение, А открывает новый прослушивающий сокет, ассоциируемый с новой TCP сессией, и передает его приложению во время следующего выполнения метода
accept()
на прослушивающем сокете. Так, как исходящий от Аconnect()
использует ту же пару адресов, что уже используется иным сокетом, он выдаст ошибку "address in use". Тем не менее, на этот момент у приложения уже есть потоковый сокет, позволяющий общаться с В, а потому эту ошибку оно проигнорирует.
Первый тип поведения характерен для операционных систем семейства BSD, в то время, как второй подход скорее присущ операционным системам семейств Linux и Windows.
4.4. Одновременное открытие TCP
Предположим, что оба SYN пакета, отправленные клиентами А и В прошли через свои NAT, открыв по исходящей сессии на каждом из них. В этом случае ни один из NAT не блокирует входящий SYN, и клиенты могут наблюдать нечто, известное как "одновременное открытие TCP": каждый из клиентов получает SYN пакет, в то время, как ожидает получить SYN-ACK. Они отвечают друг другу SYN-ACK, SYN которого де-факто повторяет ранее отправленный SYN, в то время, как ACK относится к полученному SYN. В зависимости от имплементации TCP, приложения в таких ситуациях могут наблюдать различное поведение стека TCP, если оба клиента используют второй вариант поведения, то может произойти ситуация, когда все вызовы connect()
заканчиваются ошибкой, но тем не менее оба клиента получают по рабочему сокету благодаря методу accept()
, будто TCP соединение внезапно появилось само по себе и было принято обоими клиентами. Пока приложению не важно, каким из двух методов был возвращен сокет, процесс оканчивается рабочим соединением.
TCP hole punching работает аналогично UDP hole punching во всех сценариях, для которых выше описано применение UDP hole punching.
5. Вместо заключения
Оригинальная статья не обладает полноценным заключением, из своего опыта могу сказать что hole punching довольно таки удобен для самых разных применений, особенно учитывая что сейчас многие провайдеры требуют дополнительной платы за предоставление белого ip-адреса, что для небольших проектов излишне, а P2P приложения требуют обхода NAT в любом случае.