Implementing Minecraft Query Protocol in .Net Core

Minecraft Server Query is a simple protocol that allows you to get up-to-date information about the state of the server by sending a couple of simple UDP packets.





The wiki has a detailed description of this protocol with examples of implementation in different languages. However, it struck me how scanty .Net implementations exist at the moment. After searching for a while, I came across several repositories. The proposed solutions either contained trivial errors, or had reduced functionality, although, it would seem, much more to cut something.





So the decision was made to write my own implementation.





Tell me who you are ...

First, let's see what the Minecraft Query protocol itself is. According to the wiki , we have at our disposal 3 types of request packages and, accordingly, 3 types of response packages:





  • Handshake





  • BasicStatus





  • FullStatus





The first type of packet is used to obtain the ChallengeToken needed to form the other two packets. It binds to the sender's IP address for 30 seconds . The semantic load of the remaining two is clear from the names.





It is worth noting that although the last two requests differ from each other only by the alignment at the ends of the packets, the responses sent differ in the way the data is presented. For example, this is how BasicStatus's answer looks like





Response to BasicStatus Request
Response to BasicStatus Request

– FullStatus





FullStatus response
FullStatus

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









General query

.





,

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



The only thing left to do is to implement waiting for the connection to be restored. To do this, we just need to make sure that we have received at least some kind of response from the server. To do this, we can use the same handshake request, which does not require a valid 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);
}
      
      






All Articles