TCP-протокол и сервер с нуля на C#
Ты в жизни не раз сталкивался с разными протоколами — одни использовал, другие, возможно, реверсил. Одни были легко читаемы, в других без hex-редактора не разобраться. В этой статье я покажу, как создать свой собственный протокол, который будет работать поверх TCP/IP. Мы разработаем свою структуру данных и реализуем сервер на C#.
Итак, протокол передачи данных — это соглашение между приложениями о том, как должны выглядеть передаваемые данные. Например, сервер и клиент могут использовать WebSocket в связке с JSON. Вот так приложение на Android могло бы запросить погоду с сервера:
{ "request": "getWeather", "city": "cityname" }
И сервер мог бы ответить:
{ "success": true, "weatherHumanReadable": "Warm", "degrees": 18 }
Пропарсив ответ по известной модели, приложение предоставит информацию пользователю. Выполнить парсинг такого пакета можно, только располагая информацией о его строении. Если ее нет, протокол придется реверсить.
Создаем базовую структуру протокола
Этот протокол будет базовым для простоты. Но мы будем вести его разработку с расчетом на то, что впоследствии его расширим и усложним.
Первое, что необходимо ввести, — это наш собственный заголовок, чтобы приложения могли отличать пакеты нашего протокола. У нас это будет набор байтов 0xAF
, 0xAA
, 0xAF
. Именно они и будут стоять в начале каждого сообщения.
INFO
Почти каждый бинарный протокол имеет свое «магическое число» (также «заголовок» и «сигнатура») — набор байтов в начале пакета. Оно используется для идентификации пакетов своего протокола. Остальные пакеты будут игнорироваться.
Каждый пакет будет иметь тип и подтип и будет размером в байт. Так мы сможем создать 65 025 (255 * 255) разных типов пакетов. Пакет будет содержать в себе поля, каждое со своим уникальным номером, тоже размером в один байт. Это предоставит возможность иметь 255 полей в одном пакете. Чтобы удостовериться в том, что пакет дошел до приложения полностью (и для удобства парсинга), добавим байты, которые будут сигнализировать о конце пакета.
Завершенная структура пакета:
XPROTOCOL PACKET STRUCTURE
(offset: 0) HEADER (3 bytes) [ 0xAF, 0xAA, 0xAF ]
(offset: 3) PACKET ID
(offset: 3) PACKET TYPE (1 byte)
(offset: 4) PACKET SUBTYPE (1 byte)
(offset: 5) FIELDS (FIELD[])
(offset: END) PACKET ENDING (2 bytes) [ 0xFF, 0x00 ]
FIELD STRUCTURE
(offset: 0) FIELD ID (1 byte)
(offset: 1) FIELD SIZE (1 byte)
(offset: 2) FIELD CONTENTS
Назовем наш протокол, как ты мог заметить, XProtocol. На третьем сдвиге начинается информация о типе пакета. На пятом начинается массив из полей. Завершающим звеном будут байты 0xFF
и 0x00
, закрывающие пакет.
Пишем клиент и сервер
Для начала нужно ввести основные свойства, которые будет иметь пакет:
- тип пакета;
- подтип;
- набор полей.
public class XPacket { public byte PacketType { get; private set; } public byte PacketSubtype { get; private set; } public List<XPacketField> Fields { get; set; } = new List<XPacketField>(); }
Добавим класс для описания поля пакета, в котором будут его данные, ID и размер.
public class XPacketField { public byte FieldID { get; set; } public byte FieldSize { get; set; } public byte[] Contents { get; set; } }
Сделаем обычный конструктор приватным и создадим статический метод для получения нового экземпляра объекта.
private XPacket() {} public static XPacket Create(byte type, byte subtype) { return new XPacket { PacketType = type, PacketSubtype = subtype }; }
Теперь можно задать тип пакета и поля, которые будут внутри него. Создадим функцию для этого. Записывать будем в поток MemoryStream
. Первым делом запишем байты заголовка, типа и подтипа пакета, а потом отсортируем поля по возрастанию FieldID
.
public byte[] ToPacket() { var packet = new MemoryStream(); packet.Write( new byte[] {0xAF, 0xAA, 0xAF, PacketType, PacketSubtype}, 0, 5); var fields = Fields.OrderBy(field => field.FieldID); foreach (var field in fields) { packet.Write(new[] {field.FieldID, field.FieldSize}, 0, 2); packet.Write(field.Contents, 0, field.Contents.Length); } packet.Write(new byte[] {0xFF, 0x00}, 0, 2); return packet.ToArray(); }
Теперь запишем все поля. Сначала пойдет ID поля, его размер и данные. И только потом конец пакета — 0xFF
, 0x00
.
Теперь пора научиться парсить пакеты.
INFO
Минимальный размер пакета — 7 байт: HEADER
(3) + TYPE
(1) + SUBTYPE
(1) + PACKET ENDING
(2)
Проверяем размер входного пакета, его заголовок и два последних байта. После валидации пакета получим его тип и подтип.
public static XPacket Parse(byte[] packet) { if (packet.Length < 7) { return null; } if (packet[0] != 0xAF || packet[1] != 0xAA || packet[2] != 0xAF) { return null; } var mIndex = packet.Length - 1; if (packet[mIndex - 1] != 0xFF || packet[mIndex] != 0x00) { return null; } var type = packet[3]; var subtype = packet[4]; var xpacket = Create(type, subtype); /* <---> */
Пора перейти к парсингу полей. Так как наш пакет заканчивается двумя байтами, мы можем узнать, когда закончились данные для парсинга. Получим ID поля и его размер, добавим к списку. Если пакет будет поврежден и будет существовать поле с ID
, равным нулю, и SIZE
, равным нулю, то необходимости его парсить нет.
/* <---> */ var fields = packet.Skip(5).ToArray(); while (true) { if (fields.Length == 2) { return xpacket; } var id = fields[0]; var size = fields[1]; var contents = size != 0 ? fields.Skip(2).Take(size).ToArray() : null; xpacket.Fields.Add(new XPacketField { FieldID = id, FieldSize = size, Contents = contents }); fields = fields.Skip(2 + size).ToArray(); } }
У кода выше есть проблема: если подменить размер одного из полей, парсинг завершится с необработанным исключением или пропарсит пакет неверно. Необходимо обеспечить безопасность пакетов. Но об этом речь пойдет чуть позже.
Учимся записывать и считывать данные
Из-за строения класса XPacket
необходимо хранить бинарные данные для полей. Чтобы установить значение поля, нам потребуется конвертировать имеющиеся данные в массив байтов. Язык C# не предоставляет идеальных способов сделать это, поэтому внутри пакетов будут передаваться только базовые типы: int
, double
, float
и так далее. Так как они имеют фиксированный размер, можно считать его напрямую из памяти.
Чтобы получить чистые байты объекта из памяти, иногда используется метод небезопасного кода и указателей, но есть и способы проще: благодаря классу Marshal
в C# можно взаимодействовать с unmanaged
-областями нашего приложения. Чтобы перевести любой объект фиксированной длины в байты, мы будем пользоваться такой функцией:
public byte[] FixedObjectToByteArray(object value) { var rawsize = Marshal.SizeOf(value); var rawdata = new byte[rawsize]; var handle = GCHandle.Alloc(rawdata, GCHandleType.Pinned); Marshal.StructureToPtr(value, handle.AddrOfPinnedObject(), false); handle.Free(); return rawdata; }
Здесь мы делаем следующее:
- получаем размер нашего объекта;
- создаем массив, в который будет записана вся информация;
- получаем дескриптор на наш массив и записываем в него объект.
Теперь сделаем то же самое, только наоборот.
private T ByteArrayToFixedObject<T>(byte[] bytes) where T: struct { T structure; var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); try { structure = (T) Marshal.PtrToStructure(handle.AddrOfPinnedObject(), typeof(T)); } finally { handle.Free(); } return structure; }
Только что ты научился превращать объекты в массив байтов и обратно. Сейчас можно добавить функции для установки и получения значений полей. Давай создадим функцию для простого поиска поля по его ID.
public XPacketField GetField(byte id) { foreach (var field in Fields) { if (field.FieldID == id) { return field; } } return null; }
Добавим функцию для проверки существования поля.
public bool HasField(byte id) { return GetField(id) != null; }
Получаем значение из поля.
public T GetValue<T>(byte id) where T : struct { var field = GetField(id); if (field == null) { throw new Exception(quot;Field with ID {id} wasn't found."); } var neededSize = Marshal.SizeOf(typeof(T)); if (field.FieldSize != neededSize) { throw new Exception(quot;Can't convert field to type {typeof(T).FullName}.\n" + quot;We have {field.FieldSize} bytes but we need exactly {neededSize}."); } return ByteArrayToFixedObject<T>(field.Contents); }
Добавив несколько проверок и используя уже известную нам функцию, превратим набор байтов из поля в нужный нам объект типа T
.
Установка значения
Мы можем принять только объекты Value-Type
. Они имеют фиксированный размер, поэтому мы можем их записать.
public void SetValue(byte id, object structure) { if (!structure.GetType().IsValueType) { throw new Exception("Only value types are available."); } var field = GetField(id); if (field == null) { field = new XPacketField { FieldID = id }; Fields.Add(field); } var bytes = FixedObjectToByteArray(structure); if (bytes.Length > byte.MaxValue) { throw new Exception("Object is too big. Max length is 255 bytes."); } field.FieldSize = (byte) bytes.Length; field.Contents = bytes; }
Проверка на работоспособность
Проверим создание пакета, его перевод в бинарный вид и парсинг назад.
var packet = XPacket.Create(1, 0); packet.SetValue(0, 123); packet.SetValue(1, 123D); packet.SetValue(2, 123F); packet.SetValue(3, false); var packetBytes = packet.ToPacket(); var parsedPacket = XPacket.Parse(packetBytes); Console.WriteLine(quot;int: {parsedPacket.GetValue<int>(0)}\n" + quot;double: {parsedPacket.GetValue<double>(1)}\n" + quot;float: {parsedPacket.GetValue<float>(2)}\n" + quot;bool: {parsedPacket.GetValue<bool>(3)}");
Судя по всему, все работает прекрасно. В консоли должен появиться выхлоп.
int: 123
double: 123
float: 123
bool: False
Вводим типы пакетов
Запомнить ID всех пакетов, которые будут созданы, сложно. Отлаживать пакет с типом N
и подтипом Ns
не легче, если не держать все ID в голове. В этом разделе мы дадим нашим пакетам имена и привяжем эти имена к ID пакета. Для начала создадим перечисление, которое будет содержать имена пакетов.
public enum XPacketType { Unknown, Handshake }
Unknown
будет использоваться для типа, который нам неизвестен. Handshake
— для пакета рукопожатия.
Теперь, когда нам известны типы пакетов, пора привязать их к ID. Необходимо создать менеджер, который будет этим заниматься.
public static class XPacketTypeManager { private static readonly Dictionary<XPacketType, Tuple<byte, byte>> TypeDictionary = new Dictionary<XPacketType, Tuple<byte, byte>>(); /* < ... > */ }
Статический класс хорошо подойдет для этой функции. Его конструктор вызывается лишь один раз, что позволит нам зарегистрировать все известные типы пакетов. Невозможность вызвать статический конструктор извне поможет не проходить повторную регистрацию типов.
Dictionary<TKey, TValue>
хорошо подходит для этой задачи. Используем тип (XPacketType
) как ключ, а Tuple<T1, T2>
будет хранить в себе значение типа (T1
) и подтипа (T2
). Создадим функцию для регистрации типов пакета.
public static void RegisterType(XPacketType type, byte btype, byte bsubtype) { if (TypeDictionary.ContainsKey(type)) { throw new Exception(quot;Packet type {type:G} is already registered."); } TypeDictionary.Add(type, Tuple.Create(btype, bsubtype)); }
Имплементируем получение информации по типу:
public static Tuple<byte, byte> GetType(XPacketType type) { if (!TypeDictionary.ContainsKey(type)) { throw new Exception(quot;Packet type {type:G} is not registered."); } return TypeDictionary[type]; }
И конечно, получение типа пакета. Структура может выглядеть несколько хаотичной, но она будет работать.
public static XPacketType GetTypeFromPacket(XPacket packet) { var type = packet.PacketType; var subtype = packet.PacketSubtype; foreach (var tuple in TypeDictionary) { var value = tuple.Value; if (value.Item1 == type && value.Item2 == subtype) { return tuple.Key; } } return XPacketType.Unknown; }
Создаем структуру пакетов для их сериализации и десериализации
Чтобы не парсить все вручную, обратимся к сериализации и десериализации классов. Для этого нужно создать класс и расставить атрибуты. Все остальное код сделает самостоятельно; потребуется только атрибут с информацией о том, с какого поля писать и читать.
[AttributeUsage(AttributeTargets.Field)] public class XFieldAttribute : Attribute { public byte FieldID { get; } public XFieldAttribute(byte fieldId) { FieldID = fieldId; } }
Используя AttributeUsage
, мы установили, что наш атрибут можно будет установить только на поля классов. FieldID
будет использоваться для хранения ID поля внутри пакета.
Создаем сериализатор
Для сериализации и десериализации в C# используется Reflection
. Этот набор классов позволит узнать всю необходимую информацию и установить значение полей во время рантайма.
Для начала необходимо собрать информацию о полях, которые будут участвовать в процессе сериализации. Для этого можно использовать простое выражение LINQ.
private static List<Tuple<FieldInfo, byte>> GetFields(Type t) { return t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) .Where(field => field.GetCustomAttribute<XFieldAttribute>() != null) .Select(field => Tuple.Create(field, field.GetCustomAttribute<XFieldAttribute>().FieldID)) .ToList(); }
Так как необходимые поля помечены атрибутом XFieldAttribute
, найти их внутри класса не составит труда. Сначала получим все нестатичные, приватные и публичные поля при помощи GetFields()
. Выбираем все поля, у которых есть наш атрибут. Собираем новый IEnumerable
, который содержит Tuple<FieldInfo, byte>
, где byte
— ID нашего поля в пакете.
INFO
Здесь мы вызываем GetCustomAttribute<>()
два раза. Это не обязательно, но таким образом код будет выглядеть аккуратнее.
Итак, теперь ты умеешь получать все FieldInfo
для типа, который будешь сериализовать. Пришло время создать сам сериализатор: у него будут обычный и строгий режимы работы. Во время обычного режима будет игнорироваться тот факт, что разные поля используют один и тот же ID поля внутри пакета.
public static XPacket Serialize(byte type, byte subtype, object obj, bool strict = false) { var fields = GetFields(obj.GetType()); if (strict) { var usedUp = new List<byte>(); foreach (var field in fields) { if (usedUp.Contains(field.Item2)) { throw new Exception("One field used two times."); } usedUp.Add(field.Item2); } } var packet = XPacket.Create(type, subtype); foreach (var field in fields) { packet.SetValue(field.Item2, field.Item1.GetValue(obj)); } return packet; }
Внутри foreach
происходит самое интересное: fields
содержит все нужные поля в виде Tuple<FieldInfo, byte>
. Item1
— искомое поле, Item2
— ID этого поля внутри пакета. Перебираем их все, следом устанавливаем значения полей при помощи SetPacket(byte, object)
. Теперь пакет сериализован.
Создаем десериализатор
Создавать десериализатор в разы проще. Нужно использовать функцию GetFields()
, которую мы имплементировали в прошлом разделе.
public static T Deserialize<T>(XPacket packet, bool strict = false) { var fields = GetFields(typeof(T)); var instance = Activator.CreateInstance<T>(); if (fields.Count == 0) { return instance; } /* <---> */
После того как мы подготовили все к десериализации, можем приступить к делу. Выполняем проверки для режима strict
, бросая исключение, когда это нужно.
/* <---> */ foreach (var tuple in fields) { var field = tuple.Item1; var packetFieldId = tuple.Item2; if (!packet.HasField(packetFieldId)) { if (strict) { throw new Exception(quot;Couldn't get field[{packetFieldId}] for {field.Name}"); } continue; } /* Очень важный костыль, который многое упрощает * Метод GetValue<T>(byte) принимает тип как type-параметр * Наш же тип внутри field.FieldType * Используя Reflection, вызываем метод с нужным type-параметром */ var value = typeof(XPacket) .GetMethod("GetValue")? .MakeGenericMethod(field.FieldType) .Invoke(packet, new object[] {packetFieldId}); if (value == null) { if (strict) { throw new Exception(quot;Couldn't get value for field[{packetFieldId}] for {field.Name}"); } continue; } field.SetValue(instance, value); } return instance; }
Создание десериализатора завершено. Теперь можно проверить работоспособность кода. Для начала создадим простой класс.
class TestPacket { [XField(0)] public int TestNumber; [XField(1)] public double TestDouble; [XField(2)] public bool TestBoolean; }
Напишем простой тест.
var t = new TestPacket {TestNumber = 12345, TestDouble = 123.45D, TestBoolean = true}; var packet = XPacketConverter.Serialize(0, 0, t); var tDes = XPacketConverter.Deserialize<TestPacket>(packet); if (tDes.TestBoolean) { Console.WriteLine(quot;Number = {tDes.TestNumber}\n" + quot;Double = {tDes.TestDouble}"); }
После запуска программы должны отобразиться две строки:
Number = 12345
Double = 123,45
А теперь перейдем к тому, для чего все это создавалось.
Первое рукопожатие
Рукопожатие применяется в протоколах для того, чтобы удостовериться, что клиент и сервер используют одинаковый протокол, и проверить соединение. В данном случае рукопожатие позволит проверить, работает ли протокол.
WWW
Примеры работы с сокетами ты найдешь в официальной документации в главе Socket Code Examples.
Мы создали простой пакет для обмена рукопожатиями.
public class XPacketHandshake { [XField(1)] public int MagicHandshakeNumber; }
Рукопожатие будет инициировать клиент. Он отправляет пакет рукопожатия с рандомным числом, а сервер в свою очередь должен ответить числом, на 15 меньше полученного.
Отправляем пакет на сервер.
var rand = new Random(); HandshakeMagic = rand.Next(); client.QueuePacketSend( XPacketConverter.Serialize( XPacketType.Handshake, new XPacketHandshake { MagicHandshakeNumber = HandshakeMagic }).ToPacket());
При получении пакета от сервера обрабатываем handshake
отдельной функцией.
private static void ProcessIncomingPacket(XPacket packet) { var type = XPacketTypeManager.GetTypeFromPacket(packet); switch (type) { case XPacketType.Handshake: ProcessHandshake(packet); break; case XPacketType.Unknown: break; default: throw new ArgumentOutOfRangeException(); } }
Десериализуем, проверяем ответ от сервера.
private static void ProcessHandshake(XPacket packet) { var handshake = XPacketConverter.Deserialize<XPacketHandshake>(packet); if (HandshakeMagic - handshake.MagicHandshakeNumber == 15) { Console.WriteLine("Handshake successful!"); } }
На стороне сервера есть свой идентичный ProcessIncomingPacket
. Разберем процесс обработки пакета на стороне сервера. Десериализуем пакет рукопожатия от клиента, отнимаем пятнадцать, сериализуем и отправляем обратно.
private void ProcessHandshake(XPacket packet) { Console.WriteLine("Recieved handshake packet."); var handshake = XPacketConverter.Deserialize<XPacketHandshake>(packet); handshake.MagicHandshakeNumber -= 15; Console.WriteLine("Answering.."); QueuePacketSend( XPacketConverter.Serialize(XPacketType.Handshake, handshake) .ToPacket()); }
Собираем и проверяем.
Имплементируем простую защиту протокола
Наш протокол будет иметь два типа пакетов — обычный и защищенный. У обычного наш стандартный заголовок, а у защищенного вот такой: [0x95, 0xAA, 0xFF]
.
Чтобы отличать зашифрованные пакеты от обычных, потребуется добавить свойство внутрь класса XPacket
.
public bool Protected { get; set; }
После модифицируем функцию XPacket.Parse(byte[])
, чтобы она принимала и расшифровывала новые пакеты. Сначала модифицируем функцию проверки заголовка:
var encrypted = false; if (packet[0] != 0xAF || packet[1] != 0xAA || packet[2] != 0xAF) { if (packet[0] == 0x95 || packet[1] == 0xAA || packet[2] == 0xFF) { encrypted = true; } else { return null; } }
Как будет выглядеть наш зашифрованный пакет? По сути, это будет пакет в пакете (вроде пакета с пакетами, который ты прячешь на кухне, только здесь защищенный пакет содержит в себе зашифрованный обычный пакет).
Теперь необходимо расшифровать и распарсить зашифрованный пакет. Позволяем пометить пакет как продукт расшифровки другого пакета.
public static XPacket Parse(byte[] packet, bool markAsEncrypted = false)
Добавляем функциональность в цикл парсинга полей.
if (fields.Length == 2) { return encrypted ? DecryptPacket(xpacket) : xpacket; }
Так как мы принимаем только структуры как типы данных, мы не сможем записать byte[]
внутрь поля. Поэтому немного модифицируем код, добавив новую функцию, которая будет принимать массив данных.
public void SetValueRaw(byte id, byte[] rawData) { var field = GetField(id); if (field == null) { field = new XPacketField { FieldID = id }; Fields.Add(field); } if (rawData.Length > byte.MaxValue) { throw new Exception("Object is too big. Max length is 255 bytes."); } field.FieldSize = (byte) rawData.Length; field.Contents = rawData; }
Сделаем такую же, но уже для получения данных из поля.
public byte[] GetValueRaw(byte id) { var field = GetField(id); if (field == null) { throw new Exception(quot;Field with ID {id} wasn't found."); } return field.Contents; }
Теперь все готово для создания функции расшифровки пакета. Шифрование будет использовать класс RijndaelManaged
со строкой в качестве пароля для шифрования. Строка с паролем будет константна. Это шифрование поможет защититься от атаки типа MITM.
Создадим класс, который будет шифровать и расшифровывать данные.
WWW
Так как процесс шифрования выглядит идентично, возьмем готовое решение для шифрования строки с Stack Overflow и адаптируем его для себя.
Модифицируем методы, чтобы они принимали и возвращали массивы байтов.
public static byte[] Encrypt(byte[] data, string passPhrase) public static byte[] Decrypt(byte[] data, string passPhrase)
И простой хендлер, который будет хранить секретный ключ.
public class XProtocolEncryptor { private static string Key { get; } = "2e985f930853919313c96d001cb5701f"; public static byte[] Encrypt(byte[] data) { return RijndaelHandler.Encrypt(data, Key); } public static byte[] Decrypt(byte[] data) { return RijndaelHandler.Decrypt(data, Key); } }
Затем создаем функцию для расшифровки. Данные обязательно должны быть в поле с ID = 0. Как иначе нам его искать?
private static XPacket DecryptPacket(XPacket packet) { if (!packet.HasField(0)) { return null; } var rawData = packet.GetValueRaw(0); var decrypted = XProtocolEncryptor.Decrypt(rawData); return Parse(decrypted, true); }
Получаем данные, расшифровываем и парсим заново. То же самое проделываем с обратной процедурой.
Вводим свойство, чтобы пометить надобность в заголовке зашифрованного пакета.
private bool ChangeHeaders { get; set; }
Создаем простой пакет и помечаем, что в нем зашифрованные данные.
public static XPacket EncryptPacket(XPacket packet) { if (packet == null) { return null; } var rawBytes = packet.ToPacket(); var encrypted = XProtocolEncryptor.Encrypt(rawBytes); var p = Create(0, 0); p.SetValueRaw(0, encrypted); p.ChangeHeaders = true; return p; }
И добавляем две функции для более удобного обращения.
public XPacket Encrypt() { return EncryptPacket(this); } public XPacket Decrypt() { return DecryptPacket(this); }
Модифицируем ToPacket()
, чтобы тот слушался значения ChangeHeaders
.
packet.Write(ChangeHeaders ? new byte[] {0x95, 0xAA, 0xFF, PacketType, PacketSubtype} : new byte[] {0xAF, 0xAA, 0xAF, PacketType, PacketSubtype}, 0, 5);
Проверяем:
var packet = XPacket.Create(0, 0); packet.SetValue(0, 12345); var encr = packet.Encrypt().ToPacket(); var decr = XPacket.Parse(encr); Console.WriteLine(decr.GetValue<int>(0));
В консоли получаем число 12345
.
Заключение
Только что мы создали свой собственный протокол. Это был долгий путь от базовой структуры на бумаге до его полной имплементации в коде. Надеюсь, тебе было интересно!