fix(tun): automatically exclude proxy node IPs and resolved domains from TUN routing to prevent loops (#9974) (#10012)

This commit is contained in:
Mangoo
2026-08-22 09:49:27 +03:00
committed by GitHub
parent 74bd28bbd0
commit 2be63d1655
2 changed files with 173 additions and 12 deletions

View File

@@ -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<ProfileItem>();
foreach (var profile in profiles)
await _dbLock.WaitAsync();
try
{
await SQLiteHelper.Instance.ReplaceAsync(profile);
SQLiteHelper.Instance.CreateTable<ProfileItem>();
foreach (var profile in profiles)
{
await SQLiteHelper.Instance.ReplaceAsync(profile);
}
}
finally
{
_dbLock.Release();
}
}
}

View File

@@ -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<string>();
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);
}
}