From d9d15c3694f03f164909eeea421072284c7bbd71 Mon Sep 17 00:00:00 2001 From: DHR60 Date: Tue, 17 Mar 2026 11:12:31 +0800 Subject: [PATCH] UDP Test Increases UDP test timeout Pref exception Fix Add Minecraft Bedrock Edition Test --- v2rayN/ServiceLib/Enums/ESpeedActionType.cs | 1 + v2rayN/ServiceLib/Global.cs | 18 + v2rayN/ServiceLib/Handler/ConfigHandler.cs | 4 + v2rayN/ServiceLib/Models/ConfigItems.cs | 1 + v2rayN/ServiceLib/Resx/ResUI.Designer.cs | 27 ++ v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx | 9 + v2rayN/ServiceLib/Resx/ResUI.hu.resx | 9 + v2rayN/ServiceLib/Resx/ResUI.resx | 9 + v2rayN/ServiceLib/Resx/ResUI.ru.resx | 9 + v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx | 9 + v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx | 9 + .../V2ray/CoreConfigV2rayService.cs | 10 + .../ServiceLib/Services/SpeedtestService.cs | 105 +++++ .../Services/Udp/Socks5UdpChannel.cs | 414 ++++++++++++++++++ .../Services/Udp/Test/DnsService.cs | 79 ++++ .../ServiceLib/Services/Udp/Test/IUdpTest.cs | 9 + .../Services/Udp/Test/McBeService.cs | 82 ++++ .../Services/Udp/Test/NtpService.cs | 49 +++ .../Services/Udp/Test/StunService.cs | 66 +++ v2rayN/ServiceLib/Services/Udp/UdpService.cs | 159 +++++++ .../ViewModels/OptionSettingViewModel.cs | 3 + .../ViewModels/ProfilesViewModel.cs | 5 + .../Views/OptionSettingWindow.axaml | 44 +- .../Views/OptionSettingWindow.axaml.cs | 2 + .../v2rayN.Desktop/Views/ProfilesView.axaml | 1 + .../Views/ProfilesView.axaml.cs | 1 + v2rayN/v2rayN/Views/OptionSettingWindow.xaml | 46 +- .../v2rayN/Views/OptionSettingWindow.xaml.cs | 2 + v2rayN/v2rayN/Views/ProfilesView.xaml | 4 + v2rayN/v2rayN/Views/ProfilesView.xaml.cs | 1 + 30 files changed, 1158 insertions(+), 29 deletions(-) create mode 100644 v2rayN/ServiceLib/Services/Udp/Socks5UdpChannel.cs create mode 100644 v2rayN/ServiceLib/Services/Udp/Test/DnsService.cs create mode 100644 v2rayN/ServiceLib/Services/Udp/Test/IUdpTest.cs create mode 100644 v2rayN/ServiceLib/Services/Udp/Test/McBeService.cs create mode 100644 v2rayN/ServiceLib/Services/Udp/Test/NtpService.cs create mode 100644 v2rayN/ServiceLib/Services/Udp/Test/StunService.cs create mode 100644 v2rayN/ServiceLib/Services/Udp/UdpService.cs diff --git a/v2rayN/ServiceLib/Enums/ESpeedActionType.cs b/v2rayN/ServiceLib/Enums/ESpeedActionType.cs index a03aa9df..5a631734 100644 --- a/v2rayN/ServiceLib/Enums/ESpeedActionType.cs +++ b/v2rayN/ServiceLib/Enums/ESpeedActionType.cs @@ -4,6 +4,7 @@ public enum ESpeedActionType { Tcping, Realping, + UdpTest, Speedtest, Mixedtest, FastRealping diff --git a/v2rayN/ServiceLib/Global.cs b/v2rayN/ServiceLib/Global.cs index bf8dd7ab..86db88ba 100644 --- a/v2rayN/ServiceLib/Global.cs +++ b/v2rayN/ServiceLib/Global.cs @@ -634,6 +634,24 @@ public class Global @"" ]; + public static readonly List UdpTestTargets = + [ + "ntp:pool.ntp.org", + "ntp:time.google.com", + "dns:1.1.1.1", + "dns:8.8.8.8", + "dns:dns.google", + "stun:stun.voztovoice.org", + "stun:stun.cloudflare.com", + "stun:stun.l.google.com:19302", + "mcbe:pms.mc-complex.com", + "mcbe:bedrock.opblocks.com", + "mcbe:opsucht.net", + "mcbe:play.craftersmc.net", + "mcbe:mps.lemoncloud.net", + "mcbe:bedrock.talonmc.net", + ]; + public static readonly List OutboundTags = [ ProxyTag, diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index 167820f0..8ce7f135 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -133,6 +133,10 @@ public static class ConfigHandler { config.SpeedTestItem.MixedConcurrencyCount = 5; } + if (config.SpeedTestItem.UdpTestTarget.IsNullOrEmpty()) + { + config.SpeedTestItem.UdpTestTarget = Global.UdpTestTargets.First(); + } config.Mux4RayItem ??= new() { diff --git a/v2rayN/ServiceLib/Models/ConfigItems.cs b/v2rayN/ServiceLib/Models/ConfigItems.cs index 0f0e257c..6f428085 100644 --- a/v2rayN/ServiceLib/Models/ConfigItems.cs +++ b/v2rayN/ServiceLib/Models/ConfigItems.cs @@ -156,6 +156,7 @@ public class SpeedTestItem public string SpeedPingTestUrl { get; set; } public int MixedConcurrencyCount { get; set; } public string IPAPIUrl { get; set; } + public string UdpTestTarget { get; set; } } [Serializable] diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index 4498d999..968d1c5c 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -1851,6 +1851,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Test Configurations UDP Delay 的本地化字符串。 + /// + public static string menuUdpTestServer { + get { + return ResourceManager.GetString("menuUdpTestServer", resourceCulture); + } + } + /// /// 查找类似 {0} Website 的本地化字符串。 /// @@ -4338,6 +4347,24 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 UDP Test Type 的本地化字符串。 + /// + public static string TbSettingsUdpTestType { + get { + return ResourceManager.GetString("TbSettingsUdpTestType", resourceCulture); + } + } + + /// + /// 查找类似 UDP Test Url 的本地化字符串。 + /// + public static string TbSettingsUdpTestUrl { + get { + return ResourceManager.GetString("TbSettingsUdpTestUrl", resourceCulture); + } + } + /// /// 查找类似 Auth user 的本地化字符串。 /// diff --git a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx index 61ccbf7b..a5555df8 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx @@ -1698,4 +1698,13 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Legacy TUN Protect + + Test Configurations UDP Delay + + + UDP Test Type + + + UDP Test Url + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.hu.resx b/v2rayN/ServiceLib/Resx/ResUI.hu.resx index 89456f99..7bf0f111 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.hu.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.hu.resx @@ -1698,4 +1698,13 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Legacy TUN Protect + + Test Configurations UDP Delay + + + UDP Test Type + + + UDP Test Url + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index 569877d7..989de052 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1698,4 +1698,13 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Legacy TUN Protect + + Test Configurations UDP Delay + + + UDP Test Type + + + UDP Test Url + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.ru.resx b/v2rayN/ServiceLib/Resx/ResUI.ru.resx index 401c237e..fcafae0b 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.ru.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.ru.resx @@ -1698,4 +1698,13 @@ Legacy TUN Protect + + Test Configurations UDP Delay + + + UDP Test Type + + + UDP Test Url + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx index 8c628644..e823a837 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1695,4 +1695,13 @@ 旧版 TUN 保护 + + 测试 UDP 延迟 (多选) + + + UDP 测试类型 + + + UDP 测试地址 + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx index 59a9fc9f..a4197064 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx @@ -1695,4 +1695,13 @@ Legacy TUN Protect + + Test Configurations UDP Delay + + + UDP Test Type + + + UDP Test Url + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs index 71e30e45..1e0ca644 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs @@ -162,6 +162,11 @@ public partial class CoreConfigV2rayService(CoreConfigContext context) listen = Global.Loopback, port = port, protocol = EInboundProtocol.mixed.ToString(), + settings = new Inboundsettings4Ray() + { + udp = true, + auth = "noauth" + }, }; inbound.tag = inbound.protocol + inbound.port.ToString(); _coreConfig.inbounds.Add(inbound); @@ -252,6 +257,11 @@ public partial class CoreConfigV2rayService(CoreConfigContext context) listen = Global.Loopback, port = port, protocol = EInboundProtocol.mixed.ToString(), + settings = new Inboundsettings4Ray() + { + udp = true, + auth = "noauth" + }, }); _coreConfig.routing.rules.Add(BuildFinalRule()); diff --git a/v2rayN/ServiceLib/Services/SpeedtestService.cs b/v2rayN/ServiceLib/Services/SpeedtestService.cs index 362f64d0..a35c87e2 100644 --- a/v2rayN/ServiceLib/Services/SpeedtestService.cs +++ b/v2rayN/ServiceLib/Services/SpeedtestService.cs @@ -1,3 +1,5 @@ +using ServiceLib.Services.Udp; + namespace ServiceLib.Services; public class SpeedtestService(Config config, Func updateFunc) @@ -49,6 +51,10 @@ public class SpeedtestService(Config config, Func updateF await RunRealPingBatchAsync(lstSelected, exitLoopKey); break; + case ESpeedActionType.UdpTest: + await RunUdpTestBatchAsync(lstSelected, exitLoopKey); + break; + case ESpeedActionType.Speedtest: await RunMixedTestAsync(lstSelected, 1, true, exitLoopKey); break; @@ -101,6 +107,7 @@ public class SpeedtestService(Config config, Func updateF { case ESpeedActionType.Tcping: case ESpeedActionType.Realping: + case ESpeedActionType.UdpTest: await UpdateFunc(it.IndexId, ResUI.Speedtesting, ""); ProfileExManager.Instance.SetTestDelay(it.IndexId, 0); break; @@ -238,6 +245,86 @@ public class SpeedtestService(Config config, Func updateF return true; } + private async Task RunUdpTestBatchAsync(List lstSelected, string exitLoopKey, int pageSize = 0) + { + if (pageSize <= 0) + { + pageSize = lstSelected.Count < Global.SpeedTestPageSize ? lstSelected.Count : Global.SpeedTestPageSize; + } + var lstTest = GetTestBatchItem(lstSelected, pageSize); + + List lstFailed = new(); + foreach (var lst in lstTest) + { + var ret = await RunUdpTestAsync(lst, exitLoopKey); + if (ret == false) + { + lstFailed.AddRange(lst); + } + await Task.Delay(100); + } + + //Retest the failed part + if (lstFailed.Count > 0) + { + if (ShouldStopTest(exitLoopKey)) + { + await UpdateFunc("", ResUI.SpeedtestingSkip); + return; + } + + await UpdateFunc("", string.Format(ResUI.SpeedtestingTestFailedPart, lstFailed.Count)); + + await RunUdpTestAsync(lstFailed, exitLoopKey); + } + } + + private async Task RunUdpTestAsync(List selecteds, string exitLoopKey) + { + ProcessService processService = null; + try + { + processService = await CoreManager.Instance.LoadCoreConfigSpeedtest(selecteds); + if (processService is null) + { + return false; + } + await Task.Delay(1000); + + List tasks = new(); + foreach (var it in selecteds) + { + if (!it.AllowTest) + { + continue; + } + + if (ShouldStopTest(exitLoopKey)) + { + return false; + } + + tasks.Add(Task.Run(async () => + { + await DoUdpTest(it); + })); + } + await Task.WhenAll(tasks); + } + catch (Exception ex) + { + Logging.SaveLog(_tag, ex); + } + finally + { + if (processService != null) + { + await processService?.StopAsync(); + } + } + return true; + } + private async Task RunMixedTestAsync(List selecteds, int concurrencyCount, bool blSpeedTest, string exitLoopKey) { using var concurrencySemaphore = new SemaphoreSlim(concurrencyCount); @@ -330,6 +417,24 @@ public class SpeedtestService(Config config, Func updateF }); } + private async Task DoUdpTest(ServerTestItem it) + { + var udpService = UdpService.CreateFromTarget(_config?.SpeedTestItem.UdpTestTarget, out var udpTestUrl); + var responseTime = -1; + try + { + responseTime = (int)(await udpService.SendUdpRequestAsync(udpTestUrl, it.Port, TimeSpan.FromSeconds(5))).TotalMilliseconds; + } + catch + { + // ignored + } + + ProfileExManager.Instance.SetTestDelay(it.IndexId, responseTime); + await UpdateFunc(it.IndexId, responseTime.ToString()); + return responseTime; + } + private async Task GetTcpingTime(string url, int port) { var responseTime = -1; diff --git a/v2rayN/ServiceLib/Services/Udp/Socks5UdpChannel.cs b/v2rayN/ServiceLib/Services/Udp/Socks5UdpChannel.cs new file mode 100644 index 00000000..3f8a63dd --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/Socks5UdpChannel.cs @@ -0,0 +1,414 @@ +using System.Buffers.Binary; + +namespace ServiceLib.Services.Udp; + +public class Socks5UdpChannel(string socks5Host, int socks5TcpPort) : IDisposable +{ + private TcpClient tcpClient; + private UdpClient udpClient; + private IPEndPoint relayEndPoint; + + private bool _initialized = false; + + /// + /// Send UDP data to a remote endpoint (IP address) + /// + public async Task SendAsync(IPEndPoint remote, byte[] data) + { + var addrData = new Socks5AddressData + { + AddressType = remote.Address.AddressFamily == AddressFamily.InterNetwork + ? Socks5AddressData.AddrTypeIPv4 + : Socks5AddressData.AddrTypeIPv6, + Host = remote.Address.ToString(), + Port = (ushort)remote.Port + }; + var packet = BuildSocks5UdpPacket(addrData, data); + await udpClient.SendAsync(packet, packet.Length, relayEndPoint); + } + + /// + /// Send UDP data to a remote endpoint (domain name or IP address) + /// + /// Domain name or IP address + /// Port number + /// Data to send + public async Task SendAsync(string host, ushort port, byte[] data) + { + var addrData = new Socks5AddressData(); + + // Try to parse as IP address first + if (IPAddress.TryParse(host, out var ipAddr)) + { + addrData.AddressType = ipAddr.AddressFamily == AddressFamily.InterNetwork + ? Socks5AddressData.AddrTypeIPv4 + : Socks5AddressData.AddrTypeIPv6; + addrData.Host = ipAddr.ToString(); + } + else + { + // Treat as domain name + addrData.AddressType = Socks5AddressData.AddrTypeDomain; + addrData.Host = host; + } + + addrData.Port = port; + + var packet = BuildSocks5UdpPacket(addrData, data); + await udpClient.SendAsync(packet, packet.Length, relayEndPoint); + } + + /// + /// Receive UDP data from remote endpoint + /// + /// Cancellation token to cancel the receive operation + /// Remote endpoint information and received data + public async Task<(Socks5RemoteEndpoint Remote, byte[] Data)> ReceiveAsync( + CancellationToken cancellationToken = default) + { + var result = await udpClient.ReceiveAsync(cancellationToken).ConfigureAwait(false); + var (remote, payload) = ParseSocks5UdpPacket(result.Buffer); + return (remote, payload); + } + + /// + /// Represents a remote endpoint that can be either an IP address or a domain name + /// + public class Socks5RemoteEndpoint(string host, ushort port, bool isDomain) + { + public string Host { get; set; } = host; + public ushort Port { get; set; } = port; + public bool IsDomain { get; set; } = isDomain; + } + + private static byte[] BuildSocks5UdpPacket(Socks5AddressData addressData, byte[] data) + { + using var ms = new MemoryStream(); + + // RSV (2 bytes) + FRAG (1 byte) - Reserved and Fragment fields + ms.WriteByte(0x00); + ms.WriteByte(0x00); + ms.WriteByte(0x00); + + // Write address (ATYP + address + port) + ms.Write(addressData.ToBytes()); + + // User data payload + ms.Write(data); + + return ms.ToArray(); + } + + private static (Socks5RemoteEndpoint Remote, byte[] Data) ParseSocks5UdpPacket(byte[] packet) + { + if (packet.Length < 10) // Minimum length: RSV(2) + FRAG(1) + ATYP(1) + IPv4(4) + Port(2) = 10 + { + throw new ArgumentException("Invalid SOCKS5 UDP packet: too short"); + } + + var offset = 0; + + // RSV (2 bytes) - Reserved field, skip + offset += 2; + + // FRAG (1 byte) - Fragment number, currently only support 0 (no fragmentation) + var frag = packet[offset++]; + if (frag != 0x00) + { + throw new NotSupportedException("SOCKS5 UDP fragmentation is not supported"); + } + + // ATYP (1 byte) - Address type + var addressType = packet[offset++]; + + string host; + int addressLength; + bool isDomain; + + switch (addressType) + { + case Socks5AddressData.AddrTypeIPv4: + if (packet.Length < offset + 4) + { + throw new ArgumentException("Invalid SOCKS5 UDP packet: IPv4 address incomplete"); + } + + var ipv4Bytes = new byte[4]; + Array.Copy(packet, offset, ipv4Bytes, 0, 4); + host = new IPAddress(ipv4Bytes).ToString(); + addressLength = 4; + isDomain = false; + break; + + case Socks5AddressData.AddrTypeIPv6: + if (packet.Length < offset + 16) + { + throw new ArgumentException("Invalid SOCKS5 UDP packet: IPv6 address incomplete"); + } + + var ipv6Bytes = new byte[16]; + Array.Copy(packet, offset, ipv6Bytes, 0, 16); + host = new IPAddress(ipv6Bytes).ToString(); + addressLength = 16; + isDomain = false; + break; + + case Socks5AddressData.AddrTypeDomain: + if (packet.Length < offset + 1) + { + throw new ArgumentException("Invalid SOCKS5 UDP packet: domain length missing"); + } + + var domainLength = packet[offset++]; + if (packet.Length < offset + domainLength) + { + throw new ArgumentException("Invalid SOCKS5 UDP packet: domain incomplete"); + } + + host = Encoding.ASCII.GetString(packet, offset, domainLength); + addressLength = domainLength; + isDomain = true; + break; + + default: + throw new NotSupportedException($"Unsupported SOCKS5 address type: {addressType}"); + } + + offset += addressLength; + + // Port (2 bytes, big-endian) + if (packet.Length < offset + 2) + { + throw new ArgumentException("Invalid SOCKS5 UDP packet: port incomplete"); + } + + var port = BinaryPrimitives.ReadUInt16BigEndian(packet.AsSpan(offset, 2)); + offset += 2; + + // Data (remaining bytes) + var dataLength = packet.Length - offset; + var data = new byte[dataLength]; + if (dataLength > 0) + { + Array.Copy(packet, offset, data, 0, dataLength); + } + + // Create remote endpoint without DNS resolution + var remote = new Socks5RemoteEndpoint(host, port, isDomain); + return (remote, data); + } + + public void Dispose() + { + tcpClient.Dispose(); + udpClient.Dispose(); + } + + #region SOCKS5 Connection Handling + + private const byte Socks5Version = 0x05; + private const byte SocksCmdUdpAssociate = 0x03; + + public async Task EstablishUdpAssociationAsync(CancellationToken cancellationToken) + { + if (_initialized) + { + Dispose(); + _initialized = false; + } + + udpClient = new UdpClient(new IPEndPoint(IPAddress.Any, 0)); + tcpClient = new TcpClient(); + try + { + await tcpClient.ConnectAsync(socks5Host, socks5TcpPort, cancellationToken).ConfigureAwait(false); + } + catch (SocketException) + { + return false; + } + + var tcpControlStream = tcpClient.GetStream(); + + byte[] handshakeRequest = { Socks5Version, 0x01, 0x00 }; + await tcpControlStream.WriteAsync(handshakeRequest, cancellationToken).ConfigureAwait(false); + var handshakeResponse = new byte[2]; + if (await tcpControlStream.ReadAsync(handshakeResponse, cancellationToken).ConfigureAwait(false) < 2 || + handshakeResponse[0] != Socks5Version || handshakeResponse[1] != 0x00) + { + return false; + } + + var clientAddrForSocks = new Socks5AddressData + { + AddressType = Socks5AddressData.AddrTypeIPv4, Host = "0.0.0.0", Port = 0 + }; + using var udpAssociateReqMs = new MemoryStream(); + udpAssociateReqMs.WriteByte(Socks5Version); + udpAssociateReqMs.WriteByte(SocksCmdUdpAssociate); + udpAssociateReqMs.WriteByte(0x00); + udpAssociateReqMs.Write(clientAddrForSocks.ToBytes()); + await tcpControlStream.WriteAsync(udpAssociateReqMs.ToArray(), cancellationToken).ConfigureAwait(false); + + var verRepRsv = new byte[3]; + if (await tcpControlStream.ReadAsync(verRepRsv, cancellationToken).ConfigureAwait(false) < 3 || + verRepRsv[0] != Socks5Version || verRepRsv[1] != 0x00) + { + return false; + } + + var proxyRelaySocksAddr = + await Socks5AddressData.ParseAsync(tcpControlStream, cancellationToken).ConfigureAwait(false); + if (proxyRelaySocksAddr == null || !IPAddress.TryParse(proxyRelaySocksAddr.Host, out var proxyRelayIp)) + { + return false; + } + + relayEndPoint = new IPEndPoint(proxyRelayIp, proxyRelaySocksAddr.Port); + _initialized = true; + return true; + } + + #endregion + + #region SOCKS5 Address Handling + + private class Socks5AddressData + { + public const byte AddrTypeIPv4 = 0x01; + public const byte AddrTypeDomain = 0x03; + public const byte AddrTypeIPv6 = 0x04; + + public byte AddressType { get; set; } + public string Host { get; set; } = string.Empty; + public ushort Port { get; set; } + + public byte[] ToBytes() + { + using var ms = new MemoryStream(); + ms.WriteByte(AddressType); + switch (AddressType) + { + case AddrTypeIPv4: + if (IPAddress.TryParse(Host, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork) + { + ms.Write(ip.GetAddressBytes(), 0, 4); + } + else + { + ms.Write(new byte[] { 0, 0, 0, 0 }); + } + + break; + case AddrTypeDomain: + if (string.IsNullOrEmpty(Host)) + { + ms.WriteByte(0); + } + else + { + var domainBytes = Encoding.ASCII.GetBytes(Host); + ms.WriteByte((byte)domainBytes.Length); + ms.Write(domainBytes); + } + + break; + case AddrTypeIPv6: + if (IPAddress.TryParse(Host, out var ip6) && ip6.AddressFamily == AddressFamily.InterNetworkV6) + { + ms.Write(ip6.GetAddressBytes(), 0, 16); + } + else + { + ms.Write(new byte[16]); + } + + break; + default: + throw new NotSupportedException($"SOCKS5 address type {AddressType} not supported."); + } + + var portBytes = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(portBytes, Port); + ms.Write(portBytes); + return ms.ToArray(); + } + + public static async Task ParseAsync(Stream stream, CancellationToken ct) + { + var addr = new Socks5AddressData(); + var typeByte = new byte[1]; + try + { + if (await stream.ReadAsync(typeByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1) + { + return null; + } + + addr.AddressType = typeByte[0]; + switch (addr.AddressType) + { + case AddrTypeIPv4: + var ipv4Bytes = new byte[4]; + if (await stream.ReadAsync(ipv4Bytes.AsMemory(0, 4), ct).ConfigureAwait(false) < 4) + { + return null; + } + + addr.Host = new IPAddress(ipv4Bytes).ToString(); + break; + case AddrTypeDomain: + var lenByte = new byte[1]; + if (await stream.ReadAsync(lenByte.AsMemory(0, 1), ct).ConfigureAwait(false) < 1) + { + return null; + } + + if (lenByte[0] == 0) + { + addr.Host = string.Empty; + } + else + { + var domainBytes = new byte[lenByte[0]]; + if (await stream.ReadAsync(domainBytes.AsMemory(0, domainBytes.Length), ct) + .ConfigureAwait(false) < domainBytes.Length) + { + return null; + } + + addr.Host = Encoding.ASCII.GetString(domainBytes); + } + + break; + case AddrTypeIPv6: + var ipv6Bytes = new byte[16]; + if (await stream.ReadAsync(ipv6Bytes.AsMemory(0, 16), ct).ConfigureAwait(false) < 16) + { + return null; + } + + addr.Host = new IPAddress(ipv6Bytes).ToString(); + break; + default: + return null; + } + + var portBytes = new byte[2]; + if (await stream.ReadAsync(portBytes.AsMemory(0, 2), ct).ConfigureAwait(false) < 2) + { + return null; + } + + addr.Port = BinaryPrimitives.ReadUInt16BigEndian(portBytes); + return addr; + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + return null; + } + } + } + + #endregion SOCKS5 Address Handling +} diff --git a/v2rayN/ServiceLib/Services/Udp/Test/DnsService.cs b/v2rayN/ServiceLib/Services/Udp/Test/DnsService.cs new file mode 100644 index 00000000..9c9c0912 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/Test/DnsService.cs @@ -0,0 +1,79 @@ +using System.Buffers.Binary; + +namespace ServiceLib.Services.Udp.Test; + +public class DnsService : IUdpTest +{ + private const int DnsDefaultPort = 53; + private const string DnsDefaultServer = "8.8.8.8"; // Google Public DNS + private static readonly byte[] DnsQueryPacket = + new byte[] + { + // Header: ID=0x1234, Standard query with RD set, QDCOUNT=1 + 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + // Question: www.google.com, Type A, Class IN + 0x03, 0x77, 0x77, 0x77, 0x06, 0x67, 0x6F, 0x6F, + 0x67, 0x6C, 0x65, 0x03, 0x63, 0x6F, 0x6D, 0x00, + 0x00, 0x01, 0x00, 0x01, + }; + + public byte[] BuildUdpRequestPacket() + { + return (byte[])DnsQueryPacket.Clone(); + } + + public bool VerifyAndExtractUdpResponse(byte[] dnsResponseBytes) + { + if (dnsResponseBytes == null || dnsResponseBytes.Length < 12) + { + return false; + } + + try + { + // Check transaction ID (should match 0x1234) + var transactionId = BinaryPrimitives.ReadUInt16BigEndian(dnsResponseBytes.AsSpan(0, 2)); + if (transactionId != 0x1234) + { + return false; + } + + // Check flags - should be a response (QR=1) + var flags = BinaryPrimitives.ReadUInt16BigEndian(dnsResponseBytes.AsSpan(2, 2)); + if ((flags & 0x8000) == 0) + { + return false; // Not a response + } + + // Check response code (RCODE) - should be 0 (no error) + if ((flags & 0x000F) != 0) + { + return false; // DNS error + } + + // Check answer count + var answerCount = BinaryPrimitives.ReadUInt16BigEndian(dnsResponseBytes.AsSpan(6, 2)); + if (answerCount == 0) + { + return false; // No answers + } + + return true; + } + catch + { + return false; + } + } + + public ushort GetDefaultTargetPort() + { + return DnsDefaultPort; + } + + public string GetDefaultTargetHost() + { + return DnsDefaultServer; + } +} \ No newline at end of file diff --git a/v2rayN/ServiceLib/Services/Udp/Test/IUdpTest.cs b/v2rayN/ServiceLib/Services/Udp/Test/IUdpTest.cs new file mode 100644 index 00000000..79f0086e --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/Test/IUdpTest.cs @@ -0,0 +1,9 @@ +namespace ServiceLib.Services.Udp.Test; + +public interface IUdpTest +{ + public byte[] BuildUdpRequestPacket(); + public bool VerifyAndExtractUdpResponse(byte[] udpResponseBytes); + public ushort GetDefaultTargetPort(); + public string GetDefaultTargetHost(); +} \ No newline at end of file diff --git a/v2rayN/ServiceLib/Services/Udp/Test/McBeService.cs b/v2rayN/ServiceLib/Services/Udp/Test/McBeService.cs new file mode 100644 index 00000000..bde48a29 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/Test/McBeService.cs @@ -0,0 +1,82 @@ +namespace ServiceLib.Services.Udp.Test; + +public class McBeService : IUdpTest +{ + private const int McBeDefaultPort = 19132; + private const string McBeDefaultServer = "pms.mc-complex.com"; + // 0x01 | client alive time in ms (unsigned long long) | magic | client GUID + private static readonly byte[] McBeQueryPacket = + new byte[] + { + // 0x01 + 0x01, + // Client alive time (1000 ms) + 0x27, 0xC4, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, + // Magic + 0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE, + 0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78, + // Client GUID (random 16 bytes) + 0x66, 0x0E, 0xAB, 0xBC, 0x61, 0x0D, 0x1F, 0x4E, + 0xA4, 0x40, 0x8C, 0x65, 0xC1, 0xBE, 0xF5, 0x4B + }; + private static readonly byte[] McBeMagicBytes = new byte[] + { + 0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE, + 0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78 + }; + private static readonly List ValidGameModes = new List + { + "Survival", + "Creative", + "Adventure", + "Spectator" + }; + + public byte[] BuildUdpRequestPacket() + { + return (byte[])McBeQueryPacket.Clone(); + } + + public bool VerifyAndExtractUdpResponse(byte[] mcbeResponseBytes) + { + // 0x1c | client alive time in ms (recorded from previous ping) | + // server GUID | Magic | string length | Edition + // + // Edition Example: + // + // MCPE;Dedicated Server;527;1.19.1;0;10;13253860892328930865;Bedrock level;Survival;1;19132;19133; + if (mcbeResponseBytes == null || mcbeResponseBytes.Length < 48) + { + return false; + } + if (mcbeResponseBytes[0] != 0x1C) + { + return false; // Invalid packet type + } + var pongMagic = mcbeResponseBytes.Skip(17).Take(16).ToArray(); + if (!pongMagic.SequenceEqual(McBeMagicBytes)) + { + return false; // Magic bytes do not match + } + var stringLength = (ushort)((mcbeResponseBytes[33] << 8) | mcbeResponseBytes[34]); + var stringData = Encoding.UTF8.GetString(mcbeResponseBytes.Skip(35).Take(stringLength).ToArray()); + var stringParts = stringData.Split(';'); + // check Game Mode str + var gameMode = stringParts.Length > 8 ? stringParts[8] : ""; + if (!ValidGameModes.Contains(gameMode)) + { + return false; // Invalid game mode + } + return true; + } + + public ushort GetDefaultTargetPort() + { + return McBeDefaultPort; + } + + public string GetDefaultTargetHost() + { + return McBeDefaultServer; + } +} diff --git a/v2rayN/ServiceLib/Services/Udp/Test/NtpService.cs b/v2rayN/ServiceLib/Services/Udp/Test/NtpService.cs new file mode 100644 index 00000000..d5a72d75 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/Test/NtpService.cs @@ -0,0 +1,49 @@ +using System.Buffers.Binary; + +namespace ServiceLib.Services.Udp.Test; + +public class NtpService : IUdpTest +{ + private const int NtpDefaultPort = 123; + private const string NtpDefaultServer = "pool.ntp.org"; + + public byte[] BuildUdpRequestPacket() + { + var ntpReq = new byte[48]; + ntpReq[0] = 0x23; // LI=0, VN=4, Mode=3 + return ntpReq; + } + + public bool VerifyAndExtractUdpResponse(byte[] ntpResponseBytes) + { + if (ntpResponseBytes == null || ntpResponseBytes.Length < 48) + { + return false; + } + if ((ntpResponseBytes[0] & 0x07) != 4) + { + return false; + } + try + { + var secsSince1900 = BinaryPrimitives.ReadUInt32BigEndian(ntpResponseBytes.AsSpan(40, 4)); + const long ntpToUnixEpochOffsetSeconds = 2208988800L; + var unixSecs = (long)secsSince1900 - ntpToUnixEpochOffsetSeconds; + return true; + } + catch + { + return false; + } + } + + public ushort GetDefaultTargetPort() + { + return NtpDefaultPort; + } + + public string GetDefaultTargetHost() + { + return NtpDefaultServer; + } +} \ No newline at end of file diff --git a/v2rayN/ServiceLib/Services/Udp/Test/StunService.cs b/v2rayN/ServiceLib/Services/Udp/Test/StunService.cs new file mode 100644 index 00000000..890e4f70 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/Test/StunService.cs @@ -0,0 +1,66 @@ +namespace ServiceLib.Services.Udp.Test; + +public class StunService : IUdpTest +{ + private const int StunDefaultPort = 3478; + private const string StunDefaultServer = "stun.voztovoice.org"; + private byte[] _transactionId; + + public byte[] BuildUdpRequestPacket() + { + // STUN Binding Request + var packet = new byte[20]; + + // Message Type: Binding Request (0x0001) + packet[0] = 0x00; + packet[1] = 0x01; + + // Message Length: 0 (no attributes) + packet[2] = 0x00; + packet[3] = 0x00; + + // Magic Cookie: 0x2112A442 + packet[4] = 0x21; + packet[5] = 0x12; + packet[6] = 0xA4; + packet[7] = 0x42; + + // Transaction ID: 96 bits (12 bytes) random + _transactionId = new byte[12]; + RandomNumberGenerator.Fill(_transactionId); + Array.Copy(_transactionId, 0, packet, 8, 12); + + return packet; + } + + public bool VerifyAndExtractUdpResponse(byte[] stunResponseBytes) + { + if (stunResponseBytes == null || stunResponseBytes.Length < 20) + { + return false; + } + + // Message Type: Binding Success Response (0x0101) 或 Binding Error Response (0x0111) + if (stunResponseBytes.Length >= 2) + { + var messageType = (stunResponseBytes[0] << 8) | stunResponseBytes[1]; + // 0x0101 = Success Response, 0x0111 = Error Response + if (messageType is 0x0101 or 0x0111) + { + return true; + } + } + + return true; + } + + public ushort GetDefaultTargetPort() + { + return StunDefaultPort; + } + + public string GetDefaultTargetHost() + { + return StunDefaultServer; + } +} \ No newline at end of file diff --git a/v2rayN/ServiceLib/Services/Udp/UdpService.cs b/v2rayN/ServiceLib/Services/Udp/UdpService.cs new file mode 100644 index 00000000..49bbfc44 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/UdpService.cs @@ -0,0 +1,159 @@ +namespace ServiceLib.Services.Udp; + +public class UdpService +{ + private const string DefaultUdpTestType = "ntp"; + private readonly Test.IUdpTest _udpTest; + + private static readonly IReadOnlyDictionary> UdpTestFactories = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["ntp"] = () => new Test.NtpService(), + ["dns"] = () => new Test.DnsService(), + ["stun"] = () => new Test.StunService(), + ["mcbe"] = () => new Test.McBeService(), + }; + + private UdpService(Test.IUdpTest udpTest) + { + _udpTest = udpTest; + } + + public static UdpService Create(string? udpTestType) + { + if (udpTestType.IsNullOrEmpty()) + { + return new UdpService(UdpTestFactories[DefaultUdpTestType]()); + } + + return UdpTestFactories.TryGetValue(udpTestType, out var factory) + ? new UdpService(factory()) + : new UdpService(UdpTestFactories[DefaultUdpTestType]()); + } + + public static UdpService CreateFromTarget(string? udpTestTarget, out string targetServerHost) + { + var parts = udpTestTarget?.Split(':', 2); + var udpTestType = parts?.Length > 0 ? parts[0] : DefaultUdpTestType; + + var udpService = Create(udpTestType); + targetServerHost = parts?.Length > 1 && parts[1].IsNotEmpty() + ? parts[1] + : udpService._udpTest.GetDefaultTargetHost(); + + return udpService; + } + + private (string host, ushort port) ParseHostAndPort(string targetServerHost) + { + if (targetServerHost.IsNullOrEmpty()) + { + return (_udpTest.GetDefaultTargetHost(), _udpTest.GetDefaultTargetPort()); + } + + // Handle IPv6 format: [::1]:port or [2001:db8::1]:port + if (targetServerHost.StartsWith("[")) + { + var closeBracketIndex = targetServerHost.IndexOf(']'); + if (closeBracketIndex > 0) + { + var host = targetServerHost.Substring(1, closeBracketIndex - 1); + if (closeBracketIndex < targetServerHost.Length - 1 && targetServerHost[closeBracketIndex + 1] == ':') + { + var portStr = targetServerHost.Substring(closeBracketIndex + 2); + if (ushort.TryParse(portStr, out var port)) + { + return (host, port); + } + } + return (host, _udpTest.GetDefaultTargetPort()); + } + } + + // Handle IPv4 or domain format: 1.1.1.1:53 or exam.com:333 + var lastColonIndex = targetServerHost.LastIndexOf(':'); + if (lastColonIndex > 0) + { + var host = targetServerHost.Substring(0, lastColonIndex); + var portStr = targetServerHost.Substring(lastColonIndex + 1); + if (ushort.TryParse(portStr, out var port)) + { + return (host, port); + } + } + + // No port specified, use default + return (targetServerHost, _udpTest.GetDefaultTargetPort()); + } + + public async Task SendUdpRequestAsync(string targetServerHost, int socks5Port, TimeSpan operationTimeout) + { + using var cts = new CancellationTokenSource(operationTimeout); + var cancellationToken = cts.Token; + var udpRequestPacket = _udpTest.BuildUdpRequestPacket(); + if (udpRequestPacket == null || udpRequestPacket.Length == 0) + { + throw new InvalidOperationException("Failed to build UDP request packet."); + } + using var channel = new Socks5UdpChannel(Global.Loopback, socks5Port); + try + { + if (!await channel.EstablishUdpAssociationAsync(cancellationToken).ConfigureAwait(false)) + { + throw new Exception("Failed to establish UDP association with SOCKS5 proxy."); + } + + var (targetHost, targetPort) = ParseHostAndPort(targetServerHost); + + byte[] udpReceiveResult = null; + + // Get minimum round trip time from two attempts + var roundTripTime = TimeSpan.MaxValue; + + for (var attempt = 0; attempt < 2; attempt++) + { + try + { + var stopwatch = new Stopwatch(); + stopwatch.Start(); + await channel.SendAsync(targetHost, targetPort, udpRequestPacket).ConfigureAwait(false); + var (_, receiveResult) = await channel.ReceiveAsync(cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + + udpReceiveResult = receiveResult; + + var currentRoundTripTime = stopwatch.Elapsed; + if (currentRoundTripTime < roundTripTime) + { + roundTripTime = currentRoundTripTime; + } + } + catch + { + if (attempt == 1 && roundTripTime == TimeSpan.MaxValue) + { + throw; + } + } + } + + if ((udpReceiveResult?.Length ?? 0) < 4 + 1 + 4 + 2) + { + throw new Exception("Received NTP response is too short."); + } + + if (_udpTest.VerifyAndExtractUdpResponse(udpReceiveResult)) + { + return roundTripTime; + } + else + { + throw new Exception("Failed to verify and extract UDP response."); + } + } + catch + { + throw; + } + } +} diff --git a/v2rayN/ServiceLib/ViewModels/OptionSettingViewModel.cs b/v2rayN/ServiceLib/ViewModels/OptionSettingViewModel.cs index 8b677ffd..03a88ba4 100644 --- a/v2rayN/ServiceLib/ViewModels/OptionSettingViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/OptionSettingViewModel.cs @@ -58,6 +58,7 @@ public class OptionSettingViewModel : MyReactiveObject [Reactive] public int SpeedTestTimeout { get; set; } [Reactive] public string SpeedTestUrl { get; set; } [Reactive] public string SpeedPingTestUrl { get; set; } + [Reactive] public string UdpTestTarget { get; set; } [Reactive] public int MixedConcurrencyCount { get; set; } [Reactive] public bool EnableHWA { get; set; } [Reactive] public string SubConvertUrl { get; set; } @@ -193,6 +194,7 @@ public class OptionSettingViewModel : MyReactiveObject SpeedTestUrl = _config.SpeedTestItem.SpeedTestUrl; MixedConcurrencyCount = _config.SpeedTestItem.MixedConcurrencyCount; SpeedPingTestUrl = _config.SpeedTestItem.SpeedPingTestUrl; + UdpTestTarget = _config.SpeedTestItem.UdpTestTarget; EnableHWA = _config.GuiItem.EnableHWA; SubConvertUrl = _config.ConstItem.SubConvertUrl; MainGirdOrientation = (int)_config.UiItem.MainGirdOrientation; @@ -359,6 +361,7 @@ public class OptionSettingViewModel : MyReactiveObject _config.SpeedTestItem.MixedConcurrencyCount = MixedConcurrencyCount; _config.SpeedTestItem.SpeedTestUrl = SpeedTestUrl; _config.SpeedTestItem.SpeedPingTestUrl = SpeedPingTestUrl; + _config.SpeedTestItem.UdpTestTarget = UdpTestTarget; _config.GuiItem.EnableHWA = EnableHWA; _config.ConstItem.SubConvertUrl = SubConvertUrl; _config.UiItem.MainGirdOrientation = (EGirdOrientation)MainGirdOrientation; diff --git a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs index 7a127fa7..1b8b6369 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -60,6 +60,7 @@ public class ProfilesViewModel : MyReactiveObject public ReactiveCommand TcpingServerCmd { get; } public ReactiveCommand RealPingServerCmd { get; } + public ReactiveCommand UdpTestServerCmd { get; } public ReactiveCommand SpeedServerCmd { get; } public ReactiveCommand SortServerResultCmd { get; } public ReactiveCommand RemoveInvalidServerResultCmd { get; } @@ -178,6 +179,10 @@ public class ProfilesViewModel : MyReactiveObject { await ServerSpeedtest(ESpeedActionType.Realping); }, canEditRemove); + UdpTestServerCmd = ReactiveCommand.CreateFromTask(async () => + { + await ServerSpeedtest(ESpeedActionType.UdpTest); + }, canEditRemove); SpeedServerCmd = ReactiveCommand.CreateFromTask(async () => { await ServerSpeedtest(ESpeedActionType.Speedtest); diff --git a/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml b/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml index 78483180..9d1856b3 100644 --- a/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml @@ -334,7 +334,7 @@ + RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto"> + + + cmbSpeedTestTimeout.ItemsSource = Enumerable.Range(2, 5).Select(i => i * 5).ToList(); cmbSpeedTestUrl.ItemsSource = Global.SpeedTestUrls; cmbSpeedPingTestUrl.ItemsSource = Global.SpeedPingTestUrls; + cmbUdpTestTarget.ItemsSource = Global.UdpTestTargets; cmbSubConvertUrl.ItemsSource = Global.SubConvertUrls; cmbGetFilesSourceUrl.ItemsSource = Global.GeoFilesSources; cmbSrsFilesSourceUrl.ItemsSource = Global.SingboxRulesetSources; @@ -96,6 +97,7 @@ public partial class OptionSettingWindow : WindowBase this.Bind(ViewModel, vm => vm.SpeedTestTimeout, v => v.cmbSpeedTestTimeout.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SpeedTestUrl, v => v.cmbSpeedTestUrl.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SpeedPingTestUrl, v => v.cmbSpeedPingTestUrl.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.UdpTestTarget, v => v.cmbUdpTestTarget.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.MixedConcurrencyCount, v => v.cmbMixedConcurrencyCount.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SubConvertUrl, v => v.cmbSubConvertUrl.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.MainGirdOrientation, v => v.cmbMainGirdOrientation.SelectedIndex).DisposeWith(disposables); diff --git a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml index e0751ca1..42fab7c2 100644 --- a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml +++ b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml @@ -140,6 +140,7 @@ x:Name="menuSpeedServer" Header="{x:Static resx:ResUI.menuSpeedServer}" InputGesture="Ctrl+T" /> + this.BindCommand(ViewModel, vm => vm.MixedTestServerCmd, v => v.menuMixedTestServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.TcpingServerCmd, v => v.menuTcpingServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.RealPingServerCmd, v => v.menuRealPingServer).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.UdpTestServerCmd, v => v.menuUdpTestServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.SpeedServerCmd, v => v.menuSpeedServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.SortServerResultCmd, v => v.menuSortServerResult).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.RemoveInvalidServerResultCmd, v => v.menuRemoveInvalidServerResult).DisposeWith(disposables); diff --git a/v2rayN/v2rayN/Views/OptionSettingWindow.xaml b/v2rayN/v2rayN/Views/OptionSettingWindow.xaml index a3fc391e..11e1c079 100644 --- a/v2rayN/v2rayN/Views/OptionSettingWindow.xaml +++ b/v2rayN/v2rayN/Views/OptionSettingWindow.xaml @@ -545,6 +545,8 @@ + + @@ -810,10 +812,26 @@ Margin="{StaticResource Margin8}" VerticalAlignment="Center" Style="{StaticResource ToolbarTextBlock}" + Text="{x:Static resx:ResUI.TbSettingsUdpTestUrl}" /> + + + i * 5).ToList(); cmbSpeedTestUrl.ItemsSource = Global.SpeedTestUrls; cmbSpeedPingTestUrl.ItemsSource = Global.SpeedPingTestUrls; + cmbUdpTestTarget.ItemsSource = Global.UdpTestTargets; cmbSubConvertUrl.ItemsSource = Global.SubConvertUrls; cmbGetFilesSourceUrl.ItemsSource = Global.GeoFilesSources; cmbSrsFilesSourceUrl.ItemsSource = Global.SingboxRulesetSources; @@ -101,6 +102,7 @@ public partial class OptionSettingWindow this.Bind(ViewModel, vm => vm.SpeedTestTimeout, v => v.cmbSpeedTestTimeout.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SpeedTestUrl, v => v.cmbSpeedTestUrl.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SpeedPingTestUrl, v => v.cmbSpeedPingTestUrl.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.UdpTestTarget, v => v.cmbUdpTestTarget.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.MixedConcurrencyCount, v => v.cmbMixedConcurrencyCount.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.EnableHWA, v => v.togEnableHWA.IsChecked).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SubConvertUrl, v => v.cmbSubConvertUrl.Text).DisposeWith(disposables); diff --git a/v2rayN/v2rayN/Views/ProfilesView.xaml b/v2rayN/v2rayN/Views/ProfilesView.xaml index fd984306..97d3af3f 100644 --- a/v2rayN/v2rayN/Views/ProfilesView.xaml +++ b/v2rayN/v2rayN/Views/ProfilesView.xaml @@ -158,6 +158,10 @@ Height="{StaticResource MenuItemHeight}" Header="{x:Static resx:ResUI.menuRealPingServer}" InputGestureText="Ctrl+R" /> + vm.MixedTestServerCmd, v => v.menuMixedTestServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.TcpingServerCmd, v => v.menuTcpingServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.RealPingServerCmd, v => v.menuRealPingServer).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.UdpTestServerCmd, v => v.menuUdpTestServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.SpeedServerCmd, v => v.menuSpeedServer).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.SortServerResultCmd, v => v.menuSortServerResult).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.RemoveInvalidServerResultCmd, v => v.menuRemoveInvalidServerResult).DisposeWith(disposables);