Implementando o protocolo de consulta do Minecraft no .Net Core

O Minecraft Server Query é um protocolo simples que permite obter informações atualizadas sobre o estado do servidor, enviando alguns pacotes UDP simples.





O wiki tem uma descrição detalhada deste protocolo com exemplos de implementação em diferentes linguagens. No entanto, me ocorreu como as implementações .Net escassas existem no momento. Depois de pesquisar um pouco, me deparei com vários repositórios. As soluções propostas continham erros triviais ou tinham funcionalidade reduzida, embora, ao que parece, muito mais para cortar algo.





Portanto, tomei a decisão de escrever minha própria implementação.





Me diga quem você é ...

Primeiro, vamos ver o que é o próprio protocolo de consulta do Minecraft. De acordo com o wiki , temos à nossa disposição 3 tipos de pacotes de solicitação e, portanto, 3 tipos de pacotes de resposta:





  • Aperto de mão





  • BasicStatus





  • FullStatus





O primeiro tipo de pacote é usado para obter o ChallengeToken necessário para formar os outros dois pacotes. Ele se liga ao endereço IP do remetente por 30 segundos . A carga semântica dos dois restantes fica clara a partir dos nomes.





Vale ressaltar que embora as duas últimas requisições se diferenciem apenas pelo alinhamento nas extremidades dos pacotes, as respostas enviadas diferem na forma como os dados são apresentados. Por exemplo, é assim que a resposta de BasicStatus se parece





Resposta à solicitação BasicStatus
Resposta à solicitação BasicStatus

– FullStatus





Resposta FullStatus
FullStatus

, , short, big-endian. SessionId, - , SessionId & 0x0F0F0F0F == SessionId.









Consulta geral

.





,

, , . 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);
}
      
      



A única coisa que falta fazer é implementar a espera pela restauração da conexão. Para fazer isso, só precisamos ter certeza de que recebemos pelo menos algum tipo de resposta do servidor. Para fazer isso, podemos usar a mesma solicitação de handshake, que não requer um ChallengeToken válido.





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);
}
      
      






All Articles