Yet another UDP transport

6001fc4b0d958d1ba6f7664df43aa1c0.jpg

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

В этом посте представлен обзор написанной мной кроссплатформенной 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-пакета, магическое число 0×0909.

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

  • 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 
#include "socket.h"
...
boost::asio::ssl::stream 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

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

© Habrahabr.ru