From 2be63d1655bb9bd5a319b2c064a3a766e367a575 Mon Sep 17 00:00:00 2001 From: Mangoo Date: Sat, 22 Aug 2026 09:49:27 +0300 Subject: [PATCH] fix(tun): automatically exclude proxy node IPs and resolved domains from TUN routing to prevent loops (#9974) (#10012) --- .../Context/CoreConfigContextBuilderTests.cs | 106 +++++++++++++++++- .../Builder/CoreConfigContextBuilder.cs | 79 +++++++++++-- 2 files changed, 173 insertions(+), 12 deletions(-) diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/Context/CoreConfigContextBuilderTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/Context/CoreConfigContextBuilderTests.cs index 73688f67..65b47de2 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/Context/CoreConfigContextBuilderTests.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/Context/CoreConfigContextBuilderTests.cs @@ -83,6 +83,96 @@ public class CoreConfigContextBuilderTests await groupA.GetProtocolExtra().ChildItems.Should().BeEqualTo(leaf.IndexId); } + [Test] + public async Task Build_WhenTunEnabled_ShouldAutoExcludeProxyServerAddress() + { + var config = CoreConfigTestFactory.CreateConfig(); + config.TunModeItem.EnableTun = true; + config.TunModeItem.RouteExcludeAddress = ["10.0.0.0/8"]; + CoreConfigTestFactory.BindAppManagerConfig(config); + + var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "vmess-node"); + node.Address = "1.2.3.4"; + await UpsertProfilesAsync(node); + + var result = await CoreConfigContextBuilder.Build(config, node); + + await result.Success.Should().BeTrue(); + await result.Context.AppConfig.TunModeItem.RouteExcludeAddress.Should().Contain("10.0.0.0/8"); + await result.Context.AppConfig.TunModeItem.RouteExcludeAddress.Should().Contain("1.2.3.4/32"); + } + + [Test] + public async Task Build_WhenTunEnabled_ShouldAutoExcludeProxyServerAddress_IPv6WithBrackets() + { + var config = CoreConfigTestFactory.CreateConfig(); + config.TunModeItem.EnableTun = true; + CoreConfigTestFactory.BindAppManagerConfig(config); + + var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "vmess-v6"); + node.Address = "[2001:db8::1]"; + await UpsertProfilesAsync(node); + + var result = await CoreConfigContextBuilder.Build(config, node); + + await result.Success.Should().BeTrue(); + await result.Context.AppConfig.TunModeItem.RouteExcludeAddress.Should().Contain("2001:db8::1/128"); + } + + [Test] + public async Task Build_WhenTunEnabled_ShouldNotExcludeLoopbackAddress() + { + var config = CoreConfigTestFactory.CreateConfig(); + config.TunModeItem.EnableTun = true; + CoreConfigTestFactory.BindAppManagerConfig(config); + + var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "vmess-loopback"); + node.Address = "127.0.0.1"; + await UpsertProfilesAsync(node); + + var result = await CoreConfigContextBuilder.Build(config, node); + + await result.Success.Should().BeTrue(); + await result.Context.AppConfig.TunModeItem.RouteExcludeAddress.Should().NotContain("127.0.0.1/32"); + await result.Context.AppConfig.TunModeItem.RouteExcludeAddress.Should().NotContain("127.0.0.1"); + } + + [Test] + public async Task Build_WhenTunEnabled_ShouldNotExcludeAnyOrNoneAddress() + { + var config = CoreConfigTestFactory.CreateConfig(); + config.TunModeItem.EnableTun = true; + CoreConfigTestFactory.BindAppManagerConfig(config); + + var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "vmess-any"); + node.Address = "0.0.0.0"; + await UpsertProfilesAsync(node); + + var result = await CoreConfigContextBuilder.Build(config, node); + + await result.Success.Should().BeTrue(); + await result.Context.AppConfig.TunModeItem.RouteExcludeAddress.Should().NotContain("0.0.0.0/32"); + } + + [Test] + public async Task Build_WhenTunEnabled_WithDomainNode_ShouldExcludeResolvedIPs() + { + var config = CoreConfigTestFactory.CreateConfig(); + config.TunModeItem.EnableTun = true; + CoreConfigTestFactory.BindAppManagerConfig(config); + + var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "vmess-domain"); + node.Address = "one.one.one.one"; + await UpsertProfilesAsync(node); + + var result = await CoreConfigContextBuilder.Build(config, node); + + await result.Success.Should().BeTrue(); + await result.Context.AppConfig.TunModeItem.RouteExcludeAddress.Should().NotBeNull(); + await result.Context.AppConfig.TunModeItem.RouteExcludeAddress!.Count.Should().BeGreaterThan(0); + await result.Context.AppConfig.TunModeItem.RouteExcludeAddress.Should().Contain(x => x.StartsWith("1.1.1.1") || x.StartsWith("1.0.0.1") || x.Contains(':')); + } + private static string NewId(string prefix) { return $"{prefix}-{Guid.NewGuid():N}"; @@ -96,12 +186,22 @@ public class CoreConfigContextBuilderTests || message.Contains("циклическую зависимость", StringComparison.OrdinalIgnoreCase); } + private static readonly SemaphoreSlim _dbLock = new(1, 1); + private static async Task UpsertProfilesAsync(params ProfileItem[] profiles) { - SQLiteHelper.Instance.CreateTable(); - foreach (var profile in profiles) + await _dbLock.WaitAsync(); + try { - await SQLiteHelper.Instance.ReplaceAsync(profile); + SQLiteHelper.Instance.CreateTable(); + foreach (var profile in profiles) + { + await SQLiteHelper.Instance.ReplaceAsync(profile); + } + } + finally + { + _dbLock.Release(); } } } diff --git a/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs b/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs index 349aee00..987d1945 100644 --- a/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs +++ b/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs @@ -96,22 +96,74 @@ public class CoreConfigContextBuilder } } - if (context.IsTunEnabled && context.AppConfig.TunModeItem.RouteExcludeAddress is { Count: > 0 }) + if (context.IsTunEnabled) { var appConfig = JsonUtils.DeepCopy(config); var routeExcludeAddressList = new List(); - foreach (var addr in context.AppConfig.TunModeItem.RouteExcludeAddress) + if (context.AppConfig.TunModeItem.RouteExcludeAddress is { Count: > 0 }) { - try + foreach (var addr in context.AppConfig.TunModeItem.RouteExcludeAddress) { - IPNetwork2.Parse(addr); - routeExcludeAddressList.Add(addr); - } - catch - { - validatorResult.Warnings.Add(string.Format(ResUI.MsgTunRouteExcludeInvalidAddress, addr)); + try + { + IPNetwork2.Parse(addr); + routeExcludeAddressList.Add(addr); + } + catch + { + validatorResult.Warnings.Add(string.Format(ResUI.MsgTunRouteExcludeInvalidAddress, addr)); + } } } + + // Exclude proxy server IP addresses from TUN to prevent routing loops + var allNodes = context.AllProxiesMap.Values + .Append(context.Node) + .Where(n => n != null && !n.ConfigType.IsGroupType() && n.ConfigType != EConfigType.Outbound); + + var uniqueAddresses = allNodes + .Select(n => n.Address?.Trim().Trim('[', ']')) + .Where(a => !string.IsNullOrEmpty(a)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var pAddr in uniqueAddresses) + { + if (IPAddress.TryParse(pAddr, out var ipAddr)) + { + if (IsValidExcludableIP(ipAddr)) + { + var cidr = ipAddr.AddressFamily == AddressFamily.InterNetworkV6 ? $"{ipAddr}/128" : $"{ipAddr}/32"; + if (!routeExcludeAddressList.Contains(cidr)) + { + routeExcludeAddressList.Add(cidr); + } + } + } + else if (Utils.IsDomain(pAddr)) + { + try + { + using var cts = new CancellationTokenSource(2000); + var addresses = await Dns.GetHostAddressesAsync(pAddr, cts.Token); + foreach (var addr in addresses) + { + if (IsValidExcludableIP(addr)) + { + var cidr = addr.AddressFamily == AddressFamily.InterNetworkV6 ? $"{addr}/128" : $"{addr}/32"; + if (!routeExcludeAddressList.Contains(cidr)) + { + routeExcludeAddressList.Add(cidr); + } + } + } + } + catch + { + // Ignore DNS resolution failures at config build time + } + } + } + appConfig.TunModeItem.RouteExcludeAddress = routeExcludeAddressList; context = context with { AppConfig = appConfig }; } @@ -505,4 +557,13 @@ public class CoreConfigContextBuilder context.AllProxiesMap[node.IndexId] = node; return childNodeValidatorResult; } + + private static bool IsValidExcludableIP(IPAddress ipAddr) + { + return !IPAddress.IsLoopback(ipAddr) + && !ipAddr.Equals(IPAddress.Any) + && !ipAddr.Equals(IPAddress.IPv6Any) + && !ipAddr.Equals(IPAddress.None) + && !ipAddr.Equals(IPAddress.IPv6None); + } }