Добрый день, хабрасообщество! Сегодня я хотел бы рассказать о волшебном и чудесном проекте компании Тензор — удаленном помощнике. Это система удаленного доступа, связывающая миллионы клиентов и операторов в рамках общей клиентской базы СБИС. Удаленный помощник уже сейчас тесно интегрирован с 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

Комментарии (21)


  1. Sjam
    25.01.2018 19:59

    Интересно, что мешает использовать DHT, и ту же систему, что применяется в tox?


    1. vibornoff
      26.01.2018 13:47

      DHT не отменяет необходимость в STUN, TURN и hole punching


    1. tensor_sbis Автор
      26.01.2018 14:38

      Если я не ошибаюсь, сеть DHT подразумевает наличие постоянно запущенного приложения. В наших реалиях — средство управление запускается по мере необходимости оказать или получить помощь.
      Плюс торрентоподобные сети подразумевают возможность ретрансляции трафика через других участников сетей. Уверен клиентам это понравилось бы.
      Ну и да, как отметили чуть выше, STUN или аналогичный способ определения своего внешнего IP все же необходим.


      1. Sjam
        26.01.2018 15:06

        1. сеть tox и opendht существуют независимо, при этом ежеминутно активны десятки тысяч хостов.
        2. DHT не требует передачи трафика через клиентов, а является только таблицей адресов, точнее хешей. Своего рода адресная книга
        3. github.com/TokTok/c-toxcore/tree/master/toxcore здесь живая реализация. И да, пробивка NAT через STUN/TURN здесь может и работает, но явно без задействования внешних, дополнительных серверов.
        4. в вашей реализации требуется отдельная сущность «remote helper manager(RHM)». Вопрос в том, зачем плодить сущностей когда все придумано до нас?


        1. asmsa
          26.01.2018 16:03

          В случае если в любой момент есть хотя бы одно запущенное приложение DHT сети — согласен, проблем нет. Что если запуск происходит в момент недоступности последних известных узлов?
          «Без задействования дополнительных серверов» — да, когда есть внешний хост DHT сети — вопрос о своем внешнем IP решается сам собой, бесспорно. Но если такой возможности нет, что делать, с чего начать? На это и была нацелена статья.
          Наша структура построена на централизованном типе p2p. Ей и проще управлять и собирать статистику соединений, а так же интегрировать в панель ожидающих подключения клиентов.


          1. Sjam
            26.01.2018 16:14

            1. повторюсь, единовременно в сети существуют десятки тысяч нод DHT/OpenDHT… Чтобы не было ни одной надо полностью закрыть весь доступ во внешнуюю сеть.
            2. > Наша структура построена на централизованном типе p2p.
            в таком случае здесь и речи быть не может о p2p. В вашем случае это обычная клиент-серверная структура. Где есть сервер и есть клиенты.


            1. asmsa
              26.01.2018 16:19

              1. средство управление запускается по мере необходимости оказать или получить помощь — оно не висит в памяти без необходимости. Таким образом, скажем в часы минимальной нагрузки(скажем ночью или ближе к утру по Москве) — да, есть такая вероятность, что ни одного приложения не будет запущено вовсе. А вероятность доступности последнего известного узла — еще меньше.
              2. В рамках коннекта к rhm — да, согласен. Но дальнейшее подключение между оператором и клиентом идет непосредственно по p2p


            1. asmsa
              26.01.2018 16:22

              Кажется, понял. Предлагается завязаться не на свою DHT, а на некую общую. Да, как вариант. Но есть 2 момента:
              1. Придется уделить огромное внимание безопасности
              2. Как быть с клиентами у которых для бух.компов выход в сеть только по белым спискам? Их в топку?


              1. Sjam
                26.01.2018 16:26

                1. Какой? Там только хеши.
                2. А кто даст гарантию, что для rhm администраторы будут создавать отдельные белые списки?
                Опять же, если rhm не единичный, и/или будет масштабироваться, то кто будет обновлять постоянно белые списки?

                >2. В рамках коннекта к rhm — да, согласен. Но дальнейшее подключение между оператором и клиентом идет непосредственно по p2p
                Здесь противоречие с белыми списками. Если знакомы с VoIP SIP, то вспомните к чему приводят подобные вещи без проксирования.


                1. asmsa
                  26.01.2018 16:52

                  1. Любая открытая сеть — потенциально опасна
                  2. Гарантий нет, но есть возможность, а это уже +, чего не скажешь о dht
                  3. Абсолютно нет противоречия. Перелистайте статью — в случае, когда прямое соединение установить не удается, мы идем через релей, т.е сервер ретрансляции трафика.

                  Но Ваша позиция мне понятна, спасибо!


  1. evnuh
    26.01.2018 12:17

    А бенчмарки будут? Фпс хотя бы и лэтэнси по обычному интернет каналу и по локалке?


    1. tensor_sbis Автор
      26.01.2018 14:39

      Бенчмарки чего? Самой системы управления? Или установки соединения? Если вопрос касается только установки соединения — не более пары секунд, в зависимости от канала.
      Если говорить о качестве самой работы, то очень многое зависит от клиентского аппаратного обеспечения. Но в среднем достаточно комфортно.


  1. vibornoff
    26.01.2018 13:50

    Кстати, про reliable UDP — эдак вы еще и до управления congestion window дойдёте и получится почти полноценный TCP :-)


    1. tensor_sbis Автор
      26.01.2018 14:40

      К сожалению, до полноценного TCP дойти точно не удастся, т.к. вся нагрузка по гарантии доставки и очередности сборки пакетов лежит все-таки на приложении, а не драйвере.


  1. lorka
    26.01.2018 13:53

    Работаю с online.sbis && reg — было интересно почитать, что там под капотом, спасибо.
    Среди наболевшего:
    — API развивать будете в сторону работы с ЭЦП/сертификатами?
    — планируете как-нибудь ограничить аппетиты sbis plugin в плане оперативки? В диспетчере постоянно наблюдаю значение в 700-800 Мб, пару раз за гигабайт переваливало. Причем периодически не может переварить и давится, помогает только «тремя пальцами», что грустно.


    1. tensor_sbis Автор
      26.01.2018 14:41

      А нам в радость рассказать об этом. В ближайшее время удаленный помощник портируется на linux и os x системы. Дальнейшее развитие проекта еще на стадии обсуждения.
      Про СБИС Плагин — это, конечно, не по теме статьи, однако могу лишь намекнуть, что разрабатывается его новая реализация. Надеюсь, вы оцените! ;)


      1. Sjam
        26.01.2018 15:10

        В дополнение к вопросу выше. Будет ли работать полноценно sbis в Linux/Mac?


  1. den_admin
    26.01.2018 19:09

    А есть статистика по процентам tcp/udp/relay?


    1. asmsa
      26.01.2018 20:27
      +1

      Добрый вечер! В среднем на релей уходит около 37%, прямое udp 58% ну и оставшиеся 5% — прямые tcp по данным за январь месяц.


  1. Daar
    28.01.2018 09:41

    Как понял тут описан транспортный уровень, а интересна именно реализация захвата видео и управления, что для этого используется?


    1. asmsa
      28.01.2018 22:06

      Спасибо за интерес! этот вопрос заслуживает отдельной статьи, парой фраз тут не рассказать :)