Implementación del protocolo de consulta de Minecraft en .Net Core

Minecraft Server Query es un protocolo simple que le permite obtener información actualizada sobre el estado del servidor enviando un par de paquetes UDP simples.





La wiki tiene una descripción detallada de este protocolo con ejemplos de implementación en diferentes idiomas. Sin embargo, me llamó la atención lo escasas que existen las implementaciones de .Net en este momento. Después de buscar por un tiempo, encontré varios repositorios. Las soluciones propuestas contenían errores triviales o tenían una funcionalidad reducida, aunque, al parecer, mucho más para cortar algo.





Entonces se tomó la decisión de escribir mi propia implementación.





Dime quien eres ...

Primero, veamos qué es el protocolo Minecraft Query en sí. Según la wiki , tenemos a nuestra disposición 3 tipos de paquetes de solicitud y, en consecuencia, 3 tipos de paquetes de respuesta:





  • Apretón de manos





  • BasicStatus





  • FullStatus





El primer tipo de paquete se utiliza para obtener el ChallengeToken necesario para formar los otros dos paquetes. Se une a la dirección IP del remitente durante 30 segundos . La carga semántica de los dos restantes se desprende de los nombres.





Vale la pena señalar que aunque las dos últimas solicitudes difieren entre sí solo por la alineación en los extremos de los paquetes, las respuestas enviadas difieren en la forma en que se presentan los datos. Por ejemplo, así es como se ve la respuesta de BasicStatus





Respuesta a la solicitud de BasicStatus
Respuesta a la solicitud de BasicStatus

– FullStatus





Respuesta FullStatus
FullStatus

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









Consulta general

.





,

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



Lo único que queda por hacer es implementar la espera de restauración de la conexión. Para hacer esto, solo necesitamos asegurarnos de haber recibido al menos algún tipo de respuesta del servidor. Para hacer esto, podemos usar la misma solicitud de protocolo de enlace, que no requiere un 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