diff --git a/v2rayN/ServiceLib/Enums/ESpeedActionType.cs b/v2rayN/ServiceLib/Enums/ESpeedActionType.cs index 0478bcc0..eb930134 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 } diff --git a/v2rayN/ServiceLib/Enums/EUdpTest.cs b/v2rayN/ServiceLib/Enums/EUdpTest.cs new file mode 100644 index 00000000..cb972c78 --- /dev/null +++ b/v2rayN/ServiceLib/Enums/EUdpTest.cs @@ -0,0 +1,7 @@ +namespace ServiceLib.Enums; +public enum EUdpTest +{ + NTP, + DNS, + STUN, +} diff --git a/v2rayN/ServiceLib/Models/ConfigItems.cs b/v2rayN/ServiceLib/Models/ConfigItems.cs index b9b305bb..9a641567 100644 --- a/v2rayN/ServiceLib/Models/ConfigItems.cs +++ b/v2rayN/ServiceLib/Models/ConfigItems.cs @@ -158,6 +158,8 @@ public class SpeedTestItem public string SpeedPingTestUrl { get; set; } public int MixedConcurrencyCount { get; set; } public string IPAPIUrl { get; set; } + public EUdpTest UdpTestType { get; set; } + public string UdpTestUrl { get; set; } } [Serializable] diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index fa04e98c..444d6e6a 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -1860,6 +1860,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Test Configurations UDP Delay 的本地化字符串。 + /// + public static string menuUdpTestServer { + get { + return ResourceManager.GetString("menuUdpTestServer", resourceCulture); + } + } + /// /// 查找类似 {0} Website 的本地化字符串。 /// @@ -4065,6 +4074,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 9fda034c..6f79ecd7 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx @@ -1602,4 +1602,13 @@ You can set separate rules for Routing and DNS, or select "ALL" to apply to both + + 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 49e7e8c4..c6d61764 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.hu.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.hu.resx @@ -1602,4 +1602,13 @@ Rule Type + + 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 4836f005..8fe6af31 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1602,4 +1602,13 @@ Rule Type + + 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 5fd8640f..6249bbf7 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.ru.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.ru.resx @@ -1602,4 +1602,13 @@ Rule Type + + 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 eaf55173..d4f69be5 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1599,4 +1599,13 @@ 可对 Routing 和 DNS 单独设定规则,ALL 则都生效 + + 测试配置文件 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 0ee39e31..df71de06 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx @@ -1599,4 +1599,13 @@ 可对 Routing 和 DNS 单独设定规则,ALL 则都生效 + + 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 8c31ddae..1bab8903 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs @@ -341,6 +341,11 @@ public partial class CoreConfigV2rayService(Config config) listen = Global.Loopback, port = port, protocol = EInboundProtocol.mixed.ToString(), + settings = new Inboundsettings4Ray() + { + udp = true, + auth = "noauth" + }, }; inbound.tag = inbound.protocol + inbound.port.ToString(); v2rayConfig.inbounds.Add(inbound); @@ -418,6 +423,11 @@ public partial class CoreConfigV2rayService(Config config) listen = Global.Loopback, port = port, protocol = EInboundProtocol.mixed.ToString(), + settings = new Inboundsettings4Ray() + { + udp = true, + auth = "noauth" + }, }); ret.Msg = string.Format(ResUI.SuccessfulConfiguration, ""); diff --git a/v2rayN/ServiceLib/Services/SpeedtestService.cs b/v2rayN/ServiceLib/Services/SpeedtestService.cs index b2cd6a2b..4eef233d 100644 --- a/v2rayN/ServiceLib/Services/SpeedtestService.cs +++ b/v2rayN/ServiceLib/Services/SpeedtestService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Net; using System.Net.Sockets; +using ServiceLib.Services.Udp; namespace ServiceLib.Services; @@ -49,6 +50,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; @@ -91,6 +96,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; @@ -216,6 +222,81 @@ 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 + var pageSizeNext = pageSize / 2; + if (lstFailed.Count > 0 && pageSizeNext > 0) + { + if (_lstExitLoop.Any(p => p == exitLoopKey) == false) + { + await UpdateFunc("", ResUI.SpeedtestingSkip); + return; + } + + await UpdateFunc("", string.Format(ResUI.SpeedtestingTestFailedPart, lstFailed.Count)); + + await RunUdpTestBatchAsync(lstFailed, exitLoopKey, pageSizeNext); + } + } + + 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; + } + 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); @@ -302,6 +383,27 @@ public class SpeedtestService(Config config, Func updateF }); } + private async Task DoUdpTest(ServerTestItem it) + { + UdpService udpService = _config.SpeedTestItem.UdpTestType switch + { + EUdpTest.NTP => new NtpService(), + EUdpTest.DNS => new DnsService(), + EUdpTest.STUN => new StunService(), + _ => new NtpService(), + }; + var responseTime = -1; + try + { + responseTime = (int)(await udpService.SendUdpRequestAsync(_config.SpeedTestItem.UdpTestUrl, it.Port, TimeSpan.FromSeconds(5))).TotalMilliseconds; + } + catch + { } + 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/DnsService.cs b/v2rayN/ServiceLib/Services/Udp/DnsService.cs new file mode 100644 index 00000000..c1f31b02 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/DnsService.cs @@ -0,0 +1,108 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ServiceLib.Services.Udp; +public class DnsService : UdpService +{ + private const int DnsDefaultPort = 53; + private const string DnsDefaultServer = "8.8.8.8"; // Google Public DNS + private const string QueryDomain = "www.google.com"; + + protected override byte[] BuildUdpRequestPacket() + { + // DNS Query for www.google.com A record + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + // Transaction ID (random 2 bytes) + writer.Write(BinaryPrimitives.ReverseEndianness((ushort)0x1234)); + + // Flags: Standard query (0x0100) + // QR=0 (query), Opcode=0 (standard), RD=1 (recursion desired) + writer.Write(BinaryPrimitives.ReverseEndianness((ushort)0x0100)); + + // Questions: 1 + writer.Write(BinaryPrimitives.ReverseEndianness((ushort)1)); + + // Answer RRs: 0 + writer.Write(BinaryPrimitives.ReverseEndianness((ushort)0)); + + // Authority RRs: 0 + writer.Write(BinaryPrimitives.ReverseEndianness((ushort)0)); + + // Additional RRs: 0 + writer.Write(BinaryPrimitives.ReverseEndianness((ushort)0)); + + // Question section + // Domain name: www.google.com + var labels = QueryDomain.Split('.'); + foreach (var label in labels) + { + writer.Write((byte)label.Length); + writer.Write(Encoding.ASCII.GetBytes(label)); + } + writer.Write((byte)0); // End of domain name + + // Type: A (0x0001) + writer.Write(BinaryPrimitives.ReverseEndianness((ushort)1)); + + // Class: IN (0x0001) + writer.Write(BinaryPrimitives.ReverseEndianness((ushort)1)); + + return ms.ToArray(); + } + + protected override 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; + } + } + + protected override ushort GetDefaultTargetPort() + { + return DnsDefaultPort; + } + + protected override string GetDefaultTargetHost() + { + return DnsDefaultServer; + } +} diff --git a/v2rayN/ServiceLib/Services/Udp/NtpService.cs b/v2rayN/ServiceLib/Services/Udp/NtpService.cs new file mode 100644 index 00000000..5b827706 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/NtpService.cs @@ -0,0 +1,48 @@ +using System.Buffers.Binary; + +namespace ServiceLib.Services.Udp; +public class NtpService : UdpService +{ + private const int NtpDefaultPort = 123; + private const string NtpDefaultServer = "pool.ntp.org"; + + protected override byte[] BuildUdpRequestPacket() + { + var ntpReq = new byte[48]; + ntpReq[0] = 0x23; // LI=0, VN=4, Mode=3 + return ntpReq; + } + + protected override 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; + } + } + + protected override ushort GetDefaultTargetPort() + { + return NtpDefaultPort; + } + + protected override string GetDefaultTargetHost() + { + return NtpDefaultServer; + } +} diff --git a/v2rayN/ServiceLib/Services/Udp/Socks5UdpChannel.cs b/v2rayN/ServiceLib/Services/Udp/Socks5UdpChannel.cs new file mode 100644 index 00000000..b4e9ebe2 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/Socks5UdpChannel.cs @@ -0,0 +1,422 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Text; + +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)); + var clientListenEp = (IPEndPoint)udpClient.Client.LocalEndPoint!; + + 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 clientListenIp = clientListenEp.Address; + var clientAddrForSocks = new Socks5AddressData(); + if (clientListenIp.Equals(IPAddress.Any)) + { + clientAddrForSocks.AddressType = Socks5AddressData.AddrTypeIPv4; + clientAddrForSocks.Host = "0.0.0.0"; + } + else if (clientListenIp.Equals(IPAddress.IPv6Any)) + { + clientAddrForSocks.AddressType = Socks5AddressData.AddrTypeIPv6; + clientAddrForSocks.Host = "::"; + } + else if (clientListenIp.IsIPv4MappedToIPv6) + { + clientAddrForSocks.AddressType = Socks5AddressData.AddrTypeIPv4; + clientAddrForSocks.Host = clientListenIp.MapToIPv4().ToString(); + } + else if (clientListenIp.AddressFamily == AddressFamily.InterNetwork) + { + clientAddrForSocks.AddressType = Socks5AddressData.AddrTypeIPv4; + clientAddrForSocks.Host = clientListenIp.ToString(); + } + else if (clientListenIp.AddressFamily == AddressFamily.InterNetworkV6) + { + clientAddrForSocks.AddressType = Socks5AddressData.AddrTypeIPv6; + clientAddrForSocks.Host = clientListenIp.ToString(); + } + else + { + return false; + } + clientAddrForSocks.Port = (ushort)clientListenEp.Port; + + 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/StunService.cs b/v2rayN/ServiceLib/Services/Udp/StunService.cs new file mode 100644 index 00000000..d6b0bb17 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/StunService.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; + +namespace ServiceLib.Services.Udp; +public class StunService : UdpService +{ + private const int StunDefaultPort = 3478; + private const string StunDefaultServer = "stun.voztovoice.org"; + private byte[] _transactionId; + + protected override 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; + } + + protected override 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; + } + + protected override ushort GetDefaultTargetPort() + { + return StunDefaultPort; + } + + protected override string GetDefaultTargetHost() + { + return StunDefaultServer; + } +} diff --git a/v2rayN/ServiceLib/Services/Udp/UdpService.cs b/v2rayN/ServiceLib/Services/Udp/UdpService.cs new file mode 100644 index 00000000..3c2930f9 --- /dev/null +++ b/v2rayN/ServiceLib/Services/Udp/UdpService.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; + +namespace ServiceLib.Services.Udp; +public abstract class UdpService +{ + protected abstract byte[] BuildUdpRequestPacket(); + protected abstract bool VerifyAndExtractUdpResponse(byte[] udpResponseBytes); + protected abstract ushort GetDefaultTargetPort(); + protected abstract string GetDefaultTargetHost(); + + private (string host, ushort port) ParseHostAndPort(string targetServerHost) + { + if (targetServerHost.IsNullOrEmpty()) + { + return (GetDefaultTargetHost(), 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, 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, GetDefaultTargetPort()); + } + + public async Task SendUdpRequestAsync(string targetServerHost, int socks5Port, TimeSpan operationTimeout) + { + using var cts = new CancellationTokenSource(operationTimeout); + var cancellationToken = cts.Token; + var udpRequestPacket = 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) + { + throw; + } + } + } + + if ((udpReceiveResult?.Length ?? 0) < 4 + 1 + 4 + 2) + { + throw new Exception("Received NTP response is too short."); + } + + if (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 7f446cf2..7a173827 100644 --- a/v2rayN/ServiceLib/ViewModels/OptionSettingViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/OptionSettingViewModel.cs @@ -63,6 +63,8 @@ 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 UdpTestType { get; set; } + [Reactive] public string UdpTestUrl { get; set; } [Reactive] public int MixedConcurrencyCount { get; set; } [Reactive] public bool EnableHWA { get; set; } [Reactive] public string SubConvertUrl { get; set; } @@ -182,6 +184,8 @@ public class OptionSettingViewModel : MyReactiveObject SpeedTestUrl = _config.SpeedTestItem.SpeedTestUrl; MixedConcurrencyCount = _config.SpeedTestItem.MixedConcurrencyCount; SpeedPingTestUrl = _config.SpeedTestItem.SpeedPingTestUrl; + UdpTestType = _config.SpeedTestItem.UdpTestType.ToString(); + UdpTestUrl = _config.SpeedTestItem.UdpTestUrl; EnableHWA = _config.GuiItem.EnableHWA; SubConvertUrl = _config.ConstItem.SubConvertUrl; MainGirdOrientation = (int)_config.UiItem.MainGirdOrientation; @@ -342,6 +346,8 @@ public class OptionSettingViewModel : MyReactiveObject _config.SpeedTestItem.MixedConcurrencyCount = MixedConcurrencyCount; _config.SpeedTestItem.SpeedTestUrl = SpeedTestUrl; _config.SpeedTestItem.SpeedPingTestUrl = SpeedPingTestUrl; + _config.SpeedTestItem.UdpTestType = (EUdpTest)Enum.Parse(typeof(EUdpTest), UdpTestType); + _config.SpeedTestItem.UdpTestUrl = UdpTestUrl; _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 bf9c4dbf..78c153ef 100644 --- a/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/ProfilesViewModel.cs @@ -72,6 +72,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; } @@ -200,6 +201,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 e3d225d6..df24a9f3 100644 --- a/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml @@ -340,7 +340,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; + cmbUdpTestType.ItemsSource = Utils.GetEnumNames(); cmbSubConvertUrl.ItemsSource = Global.SubConvertUrls; cmbGetFilesSourceUrl.ItemsSource = Global.GeoFilesSources; cmbSrsFilesSourceUrl.ItemsSource = Global.SingboxRulesetSources; @@ -95,6 +96,8 @@ 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.UdpTestType, v => v.cmbUdpTestType.SelectedValue).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.UdpTestUrl, v => v.txtUdpTestUrl.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 89bcbb28..cda96edf 100644 --- a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml +++ b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml @@ -112,6 +112,7 @@ + diff --git a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs index c6c16c86..ac38e4b6 100644 --- a/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs +++ b/v2rayN/v2rayN.Desktop/Views/ProfilesView.axaml.cs @@ -86,6 +86,7 @@ public partial class ProfilesView : ReactiveUserControl 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 98b90235..d714197c 100644 --- a/v2rayN/v2rayN/Views/OptionSettingWindow.xaml +++ b/v2rayN/v2rayN/Views/OptionSettingWindow.xaml @@ -552,6 +552,8 @@ + + @@ -845,14 +847,13 @@ Margin="{StaticResource Margin8}" VerticalAlignment="Center" Style="{StaticResource ToolbarTextBlock}" - Text="{x:Static resx:ResUI.TbSettingsIPAPIUrl}" /> + Text="{x:Static resx:ResUI.TbSettingsUdpTestType}" /> + + + + + + i * 5).ToList(); cmbSpeedTestUrl.ItemsSource = Global.SpeedTestUrls; cmbSpeedPingTestUrl.ItemsSource = Global.SpeedPingTestUrls; + cmbUdpTestType.ItemsSource = Utils.GetEnumNames(); cmbSubConvertUrl.ItemsSource = Global.SubConvertUrls; cmbGetFilesSourceUrl.ItemsSource = Global.GeoFilesSources; cmbSrsFilesSourceUrl.ItemsSource = Global.SingboxRulesetSources; @@ -104,6 +105,8 @@ 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.UdpTestType, v => v.cmbUdpTestType.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.UdpTestUrl, v => v.txtUdpTestUrl.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 e81ee068..2729cfe4 100644 --- a/v2rayN/v2rayN/Views/ProfilesView.xaml +++ b/v2rayN/v2rayN/Views/ProfilesView.xaml @@ -165,6 +165,10 @@ x:Name="menuRealPingServer" Height="{StaticResource MenuItemHeight}" Header="{x:Static resx:ResUI.menuRealPingServer}" /> + 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);