Приветствую всех!

В этом посте представлен обзор написанной мной кроссплатформенной C++ библиотеки tubus реализующей транспортный протокол поверх UDP. Целевая аудитория C++ разработчики сетевых приложений.

Мотивация

Работая над утилитой plexus, предназначенной для связи сетевых приложений находящихся за NAT, быстро убедился, что, в отличии от UDP, для TCP протокола преодоление NAT задача куда более проблематичная и пробивание «дыр» в NAT работает крайне ненадежно. Это сильно сужало полезность утилиты ввиду того, что TCP протокол занимает доминирующее положение. Первоначальной же идеей для plexus было создание универсального инструмента преодоления NAT. Чтобы решить эту проблему было принято решение написать дополнительную утилиту wormhole туннелирующую TCP трафик через UDP канал. Учитывая то, что для TCP гонки и потеря пакетов недопустима стал вопрос о транспортном протоколе поверх UDP. На github встречаются реализации подобных протоколов. Однако они как правило не кроссплатформенные и в большинстве своем давно не поддерживаются, отзывов об их надежности нет, а потому использовать их в своем проекте не очень хотелось. В дополнение ко всему хотелось чтобы библиотека легко интегрировалась в проекты с использованием boost::asio, а примитивы имели схожий сокет‑подобный интерфейс. Дополнительной целью было реализовать опционную возможность делать протокол непрозрачным для повышения его безопасности. Есть, конечно, реализации QUIC, но для моих скромных целей он довольно избыточный, в любом случае потребовал бы написания обертки и не предусматривает обфускации. В конечном счете мои изыскания вылились в реализацию протокола названного tubus, за его способность быть непрозрачным.

Протокол

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

struct header
{
    uint64_t salt;
    uint16_t sign;
    uint16_t version;
    uint32_t pin;
};
  • salt - cодержит 0, если обфускация не используется, либо случайное число.

  • sign - cодержит сигнатуру tubus-пакета, магическое число 0x0909.

  • version - cодержит версию протокола. На настоящий момент это 0x0101, где старший байт мажорная часть версии, а младший минорная.

  • pin - содержит сессионный идентификатор пира, который должен быть уникален для каждой новой сессии, чтобы не ввести в заблуждение противоположную сторону в случае переподключения.

Тело пакета представляет из себя последовательность type-length-value блоков. Поле type и поле length по 16 бит. Существуют две разновидности блоков. Блоки с четными номерами являются базовыми, несущими полезную информацию, например, сигнал о завершении соединения или фрагмент данных потока. Блоки с нечетными номерами являются эхо-типами, подтверждениями поступления данных и сигналов.

Все возможные в данной версии типы определяются следующими флагами

enum flag : uint16_t
{
    echo = 1,
    link = 2,
    tear = 4,
    ping = 6,
    move = 8,
    edge = 10
};

где флаг echo комбинируется с другими флагами, если блок является подтверждающим.

Базовые типы:

  • link - запрос на установление соединения, length = 0.

  • tear - запрос на разрыв соединения, length = 0.

  • ping - проверка соединения, length = 0.

  • move - блок с фрагментом потока данных, первые 8 байт содержат смещение (идентификатор) фрагмента потока, остальное данные.

  • edge - уведомление о максимальной границе потока, которую сторона готова принять, length = 8. Изменяется по мере чтения данных из буфера входящего потока. Это защита медленных читателей от нетерпеливых писателей данных.

Эхо типы:

  • link | echo - подтверждение установления соединения, length = 0.

  • tear | echo - подтверждение разрыва соединения, length = 0.

  • ping | echo - подтверждение проверки соединения, length = 0.

  • move | echo - подтверждение получения фрагмента потока, содержит его идентификатор, length = 8.

  • edge | echo - подтверждение получения ограничения для исходящего потока, содержит полученное значение, length = 8.

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

Обфускация

Протокол использует 64-битный pre-shared ключ для обфускации. Замечу, что это именно обфускация, а не шифрование. Шифрование данных забота протоколов следующих уровней. Длина ключа и алгоритм обфускации не являются криптостойкими и не рассчитаны на защиту данных, но достаточны, чтобы сделать проблематичной идентификацию протокола в рантайме.

Алгоритм обфускации довольно прост и заключается в последовательной маскировке 64-битных фрагментов пакета с помощью ключа такой же размерности и операции xor. Однако для достижения хорошего результата недостаточно одного только pre-shared ключа и xor, так как заголовки протокола вполне предсказуемы и взломать такую обфускацию будет не трудно. Для достижения качественной маскировки используется дополнительный случайный для каждого пакета параметр, который записывается в поле salt и на каждой следующей порции данных маскирующий ключ вычисляется заново в функции make_inverter, что делает различными значения одинаковых 64-битных полей после маскировки.

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

Функция make_inverter делает ряд операций изменяющих значение ключа. Выведена чисто эмпирически.

uint64_t make_inverter(uint64_t secret, uint64_t salt)
{
    uint64_t base = secret + salt;
    uint64_t shift = (base & 0x3F) | 0x01;
    return ((base >> shift) | (base << (64 - shift))) ^ salt;
}

Реализация

Реализация протокола написана с помощью boost::asio. Базовый интерфейс канала tubus описан в файле channel.h и предоставляет интуитивно понятный набор типовых операций.

namespace tubus {

struct channel
{
    virtual ~channel() noexcept(true) {}
    virtual void close() noexcept(true) = 0;
    virtual void open(const endpoint& local) noexcept(false) = 0;
    virtual void connect(const endpoint& remote, const callback& handle) noexcept(true) = 0;
    virtual void accept(const endpoint& remote, const callback& handle) noexcept(true) = 0;
    virtual void read(const mutable_buffer& buffer, const io_callback& handle) noexcept(true) = 0;
    virtual void write(const const_buffer& buffer, const io_callback& handle) noexcept(true) = 0;
    virtual void shutdown(const callback& handle) noexcept(true) = 0;
    virtual size_t writable() const noexcept(true) = 0;
    virtual size_t readable() const noexcept(true) = 0;
    virtual endpoint host() const noexcept(false) = 0;
    virtual endpoint peer() const noexcept(false) = 0;
};

channel_ptr create_channel(boost::asio::io_context& io, uint64_t /*pre-shared key*/ secret = 0) noexcept(true);
}
  • open - открыть канал на заданном локальном эндпоинте

  • close - закрыть канал без уведомления удаленной стороны, все невыполненные асинхронные операции будут прерваны

  • shutdown - закрыть канал с уведомлением удаленной стороны, все невыполненные асинхронные операции будут прерваны

  • accept - инициировать асинхронное ожидание подключения от указанного пира

  • connect - инициировать асинхронное подключение к указанному пиру

  • read - добавить в очередь асинхронную операцию чтения, обратный вызов произойдет когда переданный буфер будет заполнен или произойдет ошибка

  • write - добавить в очередь асинхронную операцию записи, обратный вызов произойдет когда переданный буфер будет отправлен или произойдет ошибка

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

  • readable - количество байт доступных для чтения без ожидания

  • host - локальный эндпоинт канала

  • peer - удаленный эндпоинт канала

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

Примитивы tubus::socket и tubus::acceptor описаны в файле socket.h и acceptor.h и реализуют все типичные для asio сокетов операции с некоторыми вариациями для методов семейств open/connect/accept. Ниже в примерах вы это увидите. Отмечу, что tubus::socket реализует концепции AsyncReadStream, AsyncWriteStream, Stream, SyncReadStream и SyncWriteStream, поэтому его можно использовать в качестве нижнего уровня в потоках boost::asio::ssl::stream.

Примеры

Потребитель данных с использованием tubus::channel.

#include "channel.h"
...
auto consumer = tubus::create_channel(io_service, key);
consumer->open(local_endpoint);
consumer->connect(remote_endpoint, [&](const boost::system::error_code& error)
{
    ...
    tubus::mutable_buffer buffer(consumer->readable()); 
    
    // если буфер пустой, обратный вызов произойдет когда все предыдущие операции
    // чтения завершаться и в буфере чтения будут или появятся дополнительные данные
    
    consumer->read(buffer, [&](const boost::system::error_code& error, size_t size)
    {
        ...
        consumer->shutdown();
    };
});

Поставщик данных с использованием tubus::socket.

#include "socket.h"
...
tubus::socket producer(io_service, key);
producer.open(local_endpoint);
producer.async_accept(remote_endpoint, [&](const boost::system::error_code& error)
{
    ...
    tubus::const_buffer buffer("Hello, tubus!");
    producer.async_write_some(buffer, [&](const boost::system::error_code& error, size_t size)
    {
        ...
        producer.shutdown();
    };
});

Сервер с использованием tubus::acceptor. К сожалению, пока не поддерживается в Windows, которая не позволяет диспетчеризовать udp-пакеты разных удаленных эндпоинтов в отдельные сокеты. В перспективе можно реализовать диспетчеризацию программно, но пока не вижу в этом потребности.

#include "acceptor.h"
...
tubus::acceptor server(io_service, key);
server.open(local_endpoint);

tubus::socket peer1(io_service);
server.accept(peer1);

peer1.read_some(...);
peer1.write_some(...);

tubus::socket peer2(io_service);
server.accept(peer2);

peer2.read_some(...);
peer2.write_some(...);

peer1.shutdown();
peer2.shutdown();

server.close();

Шифрованный поток с использованием boost::asio::ssl::stream и tubus::socket.

#include <boost/asio/ssl.hpp>
#include "socket.h"
...
boost::asio::ssl::stream<tubus::socket> client(tubus::socket(io_service, key), ssl_ctx);

client.lowest_layer().open(local_endpoint);
client.lowest_layer().connect(remote_endpoint);
client.handshake(boost::asio::ssl::stream_base::client);

boost::asio::read(client, ...);
boost::asio::write(client, ...);

client.shutdown();

Полезные переменные окружения:

  • TUBUS_MAX_PACKET_SIZE - максимальный размер пакета, должен быть одинаковым для обеих сторон, по умолчанию 1432 байта

  • TUBUS_PING_TIMEOUT - таймаут отправки пингов для проверки соединения, по умолчанию 30 секунд

  • TUBUS_RESEND_TIMEOUT - таймаут повторной отправки данных в случае отсутствия подтверждения, по умолчанию 100 миллисекунд

  • TUBUS_SHUTDOWN_TIMEOUT - максимальное время ожидания подтверждения закрытия сессии, по умолчанию 2000 миллисекунд

  • TUBUS_CONNECT_TIMEOUT - максимальное время для установления соединения, по умолчанию 30 секунд

  • TUBUS_ACCEPT_TIMEOUT - максимальное время ожидания запроса на установление соединения, по умолчанию 30 секунд

  • TUBUS_SNIPPET_FLIGHT - максимальное количество одновременно отправленных фрагментов потока, по умолчанию 48

  • TUBUS_MOVE_ATTEMPTS - максимальное количество попыток отправить фрагмент потока, по умолчанию 32

  • TUBUS_RECEIVE_BUFFER_SIZE - размер буфера чтения, превышение вызовет аварийное закрытие канала, возможно в случае, если удаленная сторона будет игнорировать edge-уведомления, по умолчанию 5242880 байт

  • TUBUS_SEND_BUFFER_SIZE - размер буфера записи, превышение вызовет аварийное закрытие канала, по умолчанию 5242880 байт

Инструкции по сборке и инсталляции вы можете найти в репозиториях

tubus - https://github.com/novemus/tubus

plexus - https://github.com/novemus/plexus

wormhole - https://github.com/novemus/wormhole

Спасибо за внимание.

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


  1. ivankudryavtsev
    06.08.2023 11:20

    Наверное, какие-то экзотические задачи решаете? Вроде как TCP непринужденно NAT преодолевает уже много десятков(?) лет. Разъясните суть проблемы подробнее?

    Выглядит, что Вы решаете проблему установления соединения двух клиентов за NAT между собой? Это не совсем «преодоление NAT», а решение конкретной проблемы.


    1. novemix Автор
      06.08.2023 11:20
      +1

      Задачи вы можете увидеть в предыдущих постах, если перейдете по ссылкам и почитаете.

      Под преодолением NAT подразумевается возможность p2p соединения приложений расположенных за NAT, без релейного сервера.


  1. eri
    06.08.2023 11:20

    Почему потери и порядок пакетов важны для TCP. Там же есть ретрансмисия и порядковые номера. просто запихнуть TCP пакет вместе головой в UDP сокет и всего делов...


    1. novemix Автор
      06.08.2023 11:20

      Все не так просто. Это не vpn, где вопрос решается на уровне драйвера виртуального интерфейса. Это программный туннель udp для ретрансляции tcp и вам нужно на стороне клиента сделать прокси tcp-сервера, а на стороне сервера прокси tcp-клиента и отдельный udp канал между машинами для трансляции данных. Чем-то напоминает MiTM. Разумеется, если прокси получил данные его пир об этом знает. Но дальше данные могут потеряться или смешаться в udp канале. На первый взгляд кажется, а почему не использовать vpn через udp. Это тоже выход, но у него есть и недостатки.

      1. необходимость повышенных привилений

      2. необходимость настройки фаервола

      3. нелюбовь регулирующих органов разных стран к vpn

      4. vpn не очень удобен для p2p

      В этом посте подробнее изложено.


      1. eri
        06.08.2023 11:20
        +1

        Я не говорю о впн. Там все понятно. Я делал tun/tap поверх rtsp . идея другая. Открыть tcp сокет режиме raw и откусить его вместе с головой tcp. Таким Макаром переложить контроль скорости соединения и последовательность данных на операционную систему


        1. novemix Автор
          06.08.2023 11:20

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


        1. novemix Автор
          06.08.2023 11:20

          А так да. Идея решить вопрос на уровне драйвера заманчивая.


    1. apevzner
      06.08.2023 11:20

      Для этого нужна или user-space реализация TCP, или возня с tun-ом и/или сырыми сокетами, а и то и другое требует рутовых прав (а в венде еще и tun-а нет...)


      1. eri
        06.08.2023 11:20

        Tun в Винду ставится, но можно обойтись и ndis драйвером. На Винде есть ещё фильтры уровня winsock. Рутовых прав не нужно , нужен cap_net_raw для сырого сокета или группа для tun.

        Юзерспейс реализация TCP не нужна, TCP вообще никак не разбирается в моем случае. Телепортируется как есть на тот конец и другая ос разбирает его на том конце как вошедший в интерфейс. Разве что подстроить MSS.

        Нужно как-то воткнуть UDP заголовок между IP и TCP записями.


  1. AetherNetIO
    06.08.2023 11:20

    Ожидала увидеть congestion control, гарантию доставки и т.д. в духе библиотек UDT или uTP


    1. novemix Автор
      06.08.2023 11:20

      Может плохо код смотрели?


      1. AetherNetIO
        06.08.2023 11:20
        -1

        Хватило бы пары слов в описании. Код не смотрела, по ссылкам не ходила; каюсь.


        1. novemix Автор
          06.08.2023 11:20

          Ну, пара слов была все-таки. Просто здесь не предполагался подробный разбор реализации.

          Фрагмент потока (блок move) отправляется до тех пор пока не будет получено подтверждение (блок move|echo), интервал повтора по умолчанию 100мс. Принимающая сторона собирает фрагменты и по мере выстраивания непрерывных интервалов и появления в очереди запросов на чтение копирует данные в клиентские буферы.

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


          1. apevzner
            06.08.2023 11:20

            100 ms без автоподстройки под RTT - это очень круто. RTT между Москвой и Сан-Франциско где-то 200 миллисекунд. Если таймаут на ретрансмит равен 100 ms, большая часть пакетов будет посылаться дважды.

            Борьба с заторами осуществляется обменом специальными сообщениями (блок edge), которое содержит допустимую границу потока

            Ну т.е., окно, как в TCP, управляемое приемником?

            На мой взгляд, то, что в TCP смешан congestion и flow control - это ошибка в дизайне протокола. Простительная, если учесть, что протоколу ~50 лет, и не забывать, что процессоры, для которых разрабатывался TCP по мощности и по памяти, ну, наверное, сейчас такие в клавиатуру и мышь ставят в качестве контроллера.

            Но совершенно не обязательно повторять эту ошибку в новых протоколах...


            1. novemix Автор
              06.08.2023 11:20

              Ретрансмит не захаркожен, можно менять, но авто подстройка конечно правильная идея.

              А в чем недостаток окна? И какие альтернативы более эффективны?


              1. apevzner
                06.08.2023 11:20
                +1

                В том, что в терминах окна хорошо выражается flow control - подстройка скорости передачи под скорость потребления получателем, но плохо выражается congestion control - подстройка скорости передачи под пропускную способность пути.

                В TCP это худо-бедно работает за счет того, что TCP - это ACK-paced протокол. Т.е., именно получение ACK-ов является тем естественным таймером, к которому привязана отправка сегментов при устоявшемся потоке. Заметим, окно на это дело вообще особо не влияет, влияет именно периодичность поступления ACK-ов. Окно тут важно, чтобы (1) канал не простаивал в ожидании ACK-ов при большом RTT (2) не лить данные струей вслепую, если ACK задерживается, чтобы не переполнять очереди роутеров в пути

                Цена, которую мы за это платим, заключается в том, что на каждые два отправленных пакета данных в среднем приходится один ACK (желающие могут проверить wireshark-ом).

                Я тоже в свое время потратил некоторое количество усилий и на пробивание дырок в NAT-е и на построение UDP-based протокола. К сожалению, я не довел это дело до ума, и оно не опубликовано в каком-либо виде

                В моем протоколе я разделил три несвязанные вещи, которые в TCP перемешаны: (1) flow control (2) congestion control (3) ретрансмиты

                Flow control у меня был реализован по-TCP-ному, с помощью окна. Congestion control вычислял congestion window по-TCP-ному, но применял его хитро: если, как известно, window = bandwidth*delay, то bandwidth = window/delay. И вот с этой вычисленной скоростью пакеты равномерно передавались. В терминологии TCP это называется paced TCP, по этому сочетанию слов можно найти соответствующие исследовательские работы. И регулировалась скорость передачи потока, а что в этом потоке, новые сегменты или ретрансмиты, по сути для целей congestion control не важно. При этом можно себе позволить редкие ACK-и, ACK не работает таймером. Кроме того, у меня в протоколе был заложен не только потоковый режим, в стиле TCP, но и message-oriented режим, в стиле SCTP, и для message-oriented режима можно было отказаться от гарантий доставки и поддерживались приоритеты сообщений. Причем приоритетная очередь была организована по сегментам, а не по сообщениям, т.е. очень длинное низкоприоритеное сообщение не занимало канал на все время передачи; более высокоприоритетные сообщения могли его "подвинуть" и уйти раньше


                1. novemix Автор
                  06.08.2023 11:20

                  Спасибо за развернутый ответ. Если имели опыт борьбы с натами и есть интересные идеи в плане p2p, приглашаю поучаствовать в проекте. На счет эффективности протокола, то тут иллюзий нет. Не думаю, что на прикладном уровне можно и нужно всерьез конкурировать с tcp. Это скорее вынужденное решение, ну и по мере внедрения IPv6 будет все менее актуально.


                  1. AetherNetIO
                    06.08.2023 11:20

                    UDT имеет значительные улучшения в congestion control. Особенно при большом % потерь пакетов.


                    1. novemix Автор
                      06.08.2023 11:20

                      Мои эксперименты показали, что куда большая проблема производительность самой реализации, чем алгоритм обмена. В сравнительных тестах с tcp и чистым udp на loopback я упирался именно в производительность. А вот сравнения туннеля через мой wormhole и openvpn поверх udp в реальной сети оказались немного в пользу первого. Это конечно не исчерпывающие тесты, только в доступных мне частях сети.


      1. apevzner
        06.08.2023 11:20

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

        Я в курсе проблематики с пробиванием дырок в NAT, и понимаю, зачем нужен user-space протокол, реализованный над UDP


        1. novemix Автор
          06.08.2023 11:20

          Вкратце я все-таки упоминал эти вопросы, просто не выделял их. Рассчитывал сделать обзор для тех кто хочет пользоваться. На счет тех же кому интересно глубже. Я не против разъяснить и обсудить, но наверное без погружения в код это будет слишком абстрактно.


    1. apevzner
      06.08.2023 11:20

      Я полистал код.

      Retransmit вроде есть, а congestion/flow control что-то не видать... (flow control может и есть, если ACK-и шлются только на уже вычитаные получателем пакеты).


      1. novemix Автор
        06.08.2023 11:20

        congestion/flow управляется обменом сообщениями edge, которое содержит допустимую границу потока, который готова принять сторона. При достижении отправитель должен приостановиться пока не получит новую границу.