389 lines
15 KiB
C#
389 lines
15 KiB
C#
using System;
|
||||
|
|
using System.Collections.Generic;
|
|||
|
|
using System.Globalization;
|
|||
|
|
using System.Linq;
|
|||
|
|
using System.Net;
|
|||
|
|
using System.Net.Sockets;
|
|||
|
|
using System.Text.RegularExpressions;
|
|||
|
|
using System.Threading;
|
|||
|
|
using System.Threading.Tasks;
|
|||
|
|
using Ae.Dns.Protocol.Enums;
|
|||
|
|
using Ae.Dns.Protocol.Records;
|
|||
|
|
using PCL.Core.IO.Net.Dns;
|
|||
|
|
using PCL.Core.Logging;
|
|||
|
|
using PCL.Core.Utils;
|
|||
|
|
|
|||
|
|
namespace PCL.Core.Minecraft;
|
|||
|
|
|
|||
|
|
public static class ServerAddressResolver
|
|||
|
|
{
|
|||
|
|
public readonly record struct ResolvedServerAddress(string Host, string? Ip, int Port);
|
|||
|
|
|
|||
|
|
// Minecraft Java 默认端口
|
|||
|
|
private const int DefaultPort = 25565;
|
|||
|
|
// Happy Eyeballs 族间启动间隔(降低首包时延)
|
|||
|
|
private static readonly TimeSpan _HappyEyeballsStagger = TimeSpan.FromMilliseconds(250);
|
|||
|
|
// 单次 TCP 连接超时
|
|||
|
|
private static readonly TimeSpan _ConnectTimeout = TimeSpan.FromSeconds(2.5);
|
|||
|
|
|
|||
|
|
// [IPv6]:port 或 [IPv6]
|
|||
|
|
private static readonly Regex _BracketedIpv6 =
|
|||
|
|
new(@"^\[(?<ip>.+?)\](?::(?<port>\d{1,5}))?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|||
|
|
|
|||
|
|
// 纯端口(用于 host:port 末尾匹配)
|
|||
|
|
private static readonly Regex _TrailingPort =
|
|||
|
|
new(@":(?<port>\d{1,5})$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|||
|
|
|
|||
|
|
public static async Task<ResolvedServerAddress> GetResolvedServerAddressAsync(string address, CancellationToken cancelToken = default)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrWhiteSpace(address))
|
|||
|
|
throw new ArgumentException("服务器地址不能为空", nameof(address));
|
|||
|
|
|
|||
|
|
// 规范化:去除 scheme、空白、尾随斜杠
|
|||
|
|
address = _NormalizeInput(address);
|
|||
|
|
|
|||
|
|
// 1) 解析出 host/ip 与端口(若端口未提供则为 null)
|
|||
|
|
var (hostOrIp, portOpt) = _ParseHostAndPort(address);
|
|||
|
|
|
|||
|
|
// 2) 显式端口 => 禁止 SRV,直接解析 IP 并尝试连接
|
|||
|
|
if (portOpt is { } explicitPort)
|
|||
|
|
{
|
|||
|
|
_ValidatePort(explicitPort);
|
|||
|
|
LogWrapper.Info($"使用显式端口,跳过 SRV:{hostOrIp}:{explicitPort}");
|
|||
|
|
var target = await _ResolveReachableAsync(hostOrIp, explicitPort, cancelToken).ConfigureAwait(false);
|
|||
|
|
if (target is not null)
|
|||
|
|
return new ResolvedServerAddress(hostOrIp, target.Value.Ip, target.Value.Port);
|
|||
|
|
|
|||
|
|
// 回退策略:无法连接则仍返回解析到的首个 IP
|
|||
|
|
var fallbackIp = await _ResolveFirstIpAsync(hostOrIp, cancelToken).ConfigureAwait(false);
|
|||
|
|
return new ResolvedServerAddress(hostOrIp, fallbackIp, explicitPort);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3) 未指定端口
|
|||
|
|
// 3.1 纯 IP(IPv4/IPv6)=> 直接使用默认端口
|
|||
|
|
if (IPAddress.TryParse(hostOrIp, out _))
|
|||
|
|
{
|
|||
|
|
var target = await _ResolveReachableAsync(hostOrIp, DefaultPort, cancelToken).ConfigureAwait(false);
|
|||
|
|
if (target is not null)
|
|||
|
|
return new ResolvedServerAddress(hostOrIp, target.Value.Ip, target.Value.Port);
|
|||
|
|
|
|||
|
|
return new ResolvedServerAddress(hostOrIp, hostOrIp, DefaultPort);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3.2 域名 => 先尝试 SRV(_minecraft._tcp.),成功则按 SRV 顺序与加权尝试
|
|||
|
|
var idnHost = _ToAsciiIdn(hostOrIp);
|
|||
|
|
var srvOrdered = await _QuerySrvOrderedAsync(idnHost, cancelToken).ConfigureAwait(false);
|
|||
|
|
|
|||
|
|
if (srvOrdered.Count > 0)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Info($"SRV 记录可用({srvOrdered.Count}): _minecraft._tcp.{idnHost}");
|
|||
|
|
foreach (var srv in srvOrdered)
|
|||
|
|
{
|
|||
|
|
var targetHost = _TrimTrailingDot(srv.Target);
|
|||
|
|
var port = srv.Port;
|
|||
|
|
|
|||
|
|
var reachable = await _ResolveReachableAsync(targetHost, port, cancelToken).ConfigureAwait(false);
|
|||
|
|
if (reachable is not null)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Info($"SRV 命中:{targetHost}:{port} -> {reachable.Value.Ip}:{port}");
|
|||
|
|
return new ResolvedServerAddress(idnHost, reachable.Value.Ip, reachable.Value.Port);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SRV 全部不可达则回退到 SRV 第一条的解析 IP 或域名默认端口
|
|||
|
|
var first = srvOrdered[0];
|
|||
|
|
var firstIp = await _ResolveFirstIpAsync(_TrimTrailingDot(first.Target), cancelToken).ConfigureAwait(false);
|
|||
|
|
if (!string.IsNullOrEmpty(firstIp)) return new ResolvedServerAddress(idnHost, firstIp, first.Port);
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
LogWrapper.Info($"无 SRV 记录或查询失败,回退默认端口:{idnHost}:{DefaultPort}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3.3 最终回退:域名 + 默认端口
|
|||
|
|
var ip = await _ResolveFirstIpAsync(idnHost, cancelToken).ConfigureAwait(false);
|
|||
|
|
return new ResolvedServerAddress(idnHost, ip, DefaultPort);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 规范化地址输入:去掉 scheme、空白、尾随 '/'
|
|||
|
|
private static string _NormalizeInput(string input)
|
|||
|
|
{
|
|||
|
|
var s = input.Trim();
|
|||
|
|
|
|||
|
|
// 去掉任意 scheme:// 前缀(例如 http://、https://、minecraft://)
|
|||
|
|
var schemeIdx = s.IndexOf("://", StringComparison.Ordinal);
|
|||
|
|
if (schemeIdx > 0)
|
|||
|
|
s = s[(schemeIdx + 3)..];
|
|||
|
|
|
|||
|
|
// 去掉尾随的 '/'
|
|||
|
|
while (s.EndsWith("/", StringComparison.Ordinal))
|
|||
|
|
s = s[..^1];
|
|||
|
|
|
|||
|
|
return s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static void _ValidatePort(int port)
|
|||
|
|
{
|
|||
|
|
if (port is < 1 or > 65535)
|
|||
|
|
throw new FormatException($"无效的端口:{port}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static string _ToAsciiIdn(string host)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
// 处理国际化域名
|
|||
|
|
var idn = new IdnMapping();
|
|||
|
|
// 允许末尾点号(FQDN)
|
|||
|
|
var h = _TrimTrailingDot(host);
|
|||
|
|
return idn.GetAscii(h) + (host.EndsWith(".", StringComparison.Ordinal) ? "." : "");
|
|||
|
|
}
|
|||
|
|
catch
|
|||
|
|
{
|
|||
|
|
// 无法转换时返回原值,交给后续 DNS 解析处理
|
|||
|
|
return host;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static string _TrimTrailingDot(string host)
|
|||
|
|
=> host.EndsWith(".", StringComparison.Ordinal) ? host[..^1] : host;
|
|||
|
|
|
|||
|
|
private static (string HostOrIp, int? Port) _ParseHostAndPort(string input)
|
|||
|
|
{
|
|||
|
|
// 1) [IPv6] 或 [IPv6]:port
|
|||
|
|
var m = _BracketedIpv6.Match(input);
|
|||
|
|
if (m.Success)
|
|||
|
|
{
|
|||
|
|
var ip = m.Groups["ip"].Value;
|
|||
|
|
if (!IPAddress.TryParse(ip, out _))
|
|||
|
|
throw new FormatException("无效的 IPv6 地址格式");
|
|||
|
|
var portGroup = m.Groups["port"];
|
|||
|
|
if (portGroup.Success)
|
|||
|
|
{
|
|||
|
|
var port = int.Parse(portGroup.Value, CultureInfo.InvariantCulture);
|
|||
|
|
_ValidatePort(port);
|
|||
|
|
return (ip, port);
|
|||
|
|
}
|
|||
|
|
return (ip, null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2) 试图解析为纯 IP(IPv4 或 IPv6 无端口)
|
|||
|
|
if (IPAddress.TryParse(input, out _))
|
|||
|
|
return (input, null);
|
|||
|
|
|
|||
|
|
// 3) host:port(仅在末尾存在且为纯数字端口时成立)
|
|||
|
|
var pm = _TrailingPort.Match(input);
|
|||
|
|
if (pm.Success)
|
|||
|
|
{
|
|||
|
|
// 防止误把 IPv6 当作 host:port(IPv6 必须用中括号携带端口)
|
|||
|
|
// 此处 input 中若包含多个 ':' 则极可能是 IPv6 而非 host:port
|
|||
|
|
var colonCount = input.Count(c => c == ':');
|
|||
|
|
if (colonCount == 1)
|
|||
|
|
{
|
|||
|
|
var port = int.Parse(pm.Groups["port"].Value, CultureInfo.InvariantCulture);
|
|||
|
|
_ValidatePort(port);
|
|||
|
|
var host = input[..^pm.Value.Length];
|
|||
|
|
if (string.IsNullOrWhiteSpace(host))
|
|||
|
|
throw new FormatException("无效的主机名");
|
|||
|
|
return (host, port);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4) 其余情况按“域名(无端口)”处理
|
|||
|
|
return (input, null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== SRV 查询与排序(RFC 2782) =====
|
|||
|
|
|
|||
|
|
private sealed record SrvRecord(int Priority, int Weight, int Port, string Target);
|
|||
|
|
|
|||
|
|
private static async Task<List<SrvRecord>> _QuerySrvOrderedAsync(string domain, CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
var name = $"_minecraft._tcp.{_TrimTrailingDot(domain)}";
|
|||
|
|
LogWrapper.Info($"尝试 SRV 查询:{name}");
|
|||
|
|
|
|||
|
|
// NDnsQuery.GetSrvRecords 返回 string 列表,为兼容不同实现,这里进行鲁棒解析
|
|||
|
|
var raw = await DnsQuery.Instance.QueryAsync(name, DnsQueryType.SRV, ct);
|
|||
|
|
if (raw is null || raw.Answers.Count == 0) return [];
|
|||
|
|
List<SrvRecord> parsed = [];
|
|||
|
|
foreach (var answer in raw.Answers)
|
|||
|
|
{
|
|||
|
|
if (answer.Resource is not DnsUnknownResource dnsRaw) return [];
|
|||
|
|
var srcRecord = new DnsSrvResource();
|
|||
|
|
var offset = 0;
|
|||
|
|
srcRecord.ReadBytes(dnsRaw.Raw, ref offset, dnsRaw.Raw.Length);
|
|||
|
|
parsed.Add(new SrvRecord(srcRecord.Priority, srcRecord.Weight, srcRecord.Port, srcRecord.Target));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 过滤 target 为 "."(表示服务不可用)
|
|||
|
|
parsed.RemoveAll(p => p.Target == ".");
|
|||
|
|
|
|||
|
|
if (parsed.Count == 0)
|
|||
|
|
return [];
|
|||
|
|
|
|||
|
|
// RFC 2782:按 priority 升序;相同 priority 内按权重加权随机选择顺序
|
|||
|
|
var ordered = new List<SrvRecord>(parsed.Count);
|
|||
|
|
foreach (var group in parsed.GroupBy(p => p.Priority).OrderBy(g => g.Key))
|
|||
|
|
{
|
|||
|
|
var pool = group.ToList();
|
|||
|
|
while (pool.Count > 0)
|
|||
|
|
{
|
|||
|
|
var next = _PopByWeight(pool);
|
|||
|
|
ordered.Add(next);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return ordered;
|
|||
|
|
}
|
|||
|
|
catch (SocketException ex)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Warn(ex, "SRV 查询失败(网络错误)");
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Warn(ex, "SRV 查询异常");
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static SrvRecord _PopByWeight(List<SrvRecord> pool)
|
|||
|
|
{
|
|||
|
|
// RFC 2782 加权随机:在组内以 weight 为权重抽取
|
|||
|
|
var total = pool.Sum(p => p.Weight);
|
|||
|
|
if (total <= 0)
|
|||
|
|
{
|
|||
|
|
// 无权重时等概率
|
|||
|
|
var i = RandomUtils.NextInt(0, pool.Count - 1);
|
|||
|
|
var chosen = pool[i];
|
|||
|
|
pool.RemoveAt(i);
|
|||
|
|
return chosen;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var r = RandomUtils.NextInt(1, total); // (1..total)
|
|||
|
|
var sum = 0;
|
|||
|
|
for (var i = 0; i < pool.Count; i++)
|
|||
|
|
{
|
|||
|
|
sum += pool[i].Weight;
|
|||
|
|
if (sum >= r)
|
|||
|
|
{
|
|||
|
|
var chosen = pool[i];
|
|||
|
|
pool.RemoveAt(i);
|
|||
|
|
return chosen;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 理论不可达,兜底返回末尾
|
|||
|
|
var last = pool[^1];
|
|||
|
|
pool.RemoveAt(pool.Count - 1);
|
|||
|
|
return last;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ===== DNS 与连接可达性 =====
|
|||
|
|
|
|||
|
|
private static async Task<(string Ip, int Port)?> _ResolveReachableAsync(string hostOrIp, int port, CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
// 已是字面量 IP
|
|||
|
|
if (IPAddress.TryParse(hostOrIp, out var ipLiteral))
|
|||
|
|
{
|
|||
|
|
var result = await _ConnectOneAsync(ipLiteral, port, ct).ConfigureAwait(false);
|
|||
|
|
if (result.ok) return (ipLiteral.ToString(), port);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var addresses = await Dns.GetHostAddressesAsync(_TrimTrailingDot(hostOrIp), ct).ConfigureAwait(false);
|
|||
|
|
if (addresses.Length == 0)
|
|||
|
|
return null;
|
|||
|
|
|
|||
|
|
// Happy Eyeballs:分组(IPv6、IPv4),按组分阶段并行连接,取首个成功
|
|||
|
|
var v6 = addresses.Where(a => a.AddressFamily == AddressFamily.InterNetworkV6).ToArray();
|
|||
|
|
var v4 = addresses.Where(a => a.AddressFamily == AddressFamily.InterNetwork).ToArray();
|
|||
|
|
|
|||
|
|
// 第一阶段:IPv6
|
|||
|
|
var winner = await _ConnectAnyAsync(v6, port, TimeSpan.Zero, ct).ConfigureAwait(false);
|
|||
|
|
if (winner is not null)
|
|||
|
|
return (winner, port);
|
|||
|
|
|
|||
|
|
// 第二阶段:IPv4(稍作延迟以避免同时轰炸)
|
|||
|
|
winner = await _ConnectAnyAsync(v4, port, _HappyEyeballsStagger, ct).ConfigureAwait(false);
|
|||
|
|
if (winner is not null)
|
|||
|
|
return (winner, port);
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Warn(ex, $"解析或连接失败:{hostOrIp}:{port}");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task<string?> _ResolveFirstIpAsync(string hostOrIp, CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (IPAddress.TryParse(hostOrIp, out var ip))
|
|||
|
|
return ip.ToString();
|
|||
|
|
|
|||
|
|
var addresses = await Dns.GetHostAddressesAsync(_TrimTrailingDot(hostOrIp), ct).ConfigureAwait(false);
|
|||
|
|
var chosen = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetworkV6)
|
|||
|
|
?? addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork);
|
|||
|
|
return chosen?.ToString();
|
|||
|
|
}
|
|||
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|||
|
|
{
|
|||
|
|
LogWrapper.Warn(ex, $"DNS 解析失败:{hostOrIp}");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task<string?> _ConnectAnyAsync(IReadOnlyList<IPAddress> addrs, int port, TimeSpan delay, CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
if (addrs.Count == 0) return null;
|
|||
|
|
if (delay > TimeSpan.Zero)
|
|||
|
|
await Task.Delay(delay, ct).ConfigureAwait(false);
|
|||
|
|
|
|||
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|||
|
|
var tasks = new List<Task<(bool ok, string ip)>>(addrs.Count);
|
|||
|
|
tasks.AddRange(addrs.Select(ip => _ConnectOneAsync(ip, port, cts.Token)));
|
|||
|
|
|
|||
|
|
while (tasks.Count > 0)
|
|||
|
|
{
|
|||
|
|
var done = await Task.WhenAny(tasks).ConfigureAwait(false);
|
|||
|
|
tasks.Remove(done);
|
|||
|
|
var (ok, ip) = await done.ConfigureAwait(false);
|
|||
|
|
if (!ok) continue;
|
|||
|
|
// 取消其余连接尝试
|
|||
|
|
try { await cts.CancelAsync().ConfigureAwait(false); } catch { /* ignore */ }
|
|||
|
|
return ip;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task<(bool ok, string ip)> _ConnectOneAsync(IPAddress ip, int port, CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
using var sock = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
|||
|
|
sock.NoDelay = true;
|
|||
|
|
|
|||
|
|
using var timeoutCts = new CancellationTokenSource(_ConnectTimeout);
|
|||
|
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
|
|||
|
|
#if NET8_0_OR_GREATER
|
|||
|
|
await sock.ConnectAsync(new IPEndPoint(ip, port), linked.Token).ConfigureAwait(false);
|
|||
|
|
#else
|
|||
|
|
await sock.ConnectAsync(new IPEndPoint(ip, port)).WaitAsync(ConnectTimeout, linked.Token).ConfigureAwait(false);
|
|||
|
|
#endif
|
|||
|
|
return (true, ip.ToString());
|
|||
|
|
}
|
|||
|
|
catch
|
|||
|
|
{
|
|||
|
|
return (false, ip.ToString());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|