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: Вынес библиотеку в отдельный репозиторий.