CI / Go Backend (push) Canceled after 0s
初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
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();
|
||
}
|
||
}
|