383 lines
15 KiB
C#
383 lines
15 KiB
C#
using System.Buffers.Binary;
|
||||
|
|
using PCL.Core.App.Localization;
|
|||
|
|
using PCL.Core.Link.McPing.Model;
|
|||
|
|
using PCL.Core.Logging;
|
|||
|
|
using PCL.Core.Utils;
|
|||
|
|
using System;
|
|||
|
|
using System.Collections.Generic;
|
|||
|
|
using System.IO;
|
|||
|
|
using System.Linq;
|
|||
|
|
using System.Net;
|
|||
|
|
using System.Net.Sockets;
|
|||
|
|
using System.Text;
|
|||
|
|
using System.Text.Json;
|
|||
|
|
using System.Text.Json.Nodes;
|
|||
|
|
using System.Threading;
|
|||
|
|
using System.Threading.Tasks;
|
|||
|
|
|
|||
|
|
namespace PCL.Core.Link.McPing;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 现代Minecraft协议服务器探测服务实现
|
|||
|
|
/// 支持1.7+版本的服务器信息查询协议
|
|||
|
|
/// </summary>
|
|||
|
|
public class McPingService : IMcPingService
|
|||
|
|
{
|
|||
|
|
private readonly IPEndPoint _endpoint;
|
|||
|
|
private readonly string _host;
|
|||
|
|
private const int DefaultTimeout = 10000;
|
|||
|
|
private readonly int _timeout;
|
|||
|
|
private bool _disposed;
|
|||
|
|
private const string ModuleName = "McPing";
|
|||
|
|
|
|||
|
|
public IPEndPoint Endpoint => _endpoint;
|
|||
|
|
public string Host => _host;
|
|||
|
|
public int Timeout => _timeout;
|
|||
|
|
|
|||
|
|
public McPingService(IPEndPoint endpoint, int timeout = DefaultTimeout)
|
|||
|
|
{
|
|||
|
|
_endpoint = endpoint;
|
|||
|
|
_host = _endpoint.Address.ToString();
|
|||
|
|
_timeout = timeout;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public McPingService(string ip, int port = 25565, int timeout = DefaultTimeout)
|
|||
|
|
{
|
|||
|
|
_endpoint = IPAddress.TryParse(ip, out var ipAddress)
|
|||
|
|
? new IPEndPoint(ipAddress, port)
|
|||
|
|
: new IPEndPoint(Dns.GetHostAddresses(ip).First(), port);
|
|||
|
|
_host = ip;
|
|||
|
|
_timeout = timeout;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public McPingService(string host, IPEndPoint endpoint, int timeout = DefaultTimeout)
|
|||
|
|
{
|
|||
|
|
_endpoint = endpoint;
|
|||
|
|
_host = host;
|
|||
|
|
_timeout = timeout;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 执行现代Minecraft协议的服务器探测
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="cancellationToken"></param>
|
|||
|
|
/// <returns></returns>
|
|||
|
|
public async Task<McPingResult?> PingAsync(CancellationToken cancellationToken = default)
|
|||
|
|
{
|
|||
|
|
using var so = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
|||
|
|
using var timeoutCts = new CancellationTokenSource(_timeout);
|
|||
|
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
LogWrapper.Debug(ModuleName, $"Connecting to {_endpoint}");
|
|||
|
|
await so.ConnectAsync(_endpoint.Address, _endpoint.Port, linkedCts.Token);
|
|||
|
|
}
|
|||
|
|
catch (OperationCanceledException)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Error(new TimeoutException(Lang.Text("Tools.ServerQuery.Error.Timeout.Connect")), ModuleName, $"Failed to connect to the {_endpoint}");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
catch (Exception e)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Error(e, ModuleName, $"Failed to connect to the {_endpoint}");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
LogWrapper.Debug(ModuleName, $"Connection established: {_endpoint}");
|
|||
|
|
await using var stream = new NetworkStream(so, false);
|
|||
|
|
|
|||
|
|
var handshakePacket = _BuildHandshakePacket(_host, _endpoint.Port);
|
|||
|
|
var statusPacket = _BuildStatusRequestPacket();
|
|||
|
|
|
|||
|
|
byte[]? statusPayload;
|
|||
|
|
long latency = 0;
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
await stream.WriteAsync(handshakePacket, linkedCts.Token);
|
|||
|
|
LogWrapper.Debug(ModuleName, $"Handshake sent, packet length: {handshakePacket.Length}");
|
|||
|
|
|
|||
|
|
await stream.WriteAsync(statusPacket, linkedCts.Token);
|
|||
|
|
LogWrapper.Debug(ModuleName, $"Status sent, packet length: {statusPacket.Length}");
|
|||
|
|
|
|||
|
|
var pingTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|||
|
|
var pingPacket = _BuildPingRequestPacket(pingTimestamp);
|
|||
|
|
|
|||
|
|
await stream.WriteAsync(pingPacket, linkedCts.Token);
|
|||
|
|
LogWrapper.Debug(ModuleName, $"Ping sent, packet length: {pingPacket.Length}");
|
|||
|
|
|
|||
|
|
(statusPayload, latency) = await _ReadStatusPayloadAsync(stream, linkedCts.Token);
|
|||
|
|
}
|
|||
|
|
catch (OperationCanceledException)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Error(new TimeoutException(Lang.Text("Tools.ServerQuery.Error.Timeout.ReadWrite")), "McPing", $"Operation timed out on {_endpoint}");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
catch (Exception e)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Error(e, ModuleName, $"Failed to communicate with {_endpoint}: {e.Message}");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
finally
|
|||
|
|
{
|
|||
|
|
if (so.Connected) so.Shutdown(SocketShutdown.Both);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
so.Close();
|
|||
|
|
|
|||
|
|
if (statusPayload is null || statusPayload.Length == 0) throw new InvalidDataException(Lang.Text("Tools.ServerQuery.State.NoInfo"));
|
|||
|
|
var retCtx = Encoding.UTF8.GetString(statusPayload);
|
|||
|
|
|
|||
|
|
var retJson = JsonCompat.ParseNode(retCtx) ?? throw new NullReferenceException(Lang.Text("Tools.ServerQuery.Error.InvalidResponse"));
|
|||
|
|
#if DEBUG
|
|||
|
|
var resJsonDebug = retJson.DeepClone();
|
|||
|
|
if (resJsonDebug is JsonObject jsonObject && jsonObject.ContainsKey("favicon"))
|
|||
|
|
{
|
|||
|
|
jsonObject["favicon"] = "...";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
LogWrapper.Debug(ModuleName, resJsonDebug.ToJsonString());
|
|||
|
|
#endif
|
|||
|
|
// 先处理Description字段,将其转换为字符串形式
|
|||
|
|
if (retJson["description"] is JsonObject descObj)
|
|||
|
|
{
|
|||
|
|
retJson["description"] = _ConvertJNodeToMcString(descObj);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var response = JsonSerializer.Deserialize<McPingResult>(retJson, JsonCompat.SerializerOptions);
|
|||
|
|
if (response?.Version is null)
|
|||
|
|
throw new NullReferenceException(Lang.Text("Tools.ServerQuery.Error.InvalidResponse"));
|
|||
|
|
|
|||
|
|
response = response with
|
|||
|
|
{
|
|||
|
|
Latency = latency
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return response;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void Dispose()
|
|||
|
|
{
|
|||
|
|
if (_disposed) return;
|
|||
|
|
_disposed = true;
|
|||
|
|
GC.SuppressFinalize(this);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 构建握手包
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="serverIp">服务器的地址</param>
|
|||
|
|
/// <param name="serverPort">服务器的端口</param>
|
|||
|
|
/// <returns>返回握手包的字节数组</returns>
|
|||
|
|
private byte[] _BuildHandshakePacket(string serverIp, int serverPort)
|
|||
|
|
{
|
|||
|
|
List<byte> handshake = [];
|
|||
|
|
handshake.AddRange(VarIntHelper.Encode(0)); //状态头 表明这是一个握手包
|
|||
|
|
handshake.AddRange(VarIntHelper.Encode(772)); //协议头 表明请求客户端的版本
|
|||
|
|
var binaryIp = Encoding.UTF8.GetBytes(serverIp);
|
|||
|
|
if (binaryIp.Length > 255) throw new Exception(Lang.Text("Tools.ServerQuery.Error.AddressTooLong"));
|
|||
|
|
handshake.AddRange(VarIntHelper.Encode((uint)binaryIp.Length)); //服务器地址长度
|
|||
|
|
handshake.AddRange(binaryIp); //服务器地址
|
|||
|
|
handshake.AddRange(BitConverter.GetBytes((ushort)serverPort).AsEnumerable().Reverse()); //服务器端口
|
|||
|
|
handshake.AddRange(VarIntHelper.Encode(1)); //1 表明当前状态为 ping 2 表明当前的状态为连接
|
|||
|
|
|
|||
|
|
handshake.InsertRange(0, VarIntHelper.Encode((uint)handshake.Count)); //包长度
|
|||
|
|
return handshake.ToArray();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private byte[] _BuildStatusRequestPacket()
|
|||
|
|
{
|
|||
|
|
List<byte> statusRequest = [];
|
|||
|
|
statusRequest.AddRange(VarIntHelper.Encode(1)); //包长度
|
|||
|
|
statusRequest.AddRange(VarIntHelper.Encode(0)); //包 ID
|
|||
|
|
return statusRequest.ToArray();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private byte[] _BuildPingRequestPacket(long timestamp)
|
|||
|
|
{
|
|||
|
|
List<byte> pingRequest = [];
|
|||
|
|
// Packet ID 使用值为 1 的 VarInt 编码和 8 字节的 long 时间戳
|
|||
|
|
pingRequest.AddRange(VarIntHelper.Encode(9));
|
|||
|
|
pingRequest.AddRange(VarIntHelper.Encode(1));
|
|||
|
|
pingRequest.AddRange(BitConverter.GetBytes(timestamp).AsEnumerable().Reverse());
|
|||
|
|
return pingRequest.ToArray();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async Task<(byte[] StatusPayload, long Latency)> _ReadStatusPayloadAsync(Stream stream, CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
byte[]? statusPayload = null;
|
|||
|
|
long? latency = null;
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
while (statusPayload is null || latency is null)
|
|||
|
|
{
|
|||
|
|
var packetLength = checked((int)await VarIntHelper.ReadFromStreamAsync(stream, cancellationToken));
|
|||
|
|
LogWrapper.Debug(ModuleName, $"Packet length: {packetLength}");
|
|||
|
|
if (packetLength <= 0) throw new InvalidDataException(Lang.Text("Tools.ServerQuery.Error.EmptyPacket"));
|
|||
|
|
|
|||
|
|
var packetData = await _ReadExactAsync(stream, packetLength, cancellationToken);
|
|||
|
|
using var packetStream = new MemoryStream(packetData, writable: false);
|
|||
|
|
var packetId = checked((int)await VarIntHelper.ReadFromStreamAsync(packetStream, cancellationToken));
|
|||
|
|
LogWrapper.Debug(ModuleName, $"Packet id: {packetId}");
|
|||
|
|
|
|||
|
|
switch (packetId)
|
|||
|
|
{
|
|||
|
|
case 0:
|
|||
|
|
var jsonLength = checked((int)await VarIntHelper.ReadFromStreamAsync(packetStream, cancellationToken));
|
|||
|
|
statusPayload = await _ReadExactAsync(packetStream, jsonLength, cancellationToken);
|
|||
|
|
if (packetStream.Position != packetStream.Length)
|
|||
|
|
LogWrapper.Warn(ModuleName, $"Status packet contains {packetStream.Length - packetStream.Position} trailing bytes.");
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 1:
|
|||
|
|
var pongData = await _ReadExactAsync(packetStream, 8, cancellationToken);
|
|||
|
|
if (packetStream.Position != packetStream.Length)
|
|||
|
|
LogWrapper.Warn(ModuleName, $"Pong packet contains {packetStream.Length - packetStream.Position} trailing bytes.");
|
|||
|
|
latency = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _ReadInt64BigEndian(pongData);
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
default:
|
|||
|
|
LogWrapper.Warn(ModuleName, $"Ignore unexpected packet type: {packetId}");
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (EndOfStreamException ex)
|
|||
|
|
{
|
|||
|
|
if (statusPayload is not null && latency is null)
|
|||
|
|
throw new EndOfStreamException(Lang.Text("Tools.ServerQuery.Error.StaleConnection"), ex);
|
|||
|
|
|
|||
|
|
if (statusPayload is null)
|
|||
|
|
throw new EndOfStreamException(Lang.Text("Tools.ServerQuery.Error.IncompleteConnection"), ex);
|
|||
|
|
|
|||
|
|
throw;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (statusPayload, latency.Value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static long _ReadInt64BigEndian(byte[] data)
|
|||
|
|
{
|
|||
|
|
return data.Length != 8
|
|||
|
|
? throw new ArgumentException(Lang.Text("Tools.ServerQuery.Error.PongDataLength"), nameof(data))
|
|||
|
|
: BinaryPrimitives.ReadInt64BigEndian(data);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task<byte[]> _ReadExactAsync(Stream stream, int length, CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
var buffer = new byte[length];
|
|||
|
|
await stream.ReadExactlyAsync(buffer, cancellationToken);
|
|||
|
|
return buffer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static string _ConvertJNodeToMcString(JsonNode? jsonNode)
|
|||
|
|
{
|
|||
|
|
if (jsonNode is null) return string.Empty;
|
|||
|
|
StringBuilder result = new();
|
|||
|
|
Stack<JsonNode> stack = new();
|
|||
|
|
stack.Push(jsonNode);
|
|||
|
|
|
|||
|
|
while (stack.Count > 0)
|
|||
|
|
{
|
|||
|
|
var current = stack.Pop();
|
|||
|
|
|
|||
|
|
switch (current.GetValueKind())
|
|||
|
|
{
|
|||
|
|
// 处理对象
|
|||
|
|
case JsonValueKind.Object:
|
|||
|
|
{
|
|||
|
|
var obj = current.AsObject();
|
|||
|
|
// LogWrapper.Debug("McPing",$"Treat {obj} as JObject");
|
|||
|
|
// 检查并处理 extra 数组
|
|||
|
|
if (obj.TryGetPropertyValue("extra", out var extraNode) && extraNode is JsonArray extraArray)
|
|||
|
|
// 逆序压栈保证原始顺序
|
|||
|
|
for (var i = extraArray.Count - 1; i >= 0; i--)
|
|||
|
|
if (extraArray[i] is not null)
|
|||
|
|
stack.Push(extraArray[i]!);
|
|||
|
|
// 检查并处理 text 属性
|
|||
|
|
if (obj.TryGetPropertyValue("text", out _))
|
|||
|
|
{
|
|||
|
|
var formatCode = _GetTextStyleString(
|
|||
|
|
obj["color"]?.ToString() ?? string.Empty,
|
|||
|
|
Convert.ToBoolean(obj["bold"]?.ToString() ?? "false"),
|
|||
|
|
Convert.ToBoolean(obj["obfuscated"]?.ToString() ?? "false"),
|
|||
|
|
Convert.ToBoolean(obj["strikethrough"]?.ToString() ?? "false"),
|
|||
|
|
Convert.ToBoolean(obj["underline"]?.ToString() ?? "false"),
|
|||
|
|
Convert.ToBoolean(obj["italic"]?.ToString() ?? "false")
|
|||
|
|
);
|
|||
|
|
result.Append($"{formatCode}{obj["text"] ?? string.Empty}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
// 处理字符串值
|
|||
|
|
case JsonValueKind.String:
|
|||
|
|
{
|
|||
|
|
// LogWrapper.Debug("McPing",$"Treat {value} as JValue");
|
|||
|
|
result.Append(current);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
// 处理数组
|
|||
|
|
// 逆序压栈保证原始顺序
|
|||
|
|
case JsonValueKind.Array:
|
|||
|
|
{
|
|||
|
|
var jArr = current.AsArray();
|
|||
|
|
// LogWrapper.Debug("McPing",$"Treat {array} as JArray");
|
|||
|
|
for (var i = jArr.Count - 1; i >= 0; i--)
|
|||
|
|
if (jArr[i] is not null)
|
|||
|
|
stack.Push(jArr[i]!);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
default:
|
|||
|
|
{
|
|||
|
|
LogWrapper.Warn(ModuleName, $"解析到无法处理的 Motd 内容({current.GetValueKind()}):{current}");
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
LogWrapper.Debug(ModuleName, $"处理 Motd 内容完成,结果:{result}");
|
|||
|
|
return result.ToString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static readonly Dictionary<string, string> _ColorMap = new()
|
|||
|
|
{
|
|||
|
|
["black"] = "0",
|
|||
|
|
["dark_blue"] = "1",
|
|||
|
|
["dark_green"] = "2",
|
|||
|
|
["dark_aqua"] = "3",
|
|||
|
|
["dark_red"] = "4",
|
|||
|
|
["dark_purple"] = "5",
|
|||
|
|
["gold"] = "6",
|
|||
|
|
["gray"] = "7",
|
|||
|
|
["dark_gray"] = "8",
|
|||
|
|
["blue"] = "9",
|
|||
|
|
["green"] = "a",
|
|||
|
|
["aqua"] = "b",
|
|||
|
|
["red"] = "c",
|
|||
|
|
["light_purple"] = "d",
|
|||
|
|
["yellow"] = "e",
|
|||
|
|
["white"] = "f"
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
private static string _GetTextStyleString(
|
|||
|
|
string color,
|
|||
|
|
bool bold = false,
|
|||
|
|
bool obfuscated = false,
|
|||
|
|
bool strikethrough = false,
|
|||
|
|
bool underline = false,
|
|||
|
|
bool italic = false)
|
|||
|
|
{
|
|||
|
|
var sb = new StringBuilder();
|
|||
|
|
if (_ColorMap.TryGetValue(color, out var colorCode)) sb.Append($"§{colorCode}");
|
|||
|
|
if (bold) sb.Append("§l");
|
|||
|
|
if (italic) sb.Append("§o");
|
|||
|
|
// if (obfuscated) sb.Append("§k"); // 暂时别用
|
|||
|
|
if (underline) sb.Append("§n");
|
|||
|
|
if (strikethrough) sb.Append("§m");
|
|||
|
|
if (color.StartsWith('#')) sb.Append(color);
|
|||
|
|
return sb.ToString();
|
|||
|
|
}
|
|||
|
|
}
|