Реализация Minecraft Query протокола в .Net Core

Minecraft Server Query – это простой протокол, позволяющий получить актуальную информацию о состоянии сервера путём отправки пары-тройки незамысловатых UDP-пакетов.

На вики есть подробное описание этого протокола с примерами реализации на разных языках. Однако меня поразило, насколько куцые реализации для .Net существуют на данный момент. Поискав некоторое время, я наткнулся на несколько репозиториев. Предлагаемые решения либо содержали банальные ошибки, либо имели урезанный функционал, хотя, казалось бы, куда ещё больше урезать-то.

Так было принято решение написать свою реализацию.

Скажи мне, кто ты...

Для начала, посмотрим, что из себя представляет сам протокол Minecraft Query. Согласно вики, мы имеем в распоряжении 3 вида пакетов запросов и, соотвественно, 3 вида пакетов ответа:

  • Handshake

  • BasicStatus

  • FullStatus

Первый тип пакета используется для получения ChallengeToken, необходимого для формирования других двух пакетов. Привязывается он к IP-адресу отправителя на 30 секунд. Смысловая нагрузка оставшихся двух ясна из названий.

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

Ответ на запрос BasicStatus
Ответ на запрос BasicStatus

А вот так – FullStatus

Ответ на запрос FullStatus
Ответ на запрос FullStatus

Все данные, помимо тех, что хранятся в short, представлены в big-endian. А для поля SessionId, которое постоянно в рамках одного клиент-сервер соединения, должно выполняться условие SessionId & 0x0F0F0F0F == SessionId.

В общем виде запрос выглядит так

Запрос в общем виде
Запрос в общем виде

Более подробно об этом об этом можно почитать на вики.

И я скажу тебе, как тебя распарсить

Для начала, определимся, что мы хотим получить на выходе. Готовая библиотека должна предоставлять API для отправки любого из 3 видов пакетов и получения результата в распаршеном виде.

При этом, я хочу больше свободы в плане поддержания жизнеспособности сокетов и обновления ChallengeToken. Если я буду запрашивать состояние сервера каждые 3 секунды, то я не хочу, чтобы вместо одного пакета запроса отправлялось два: хэндшейк и состояние. И наоборот, если я опрашиваю сервер раз в час, зачем мне слать запросы каждые 30 секунд? Поэтому работа с библиотекой будет происходить в "ручном" режиме.

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

public static async Task<ServerState> DoSomething(IPAddress host, int port) {
	var mcQuery = new McQuery(host, port);
  mcQuery.InitSocket();
  await mcQuery.GetHandshake();
  return await mcQuery.GetFullStatus();
}

Здесь создаётся разовое соединение. Для долгоживущего потребуется проверять состояние сокета и инициализировать заново (об этом в конце статьи).

Для того, чтобы пакет отправить, его надо для начала сформировать. Этим будет заниматься класс Request.

public class Request
{
		// Набор констант для формирования пакета
    private static readonly byte[] Magic = { 0xfe, 0xfd };
    private static readonly byte[] Challenge = { 0x09 };
    private static readonly byte[] Status = { 0x00 };
  
    public byte[] Data { get; private set; }
    
    private Request(){}

    public byte RequestType => Data[2];

    public static Request GetHandshakeRequest(SessionId sessionId)
    {
        var request = new Request();
        
      	// Собираем пакет
        var data = new List<byte>();
        data.AddRange(Magic);
        data.AddRange(Challenge);
        data.AddRange(sessionId.GetBytes());
        
        request.Data = data.ToArray();
        return request;
    }

    public static Request GetBasicStatusRequest(SessionId sessionId, byte[] challengeToken)
    {
        if (challengeToken == null)
        {
            throw new ChallengeTokenIsNullException();
        }
            
        var request = new Request();
        
        var data = new List<byte>();
        data.AddRange(Magic);
        data.AddRange(Status);
        data.AddRange(sessionId.GetBytes());
        data.AddRange(challengeToken);
        
        request.Data = data.ToArray();
        return request;
    }
    
    public static Request GetFullStatusRequest(SessionId sessionId, byte[] challengeToken)
    {
        if (challengeToken == null)
        {
            throw new ChallengeTokenIsNullException();
        }
        
        var request = new Request();
        
        var data = new List<byte>();
        data.AddRange(Magic);
        data.AddRange(Status);
        data.AddRange(sessionId.GetBytes());
        data.AddRange(challengeToken);
        data.AddRange(new byte[] {0x00, 0x00, 0x00, 0x00}); // Padding
        
        request.Data = data.ToArray();
        return request;
    }
}

Здесь всё просто. Храним все константы внутри класса и формируем пакет в трёх статических методах. Можно ещё заметить класс SessionId, который может давать как байтовое, так и строковое представление по необходимости.

public class SessionId
{
    private readonly byte[] _sessionId;

    public SessionId (byte[] sessionId)
    {
        _sessionId = sessionId;
    }

		// Случайный SessionId
    public static SessionId GenerateRandomId()
    {
        var sessionId = new byte[4];
        new Random().NextBytes(sessionId);
        sessionId = sessionId.Select(@byte => (byte)(@byte & 0x0F)).ToArray();
        return new SessionId(sessionId);
    }

    public string GetString()
    {
        return BitConverter.ToString(_sessionId);
    }

    public byte[] GetBytes()
    {
        var sessionId = new byte[4];
        Buffer.BlockCopy(_sessionId, 0, sessionId, 0, 4);
        return sessionId;
    }
}

Дождавшись ответа сервера, мы получаем последовательность байт, которые хотим привести к человекочитаемому виду. Для этого служит класс Response, который представляет набор "парсеров" в виде статических полей.

public static class Response
{
	public static byte ParseType(byte[] data)
	{
		return data[0];
	}

  // 
	public static SessionId ParseSessionId(byte[] data)
	{
		if (data.Length < 1) throw new IncorrectPackageDataException(data);
		var sessionIdBytes = new byte[4];
		Buffer.BlockCopy(data, 1, sessionIdBytes, 0, 4);
		return new SessionId(sessionIdBytes);
	}

	public static byte[] ParseHandshake(byte[] data)
	{
		if (data.Length < 5) throw new IncorrectPackageDataException(data);
		var response = BitConverter.GetBytes(int.Parse(Encoding.ASCII.GetString(data, 5, data.Length - 6)));
		if (BitConverter.IsLittleEndian)
		{
			response = response.Reverse().ToArray();
		}

		return response;
	}

	public static ServerBasicState ParseBasicState(byte[] data)
	{
		if (data.Length <= 5)
			throw new IncorrectPackageDataException(data);

		var statusValues = new Queue<string>();
		short port = -1;

		data = data.Skip(5).ToArray(); // Skip Type + SessionId
		var stream = new MemoryStream(data);

		var sb = new StringBuilder();
		int currentByte;
		int counter = 0;
		while ((currentByte = stream.ReadByte()) != -1)
		{
			if (counter > 6) break;

      // Парсим нормер порта
			if (counter == 5)
			{
				byte[] portBuffer = {(byte) currentByte, (byte) stream.ReadByte()};
				if (!BitConverter.IsLittleEndian)
					portBuffer = portBuffer.Reverse().ToArray();

				port = BitConverter.ToInt16(portBuffer); // Little-endian short
				counter++;

				continue;
			}

      // Парсим параметры-строки
			if (currentByte == 0x00)
			{
				string fieldValue = sb.ToString();
				statusValues.Enqueue(fieldValue);
				sb.Clear();
				counter++;
			}
			else sb.Append((char) currentByte);
		}

		var serverInfo = new ServerBasicState
		{
			Motd = statusValues.Dequeue(),
			GameType = statusValues.Dequeue(),
			Map = statusValues.Dequeue(),
			NumPlayers = int.Parse(statusValues.Dequeue()),
			MaxPlayers = int.Parse(statusValues.Dequeue()),
			HostPort = port,
			HostIp = statusValues.Dequeue(),
		};

		return serverInfo;
	}

  // "Секции" пакета резделены константными последовательностями байт,
  // это можно испльзовать для проверки, что мы всё сделали правильно
	public static ServerFullState ParseFullState(byte[] data)
	{
		var statusKeyValues = new Dictionary<string, string>();
		var players = new List<string>();

		var buffer = new byte[256];
		Stream stream = new MemoryStream(data);

		stream.Read(buffer, 0, 5); // Read Type + SessionID
		stream.Read(buffer, 0, 11); // Padding: 11 bytes constant
		var constant1 = new byte[] {0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, 0x00};
		for (int i = 0; i < constant1.Length; i++)
			Debug.Assert(constant1[i] == buffer[i], "Byte mismatch at " + i + " Val :" + buffer[i]);

		var sb = new StringBuilder();
		string lastKey = string.Empty;
		int currentByte;
		while ((currentByte = stream.ReadByte()) != -1)
		{
			if (currentByte == 0x00)
			{
				if (!string.IsNullOrEmpty(lastKey))
				{
					statusKeyValues.Add(lastKey, sb.ToString());
					lastKey = string.Empty;
				}
				else
				{
					lastKey = sb.ToString();
					if (string.IsNullOrEmpty(lastKey)) break;
				}

				sb.Clear();
			}
			else sb.Append((char) currentByte);
		}

		stream.Read(buffer, 0, 10); // Padding: 10 bytes constant
		var constant2 = new byte[] {0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00};
		for (int i = 0; i < constant2.Length; i++)
			Debug.Assert(constant2[i] == buffer[i], "Byte mismatch at " + i + " Val :" + buffer[i]);

		while ((currentByte = stream.ReadByte()) != -1)
		{
			if (currentByte == 0x00)
			{
				var player = sb.ToString();
				if (string.IsNullOrEmpty(player)) break;
				players.Add(player);
				sb.Clear();
			}
			else sb.Append((char) currentByte);
		}

		ServerFullState fullState = new()
		{
			Motd = statusKeyValues["hostname"],
			GameType = statusKeyValues["gametype"],
			GameId = statusKeyValues["game_id"],
			Version = statusKeyValues["version"],
			Plugins = statusKeyValues["plugins"],
			Map = statusKeyValues["map"],
			NumPlayers = int.Parse(statusKeyValues["numplayers"]),
			MaxPlayers = int.Parse(statusKeyValues["maxplayers"]),
			PlayerList = players.ToArray(),
			HostIp = statusKeyValues["hostip"],
			HostPort = int.Parse(statusKeyValues["hostport"]),
		};

		return fullState;
	}
}

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

Долгоживущие приложения на основе библиотеки

Вернёмся к том, о чем я говорил выше. Это можно реализовать таким образом. Код взят из моего нотификатора пользовательской активности. Здесь каждые 5 секунд запрашивается FullStatus, поэтому имеет смысл обновлять ChallengeToken периодически сразу после истечения предыдущего. Всего приложение имеет 2 режима работы: штатный и режим восстановления соединения.

В штатном режиме приложение по таймерам обновляет токен и запрашивает FullStatus. При обнаружении упавшего сервера/оборванного соединения/etc (5 попыток передачи) приложение переходит в режим восстановления соединения и при удачной попытке получения сообщения снова возвращается в штатный режим.

Для начала напишем конструктор и два метода для запуска прослушивания сервера и окончания.

public StatusWatcher(string serverName, string host, int queryPort)
{
    ServerName = serverName;
    _mcQuery = new McQuery(Dns.GetHostAddresses(host)[0], queryPort);
    _mcQuery.InitSocket();
}

public async Task Unwatch()
{
    await UpdateChallengeTokenTimer.DisposeAsync();
    await UpdateServerStatusTimer.DisposeAsync();
}

public async void Watch()
{
  	// Обновляем challengetoken по таймеру каждые 30 секунд
    UpdateChallengeTokenTimer = new Timer(async obj =>
    {
        if (!IsOnline) return;
        
        if(Debug)
            Console.WriteLine($"[INFO] [{ServerName}] Send handshake request");

        try
        {
            var challengeToken = await _mcQuery.GetHandshake();
            
          	// Если всё ок, говорим, что мы в онлайне и сбрасываем счетчик попыток
            IsOnline = true;
          	
            lock (_retryCounterLock)
            {
                RetryCounter = 0;
            }
            
            if(Debug)
                Console.WriteLine($"[INFO] [{ServerName}] ChallengeToken is set up: " + BitConverter.ToString(challengeToken));
        }
        
      	// Если что-то не так, увеличиваем счетчик неудачных попыток
        catch (Exception ex)
        {
            if (ex is SocketException || ex is McQueryException || ex is ChallengeTokenIsNullException)
            {
                if(Debug)
                    Console.WriteLine($"[WARNING] [{ServerName}] [UpdateChallengeTokenTimer] Server doesn't response. Try to reconnect: {RetryCounter}");
                if(ex is McQueryException)
                    Console.Error.WriteLine(ex);
                
                lock (_retryCounterLock)
                {
                    RetryCounter++;
                    if (RetryCounter >= RetryMaxCount)
                    {
                        RetryCounter = 0;
                        WaitForServerAlive(); // Переходим в режим восстановления соединения
                    }
                }
            }

            else
            {
                throw;
            }
        }
        
    }, null, 0, GettingChallengeTokenInterval);
        
  
  	// По таймеру запрашиваем текущее состояние
    UpdateServerStatusTimer = new Timer(async obj =>
    {
        if (!IsOnline) return;
        
        if(Debug)
            Console.WriteLine($"[INFO] [{ServerName}] Send full status request");

        try
        {
            var response = await _mcQuery.GetFullStatus();
            
            IsOnline = true;
            lock (_retryCounterLock)
            {
                RetryCounter = 0;
            }
            
            if(Debug)
                Console.WriteLine($"[INFO] [{ServerName}] Full status is received");
            
            OnFullStatusUpdated?.Invoke(this, new ServerStateEventArgs(ServerName, response));
        }
        
      	// По аналогии с предыдущим
        catch (Exception ex)
        {
            if (ex is SocketException || ex is McQueryException || ex is ChallengeTokenIsNullException)
            {
                if(Debug)
                    Console.WriteLine($"[WARNING] [{ServerName}] [UpdateServerStatusTimer] Server doesn't response. Try to reconnect: {RetryCounter}");
                if(ex is McQueryException)
                    Console.Error.WriteLine(ex);
                
                lock (_retryCounterLock)
                {
                    RetryCounter++;
                    if (RetryCounter >= RetryMaxCount)
                    {
                        RetryCounter = 0;
                        WaitForServerAlive();
                    }
                }
            }
            
            else
            {
                throw;
            }
        }
        
    }, null, 500, GettingStatusInterval);
}

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

public async void WaitForServerAlive()
{
    if(Debug)
        Console.WriteLine($"[WARNING] [{ServerName}] Server is unavailable. Waiting for reconnection...");

  	// Отключаем отслеживание
    IsOnline = false;
    await Unwatch();

    _mcQuery.InitSocket(); // Пересоздаём сокет

    Timer waitTimer = null;
    waitTimer = new Timer(async obj => {
        try
        {
            await _mcQuery.GetHandshake();

          	// Говорим, что можно возвращаться в штатный режим и отключаем таймер
            IsOnline = true;
            Watch();
            lock (_retryCounterLock)
            {
                RetryCounter = 0;
            }

            waitTimer.Dispose();
        }
      
      	// Пересоздаем сокет каждые 5 (настраивается) неудачных соединений
        catch (SocketException)
        {
            if(Debug)
                Console.WriteLine($"[WARNING] [{ServerName}] [WaitForServerAlive] Server doesn't response. Try to reconnect: {RetryCounter}");

            lock (_retryCounterLock)
            {
                RetryCounter++;
                if (RetryCounter >= RetryMaxCount)
                {
                    if(Debug)
                        Console.WriteLine($"[WARNING] [{ServerName}] [WaitForServerAlive] Recreate socket");

                    RetryCounter = 0;
                    _mcQuery.InitSocket();
                }
            }
        }
    }, null, 500, 5000);
}

UDP1: Вынес библиотеку в отдельный репозиторий.

Комментарии 15

    0

    Зачем такая жесть с таймерами? Почему нельзя сделать просто?


    private Task watchTask;
    private CancellationTokenSource watchCts;
    
    public void Watch() {
        if (watchCts != null) return;
        watchCts = new CancellationTokenSource();
        watchTask = Task.Run(() => RunWatch(watchCts.Token));
    }
    
    public Task Unwatch() {
        watchCts?.Cancel();
        var result = watchTask;
        watchCts = null;
        watchTask = null;
        return result ?? Task.Completed;
    }
    
    private async Task RunWatch(CancellationToken stopToken) {
        while (true) {
            TimeSpan delay;
    
            try {
                await mcQuery.GetHandshake();
                var status = await mcQuery.GetFullStatus();
                // …
                delay = GettingStatusInterval;
            } catch (SocketException) {
                // …
                delay = RetryInterval;
            }
    
            try {
                await Task.Delay(delay, stopToken);
            } catch (TaskCanceledException) {
                return;
            }
        }
    }
      0

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

        0

        Не верю, что кому-то нужно запрашивать статус сервера чаще чем раз в полминуты — а дольше, по вашим же словам, токен и не проживёт.


        Но можно и проверку добавить:


            var lastHandshake = DateTime.MinValue;
        
            while (true) {
                TimeSpan delay;
        
                try {
                    var now = DateTime.UtcNow;
                    if (now - lastHandshake > TokenLivespan) {
                        lastHandshake = now;
                        await mcQuery.GetHandshake();
                    }
                    await mcQuery.GetFullStatus();
        
                    // …
                } catch (SocketException) {
                    // …
                }
        
                    // …
            }

        А ещё лучше — если mcQuery сам будет следить за временем жизни токена и при необходимости получать его, тогда прикладной код будет ещё проще.

          0

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


          А ещё лучше — если mcQuery сам будет следить за временем жизни токена и при необходимости получать его, тогда прикладной код будет ещё проще.

          Справедливо

      0
      Крайне много лишних аллокаций
        0

        Можно по-подробнее? Хотелось бы разобраться

          0

          Для начала, всем спискам надо бы указать начальную capacity, благо вы размеры всех пакетов-запросов знаете. Или же можно использовать System.Buffers.ArrayBufferWriter, но это не обязательно.


          Далее, если парсить ответы не через MemoryStream, а через массив с переменной-индексом — можно избавиться от всех временных массивов при парсинге. Ещё при желании можно воспользоваться структурой System.Buffers.SequenceReader, если массив с индексом выглядит слишком олдскульно.


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


              public void WriteTo(List<byte> list) {
                  list.AddRange(_sessionId);
              }

          Опять-таки, для генерации сессии LINQ не требуется:


              rnd.NextBytes(sessionId);
              for (int i=0; i<sessionId.Length; i++)
                  sessionId[i] &= 0x0f;

          PS кстати, никогда не создавайте новый Random для генерации очередного числа. Принимайте такие вещи параметром.

            0

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


            Например, как-нибудь так


            private static Random _rnd;
            private static Random Rnd
            {
                get => _rnd ??= new Random();
                set => _rnd = value;
            }
              0

              Статическое поле приведёт к нарушению потокобезопасности. Только параметром.

                0

                Благодарю

        0
        Будет ли выкладываться код в общий доступ?
          0

          Промазал по ветке. Чуть ниже скинул ссылку на репозиторий

            0

            Вынес в отдельный репозиторий для удобства.

            0

            Он уже. Но пока только как часть нотификатора. Репозиторий

              0

              Дошли руки поковыряться в коде парсера. Автор, использование System.Buffers упрощает код многократно! Могу уверенно заявить: отныне за использование MemoryStream для задачи разбора пакетов надо бить по рукам.


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


                      private static string ReadString(SequenceReader<byte> reader)
                      {
                          if (!reader.TryReadTo(out ReadOnlySequence<byte> bytes, delimiter: 0, advancePastDelimiter: true))
                              throw new IncorrectPackageDataException("Zero byte not found", reader.Sequence.ToArray());
              
                          return Encoding.ASCII.GetString(bytes); // а точно ASCII? Может, Utf8?
                      }

              Этого метода достаточно, чтобы без каких бы то ни было хитростей просто прочитать все поля протокола подряд. Вот код чтения ответа на базовый запрос (просто сравните с тем что там было раньше, со StringBuilder, очередью и диким циклом:


              ParseBasicState
                      public static ServerBasicState ParseBasicState(byte[] data)
                      {
                          if (data.Length <= 5)
                              throw new IncorrectPackageDataException(data);
              
                          var reader = new SequenceReader<byte>(new ReadOnlySequence<byte>(data));
                          reader.Advance(5); // Skip Type + SessionId
              
                          var serverInfo = new ServerBasicState();
                          serverInfo.Motd = ReadString(reader);
                          serverInfo.GameType = ReadString(reader);
                          serverInfo.Map = ReadString(reader);
                          serverInfo.NumPlayers = int.Parse(ReadString(reader));
                          serverInfo.MaxPlayers = int.Parse(ReadString(reader));
              
                          if (!reader.TryReadLittleEndian(out short port))
                              throw new IncorrectPackageDataException(data);
                          serverInfo.HostPort = port;
              
                          serverInfo.HostIp = ReadString(reader);
                          return serverInfo;
                      }

              Обратите внимание также на метод TryReadLittleEndian — наконец-то можно явно задать порядок байт!


              Разбор "большого" пакета не сильно-то и сложнее:


              ParseFullState
                      private static readonly byte[] constant1 = new byte[] { 0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, 0x00 };
                      private static readonly byte[] constant2 = new byte[] {0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00}
                      public static ServerFullState ParseFullState(byte[] data)
                      {
                          if (data.Length <= 5)
                              throw new IncorrectPackageDataException(data);
              
                          var reader = new SequenceReader<byte>(new ReadOnlySequence<byte>(data));
                          reader.Advance(5); // Read Type + SessionID
              
                          if (!reader.IsNext(constant1, advancePast: true))
                              throw new IncorrectPackageDataException(data);
              
                          var statusKeyValues = new Dictionary<string, string>();
                          while (!reader.IsNext(0, advancePast: true))
                          {
                              var key = ReadString(reader);
                              var value = ReadString(reader);
                              statusKeyValues.Add(key, value);
                          }
              
                          if (!reader.IsNext(constant2, advancePast: true)) // Padding: 10 bytes constant
                              throw new IncorrectPackageDataException(data);
              
                          var players = new List<string>();
                          while (!reader.IsNext(0, advancePast: true))
                          {
                              players.Add(ReadString(reader));
                          }
              
                          ServerFullState fullState = new()
                          {
                              Motd = statusKeyValues["hostname"],
                              GameType = statusKeyValues["gametype"],
                              GameId = statusKeyValues["game_id"],
                              Version = statusKeyValues["version"],
                              Plugins = statusKeyValues["plugins"],
                              Map = statusKeyValues["map"],
                              NumPlayers = int.Parse(statusKeyValues["numplayers"]),
                              MaxPlayers = int.Parse(statusKeyValues["maxplayers"]),
                              PlayerList = players.ToArray(),
                              HostIp = statusKeyValues["hostip"],
                              HostPort = int.Parse(statusKeyValues["hostport"]),
                          };
              
                          return fullState;
                      }
                  }

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

              Самое читаемое