Как создать сетевой протокол

как создать сетевой протокол

Вы в жизни не раз сталкивался с разными протоколами — одни использовал, другие, возможно, реверсил. Одни были легко читаемы, в других без hex-редактора не разобраться. В сегодняшней статье я покажу, как создать свой собственный сетевой протокол передачи данных, который будет работать поверх TCP/IP. Мы разработаем свою структуру данных и реализуем сервер на C#.

Итак, протокол передачи данных — это соглашение между приложениями о том, как должны выглядеть передаваемые данные. Например, сервер и клиент могут использовать WebSocket в связке с JSON. Вот так приложение на Android могло бы запросить погоду с сервера:

И сервер мог бы ответить:

Пропарсив ответ по известной модели, приложение предоставит информацию пользователю. Выполнить парсинг такого пакета можно, только располагая информацией о его строении. Если ее нет, протокол придется реверсить.

Создание базовой структуры протокола

Этот протокол будет базовым для простоты. Но мы будем вести его разработку с расчетом на то, что впоследствии его расширим и усложним.

Первое, что необходимо ввести, — это наш собственный заголовок, чтобы приложения могли отличать пакеты нашего сетевого протокола. У нас это будет набор байтов 0xAF, 0xAA, 0xAF. Именно они и будут стоять в начале каждого сообщения.

Почти каждый бинарный протокол имеет свое «магическое число» (также «заголовок» и «сигнатура») — набор байтов в начале пакета. Оно используется для идентификации пакетов своего протокола. Остальные пакеты будут игнорироваться.

Каждый пакет будет иметь тип и подтип и будет размером в байт. Так мы сможем создать 65 025 (255 * 255) разных типов пакетов. Пакет будет содержать в себе поля, каждое со своим уникальным номером, тоже размером в один байт. Это предоставит возможность иметь 255 полей в одном пакете. Чтобы удостовериться в том, что пакет дошел до приложения полностью (и для удобства парсинга), добавим байты, которые будут сигнализировать о конце пакета.

Завершенная структура пакета:

Назовем наш протокол передачи данных, как вы могли заметить, XProtocol. На третьем сдвиге начинается информация о типе пакета. На пятом начинается массив из полей. Завершающим звеном будут байты 0xFF и 0x00, закрывающие пакет.

Написание клиента и сервера

Для начала нужно ввести основные свойства, которые будет иметь пакет:

  1. тип пакета;
  2. подтип;
  3. набор полей.

Добавим класс для описания поля пакета, в котором будут его данные, ID и размер.

Сделаем обычный конструктор приватным и создадим статический метод для получения нового экземпляра объекта.

Теперь можно задать тип пакета и поля, которые будут внутри него. Создадим функцию для этого. Записывать будем в поток MemoryStream. Первым делом запишем байты заголовка, типа и подтипа пакета, а потом отсортируем поля по возрастанию FieldID.

Теперь запишем все поля. Сначала пойдет ID поля, его размер и данные. И только потом конец пакета — 0xFF, 0x00.

Теперь пора научиться парсить пакеты.

Минимальный размер пакета — 7 байт.
HEADER (3) + TYPE (1) + SUBTYPE (1) + PACKET ENDING (2)

Проверяем размер входного пакета, его заголовок и два последних байта. После валидации пакета получим его тип и подтип.

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

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

Запись и считывание данных из пакетов

Из-за строения класса XPacket нужно хранить бинарные данные для полей. Чтобы установить значение поля, нам необходимо конвертировать имеющиеся данные в массив байтов. Язык C# не предоставляет идеальных способов сделать это, поэтому внутри пакетов будут передаваться только базовые типы: int, double, float и так далее. Так как они имеют фиксированный размер, можно считать его напрямую из памяти.

РЕКОМЕНДУЕМ:
Программирование в консоли

Чтобы получить чистые байты объекта из памяти, иногда используется метод небезопасного кода и указателей, но есть и способы проще: благодаря классу Marshal в C# можно взаимодействовать с unmanaged-областями нашего приложения. Чтобы перевести любой объект фиксированной длины в байты, мы будем пользоваться такой функцией:

Здесь мы делаем следующее:

  • получаем размер нашего объекта;
  • создаем массив, в который будет записана вся информация;
  • получаем дескриптор на наш массив и записываем в него объект.

Теперь сделаем то же самое, только наоборот.

Только что вы научились превращать объекты в массив байтов и обратно. Сейчас можно добавить функции для установки и получения значений полей. Давайте сделаем функцию для простого поиска поля по его ID.

Добавим функцию для проверки существования поля.

Получаем значение из поля.

Добавив несколько проверок и используя уже известную нам функцию, превратим набор байтов из поля в нужный нам объект типа T.

Установка значения

Мы можем принять только объекты Value-Type. Они имеют фиксированный размер, поэтому мы можем их записать.

Проверка на работоспособность

Проверим создание пакета, его перевод в бинарный вид и парсинг назад.

Судя по всему, все работает прекрасно. В консоли должен появиться результат.

Ввод типов пакетов

Запомнить ID всех пакетов, которые будут созданы, сложно. Отлаживать пакет с типом N и подтипом Ns не легче, если не держать все ID в голове. В этом разделе мы дадим нашим пакетам имена и привяжем эти имена к ID пакета. Для начала создадим перечисление, которое будет содержать имена пакетов.

Unknown будет использоваться для типа, который нам неизвестен. Handshake — для пакета рукопожатия.

Теперь, когда нам известны типы пакетов, пора привязать их к ID. Необходимо создать менеджер, который будет этим заниматься.

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

РЕКОМЕНДУЕМ:
Обработка сложных форм на Python с помощью WTForms

Dictionary<TKey, TValue> хорошо подходит для этой задачи. Поместим тип ( XPacketType) как ключ, Tuple<T1, T2> будет хранить в себе значение типа и подтипа пакета: T1 — тип, T2 — подтип.

Создаем функцию для регистрации типов пакета.

Имплементируем получение информации по типу:

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

Создание структуры пакетов для их сериализации и десериализации

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

Используя AttributeUsage, мы установили, что наш атрибут можно будет установить только на поля классов. FieldID будет использоваться для хранения ID поля внутри пакета.

Создание сериализатора

Для сериализации и десериализации в C# используется Reflection. Этот набор классов позволит узнать всю нужную информацию и установить значение полей во время рантайма.

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

Так как необходимые поля помечены атрибутом XFieldAttribute, найти их внутри класса не составит труда. Сначала получим все нестатичные, приватные и публичные поля при помощи GetFields(). Выбираем все поля, у которых есть наш атрибут. Собираем новый IEnumerable, который содержит Tuple<FieldInfo, byte>, где byte — ID нашего поля в пакете.

Здесь мы вызываем GetCustomAttribute<>() два раза. Это не обязательно, но таким образом код будет выглядеть аккуратнее.

Итак, теперь вы умеете получать все FieldInfo для типа, который будете сериализовать. Пришло время создать сам сериализатор: у него будут обычный и строгий режимы работы. Во время обычного режима будет игнорироваться тот факт, что разные поля используют один и тот же ID поля внутри пакета.

Внутри foreach происходит самое интересное: fields содержит все нужные поля в виде Tuple<FieldInfo, byte>. Item1 — искомое поле, Item2 — ID этого поля внутри пакета. Перебираем их все, следом устанавливаем значения полей при помощи SetPacket(byte, object). Теперь пакет сериализован.

Создание десериализатора

Создавать десериализатор в разы проще. Нужно использовать функцию GetFields(), которую мы имплементировали в прошлом разделе.

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

Создание десериализатора завершено. Теперь можно проверить работоспособность кода. Для начала создадим простой класс.

Напишем простой тест.

После запуска программы должны отобразиться две строки:

А теперь перейдем к тому, для чего все это создавалось.

Первое рукопожатие

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

Примеры работы с сокетами вы найдете в официальной документации в главе Socket Code Examples.

Мы создали простой пакет для обмена рукопожатиями.

Рукопожатие будет инициировать клиент. Он отправляет пакет рукопожатия с рандомным числом, а сервер в свою очередь должен ответить числом, на 15 меньше полученного.

Отправляем пакет на сервер.

При получении пакета от сервера обрабатываем handshake отдельной функцией.

Десериализуем, проверяем ответ от сервера.

На стороне сервера есть свой идентичный ProcessIncomingPacket. Разберем процесс обработки пакета на стороне сервера. Десериализуем пакет рукопожатия от клиента, отнимаем пятнадцать, сериализуем и отправляем обратно.

Собираем и проверяем.

создание сетевого протокола
Тестирование рукопожатия

Все работает!

Имплементация простой защиты протокола

Наш протокол будет иметь два типа пакетов — обычный и защищенный. У обычного наш стандартный заголовок, а у защищенного вот такой: [0x95, 0xAA, 0xFF].

Чтобы отличать зашифрованные пакеты от обычных, потребуется добавить свойство внутрь класса XPacket.

После этого модифицируем функцию XPacket.Parse(byte[]), чтобы она принимала и расшифровывала новые пакеты. Вначале модифицируем функцию проверки заголовка:

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

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

Добавляем функциональность в цикл парсинга полей.

Так как мы принимаем только структуры как типы данных, мы не сможем записать byte[] внутрь поля. Поэтому немного модифицируем код, добавив новую функцию, которая будет принимать массив данных.

Сделаем такую же, но уже для получения данных из поля.

Теперь все готово для создания функции расшифровки пакета. Шифрование будет использовать класс RijndaelManaged со строкой в качестве пароля для шифрования. Строка с паролем будет константна. Это шифрование поможет защититься от атаки типа MITM.

Создадим класс, который будет шифровать и расшифровывать данные.

Так как процесс шифрования выглядит идентично, возьмем готовое решение для шифрования строки с Stack Overflow и адаптируем его для себя.

Модифицируем методы, чтобы они принимали и возвращали массивы байтов.

И простой хендлер, который будет хранить секретный ключ.

Затем создаем функцию для расшифровки. Данные обязательно должны быть в поле с ID = 0. Как иначе нам его искать?

Получаем данные, расшифровываем и парсим заново. То же самое проделываем с обратной процедурой.

РЕКОМЕНДУЕМ:
Как сделать свою структуру данных в Python

Вводим свойство, чтобы пометить надобность в заголовке зашифрованного пакета.

Создаем простой пакет и помечаем, что в нем зашифрованные данные.

И добавляем две функции для более удобного обращения.

Модифицируем ToPacket(), чтобы тот слушался значения ChangeHeaders.

Проверяем:

В консоли получаем число 12345.

Заключение

Только что мы создали свой собственный протокол. Это был долгий путь от базовой структуры на бумаге до его полной имплементации в коде. Надеюсь, вам было интересно!

Исходный код проекта можно найти в GitHub.

Понравилась статья? Поделиться с друзьями:
Добавить комментарий