Pull to refresh
411.59
Тензор
Разработчик системы СБИС

Вся боль p2p разработки

Reading time 9 min
Views 19K
Добрый день, хабрасообщество! Сегодня я хотел бы рассказать о волшебном и чудесном проекте компании Тензор — удаленном помощнике. Это система удаленного доступа, связывающая миллионы клиентов и операторов в рамках общей клиентской базы СБИС. Удаленный помощник уже сейчас тесно интегрирован с online.sbis.ru. Каждый день мы регистрируем более десяти тысяч подключений и десятки часов сессионного времени в сутки.В этой статье мы расскажем о том, как мы устанавливаем p2p соединения, и что делать, если этого сделать не удается.



Опыт — сын ошибок трудных


Систем удаленного доступа существует достаточно много. Это и всевозможные вариации бесплатных VNC, и достаточно мощные и предлагающие широкий набор функционала платные решения.Изначально наша компания использовала адаптацию одного из таких решений — UltraVNC. Это отличная бесплатная система, которая позволяет подключиться к другому ПК, зная его IP. Вариант того, как стоит поступать, если ПК имеет непрямой доступ к сети интерне, уже мелькал на просторах Хабра, и мы не будем затрагивать эту тему. Этого решения будет достаточно только до достижения сравнительно небольшого количества одновременных подключений. Шаг влево, шаг вправо, и начинается головняк с масштабированием, удобством использования, интеграцией в систему и сложностью доработок, которые, конечно, появляются в процессе жизненного цикла ПО, с чем мы и столкнулись.

Итак, было принято решение изобрести свой велосипед создать свою систему управления удаленными рабочими столами, которую можно было бы интегрировать в общую экосистему СБИС. Конечно, самый простой способ связать 2 ПК, который не использует только ленивый — по числовому идентификатору. В нашей реализации мы используем рандомные 6-и знаковые номера без привязки к конкретному клиенту.

Один очень известный человек однажды сказал:

Теория — это когда все известно, но ничего не работает.
Практика — это когда все работает, но никто не знает почему.
Мы же объединяем теорию и практику: ничего не работает…
и никто не знает почему!

В самом начале нашего пути, эта цитата была очень похожа на правду: было понимание каким образом можно «познакомить» друг с другом клиента и оператора. Но на практике все оказалось не совсем тривиально.

Введение в p2p


Для связи 2х устройств мы используем сигнальный сервер — посредник, доступ к которому есть у обеих сторон. Его роль заключается в регистрации и возможности обмена информацией между участниками в режиме реального времени. Через него без лишних хлопот мы производим обмен endpoint’ами (связка IP-адрес и порт, точка доступа) с целью установки соединения.



Этот сигнальный сервер, именуемый у нас remote helper manager(RHM) — пул написанных на nodejs систем, обеспечивающих отказоустойчивую работу всего сервиса. Нууу, точнее, как «отказоустойчивую» … мы на это надеемся :). Подключение к одному из серверов происходит по принципу round-robin. Таким образом клиент и оператор могут быть подключены к разным серверам, и вся механика по их синхронизации и координации полностью снята с десктопного приложения.

Вся работа сводится к обмену служебными пакетами, при помощи которых стороны могут однозначно идентифицировать друг друга и выполнять какие-либо действия относительно синхронно, например, начать процедуру сбора кандидатов на подключение или же начало самой попытки подключения.

Кстати, не поступайте как мы – не стреляйте себе в ногу: если используете 443 TCP порт — используйте TLS, а не чистый трафик. Все больше и больше брандмауэров его блокируют и разрывают соединение, причем, нередко на стороне провайдера.


Самые распространенные в сети интернет протоколы обмена информацией — это UDP и TCP. UDP — быстр и легок, однако лишен нативной возможности гарантировать доставку пакетов и их очередность. TCP лишен этих недостатков, однако чуть более сложен в процессе установки p2p соединения. А с последними тенденциями, как мне кажется, прямое tcp соединение и вовсе может кануть в лету.

Далеко не всегда установка p2p соединения зависит от умения работать с сетевыми протоколами. По большей части эта возможность зависит от конкретных сетевых настроек, чаще: типа NAT(Network address translation) и/или настроек файрвола.

Принято разделять NAT на 4 типа, каждый из которых отличаются правилами трансляции пакетов из внешней сети конечному пользователю:

  • Symmetric NAT
  • Cone/Full Cone NAT
  • Address restricted cone NAT
  • Port restricted cone NAT

Кстати, некоторые продвинутые устройства позволяют выбрать режим прямо из конфигурационной панели, но сейчас не об этом.

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

Чтобы узнать свой IP-адрес и порт на внешнем устройстве (для простоты назовем его маршрутизатором), мы используем STUN (Session traversal utilities for NAT) и TURN (Traversal using relay NAT) сервера. STUN – для определения внешних IP: порт(endpoint) на UDP протокола, TURN – для TCP.

Почему так, ведь гораздо проще было бы получить внешний IP с нашего же сигнального сервера?

Здесь имеется как минимум 4 аргумента «за»:

  1. Возможность прозрачного расширения списка серверов (как своих, так и общедоступных) для сбора endpoint’ов, таким образом повысить отказоустойчивость системы.
  2. Взаимодополняемость и широкое распространение протоколов STUN и TURN позволяет уделять минимум внимания на сбор endpoint’ов и ретрансляцию трафика.
  3. STUN и TURN протоколы очень похожи. Разобравшись с архитектурой STUN пакетов, TURN идет уже по «накатанной». А использование TURN дают нам возможность ретрансляции трафика при провале попытки установить прямое подключение.
  4. У нас уже использовался STUN/TURN сервер «coturn» в проекте видеозвонков, а значит можно было «заюзать» их мощности с минимальными вливаниями в «железо».

Coturn — это opensource реализация TURN и STUN сервера. Его использование, как показала практика, совсем не ограничивается WebRTC. На мой взгляд, это достаточно гибкий инструмент, не сильно требовательный. Да, у него нет встроенной возможности горизонтального масштабирования, но все решаемо, например, при помощи сигнального сервера.

Как же строится общение с сервером по STUN/TURN протоколу


Этапы получения endpoint’ов задокументированы в RFC #3489, #5389, #5766 и #6062.
Все сообщения к STUN или TURN протоколу имеют следующий вид:



Соответственно:

  1. 12 байта на тип сообщения
  2. 22 байта на его длину (размер всех последующих атрибутов)
  3. 12 байт — для рандомного идентификатора для TURN и 16 байт — для STUN пакетов. Их размер отличается на 4 байта — эти данные зарезервированы для TURN пакета под константный MagicCookie.

В целом служебная информация заключена в первых 20 байтах пакета.
Атрибуты также состоят из:

  1. 2 байта на тип атрибута
  2. 2 байта на его длину
  3. самого значения атрибута

Важно, что общая длина атрибута должна быть кратна 4 байтам. Если, скажем, значение длины атрибута, например 7, то в конце необходимо доукомплектовать: (2 + 2 + 7) % 4 байтами пустых данных.

Как выглядит сбор endpoint’а для UDP протокола:

  1. Коннект к серверу
  2. Отправка пакета, содержащего binding request:
  3. Получение пакета, содержащего binding response:
  4. Парсинг пакета и извлечение mapped-address:
    0x00 0x01 — Тип атрибута, соответствующий MAPPED-ADDRESS
    0x00 0x08 — Совокупная длина атрибута
    0x00 0x01 — Версия протокола, соответствующая IPv4
    0x30 0x39 – Порт, со значением 12345

Далее каждый байт соответствует своему октету ipv4 адреса: 123.123.123.123

Сбор endpoint’а для TCP несколько отличается, т.к. получаем мы его по TURN протоколу. Почему именно так? Все объясняется минимизацией количества сокетов, подключенных к TURN-серверу, а значит, потенциально большее количество людей смогут «висеть» на одном сервере ретрансляции трафика.

Для сбора кандидата по TURN протоколу необходимо:

  1. Подключиться к серверу.
  2. Отправить пакет, содержащий allocation request.
  3. При необходимости авторизации на TURN сервере в ответ мы получим allocate failure с 401 ошибкой. В таком случае необходимо будет повторить allocation request с указанием имени пользователя и атрибута Message Integrity, генерируемого на основании самого сообщения, имени пользователя, пароля и атрибута realm, взятого из полученного от сервера ответа.
  4. Далее сервер в случае успешной регистрации присылает allocate success response с атрибутом выделенного порта на TURN-сервере, а также XOR-MAPPED-ADDRESS – тем самым публичным endpoint’ом на TCP протоколе. Для дальнейшей работы с IP каждый октет надо «заксорить» (XOR — операция логического исключения ИЛИ) аналогичным байтом из константного атрибута MagicCookie: 0x21 0x12 0xA4 0x42
  5. В случае дальнейшей работой с этим TURN соединением необходимо каждый раз продлять регистрацию, отправляя refresh request. Сделано это для отбрасывания «мертвых» коннектов.

Итак, мы имеем сервер, через который мы обменялись с удаленной стороной собранными endpoint’ами.

Конечно, это сейчас кажется простым и понятным, но оглядываясь назад, когда смотришь в RFC и понимаешь, что без подсказок wireshark’а дальше дело не сдвинется с мертвой точки — готовишься к погружению в… В общем, вспоминается один бородатый анекдот:

Учись пацан, а то так и будешь ключи подавать…


Как установить соединение?


Самое простое – это организация UDP hole punch’а.
Для этого необходимо искусственно создать правила маршрутизации на своем NAT.



Достаточно просто организовать серию передачи пакетов на удаленный endpoint и дождаться от него ответ. Несколько пакетов необходимы для создания соответствующего правила на NAT’е и избавления от «гонки», кто кому первым доставит соответствующий пакет. Ну и потерю на UDP никто не отменял.

Далее обменялись контрольными фразами и можно считать, что соединение установлено.

Чуть-чуть сложнее – организация TCP hole punch, хотя общая идеология остается точно такой же.

Сложность заключается в том, что только 1 сокет по умолчанию может занимать свой локальный endpoint, а попытка подключения к другому адресу приведет к автоматическому разрыву соединения с первым. Однако существуют опции сокета, это ограничение снимающие: REUSE_ADDRESS и EXCLUSIVEADDRUSE. После взведения первой и сбрасывания второй опции на сокете другие сокеты смогут занимать тот же самый локальный endpoint.

Ну и остается сущий пустяк – забиндиться на локальный endpoint, открытый сокетом при коннекте с TURN’ом, ну и попытаться подключиться к endpoint’у удаленной стороны.

Ну и еще чуть сложнее, но не менее важная для стабильной установки соединения – ретрансляция трафика.

  1. Т.к. регистрация на TURN’е у нас уже имеется, все, что нам необходимо – это добавить в разрешения на TURN’е регистрацию удаленной стороны. Для этого отправляем пакет CreatePermission с указанием удаленной регистрации.
  2. Инициатор соединения отправляет пакет ConnectRequest с указанием «заксоренного» endpoint’а удаленной регистрации и подписывает пакет MessageIntegrity.
  3. Если все хорошо и удаленная сторона отправляла CreatePermission с вашей регистрацией, то инициатору придет connect success response, а клиенту – connection attempt. И в том, и в другом случае во входящем пакете будет присутствовать атрибут connection-id.
  4. Далее за непродолжительный промежуток времени необходимо новым сокетом подключиться к тому же IP и порту TURN сервера, что и первоначальный сокет (в классическом исполнении TURN сервера могут слушать как 3478, так и 443 tcp порты) и отправить пакет ConnectionBind с нового сокета с указанием connection-id, полученного ранее.
  5. Дождаться пакета, содержащего connection bind success response, и вуаля – соединение установлено. При этом да, используется 2 сокета — управляющий, который отвечает за поддержание соединения, и транспортный, с которым можно работать как при прямом соединении – все, что будет отправлено или получено, должно обрабатываться как есть.

По приоритету использования у нас выстроилась такая иерархия: прямое tcp > прямое udp > релей (ретрансляция)

Почему мы унесли прямое udp на второе место?


Что ж, UDP при всей своей легкости и скорости обладает существенным недостатком: отсутствием гарантии доставки и очередности. И если с видеопотоком еще как-то с этим можно было бы смириться (наличие графических артефактов), то вот с передачей файлов тут несколько серьезней.

Для обеспечения гарантии и очередности был реализован механизм, схожий с reliable UDP, который да, потребляет несколько больше ресурсов, но и дает желаемое.
Как же мы вышли из ситуации? Для начала необходимо узнать MTU (maximum transmission unit) – то есть максимально большой размер udp пакета, который может быть отправлен без фрагментирования на проходящих узлах.

Для этого принимаем за максимальный размер пакета 512 байт и выставляем сокету опцию IP_DONTFRAGMENT. Отправляем пакет и ждем его подтверждения. Если в течение фиксированного времени мы получили ответ, то увеличиваем максимальный размер и повторяем итерацию. Если же в конечном итоге подтверждения мы не дождались, то начинаем процедуру уточнения размера MTU: начинаем не существенно понижать максимальный размер блока и ожидаем стабильного подтверждения в течение 10 раз. Не получили подтверждение – снизили MTU и по новой запускаем цикл.
Оптимальный размер MTU найден.

Далее проводим сегментирование: нарезаем весь большой блок на множество маленьких с указанием начального номера сегмента и конечного номера сегмента, характеризующего пакет. После разбиения добавляем сегменты в очередь отправки. Отправка сегмента производится до тех пор, пока удаленная сторона не сообщит нам о том, что получила его. Интервал повторной отправки используем как 1.2*максимальный размер ping’а, полученного при нахождении MTU.
На принимающей стороне смотрим полученный сегмент, добавляем во входящую очередь и пробуем собрать ближайший пакет. Если получилось – чистим очередь и пробуем собрать следующий.

Тут, конечно, самые внимательные из вас, кто «дожил» до этого абзаца, могут смело заметить: а почему не использовать кодек x264 или x265? — и будут частично правы. Честно говоря, мы тоже склонны его заюзать, тогда можно поступиться этим велосипедом на udp. Но как быть, скажем, с передачей бинарных файлов? В этом случае мы опять возвращаемся к необходимости гарантии доставки и очередности пакетов.

В заключение хочется отметить, что с такой организацией подключений мы имеем не более 2-3% несостоявшихся подключений в день, большую часть из которых составляют неверные настройки прокси или файрвола, настроив которые соединение осуществляется без проблем.

Если эта тема вам окажется интересна (а нам она кажется таковой), то в следующих статьях мы расскажем о запуске приложения с наивысшими правами в системе, проблемах, которые из-за этого образуются, и как мы с ними боремся. Об алгоритмах сжатия, виртуальных рабочих столах и многом другом.

Автор: Владислав Яковлев asmsa
Tags:
Hubs:
+9
Comments 22
Comments Comments 22

Articles

Information

Website
sbis.ru
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия