From 64aad8027a890763c6ffc516e07db4d5f2e8e347 Mon Sep 17 00:00:00 2001 From: liuclare Date: Wed, 2 Sep 2026 11:17:27 +0800 Subject: [PATCH] Skip the TUN IPv6 route when the host has no global IPv6 address (#10080) #9930 made the Xray TUN inbound always request ::/0 in autoSystemRoutingTable so that IPv6 stops bypassing the tunnel. That only helps a host which actually holds a globally routable IPv6 address. On any other host it does harm. With IPv6 disabled the TUN device gets no IPv6 address at all, the kernel rejects the route with EACCES and the whole inbound fails to start: Failed to start: app/proxyman/inbound: failed to start proxy > proxy/tun: failed to add system route ::/0 > permission denied With IPv6 enabled but no global address the route is accepted and the host gains an IPv6 default route it cannot use. The TUN completes the TCP handshake locally before dialing the outbound, so IPv6 destinations start to look reachable and get picked, and the connection then dies at the outbound instead of failing fast (#10051). Neither host has IPv6 traffic that could bypass the tunnel, so ::/0 buys them nothing. Detect a global IPv6 address once while building the config context and drop ::/0 when there is none. Link-local and unique local addresses do not count: they never reach the IPv6 internet. Co-authored-by: liuclare <177657698+liuclare@users.noreply.github.com> Co-authored-by: Claude Opus 5 --- .../CoreConfig/CoreConfigTestFactory.cs | 4 +- .../V2ray/CoreConfigV2rayServiceTests.cs | 47 +++++++++++++++++++ v2rayN/ServiceLib/Common/Utils.cs | 34 ++++++++++++++ .../Builder/CoreConfigContextBuilder.cs | 1 + .../Models/CoreConfigs/CoreConfigContext.cs | 4 ++ .../CoreConfig/V2ray/V2rayInboundService.cs | 10 +++- 6 files changed, 97 insertions(+), 3 deletions(-) diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs b/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs index cb901252..53c5f4f1 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/CoreConfigTestFactory.cs @@ -205,7 +205,8 @@ internal static class CoreConfigTestFactory return node; } - public static CoreConfigContext CreateContext(Config config, ProfileItem node, ECoreType runCoreType) + public static CoreConfigContext CreateContext(Config config, ProfileItem node, ECoreType runCoreType, + bool hasGlobalIPv6Address = true) { return new CoreConfigContext { @@ -226,6 +227,7 @@ internal static class CoreConfigTestFactory FullConfigTemplate = null, IsTunEnabled = config.TunModeItem.EnableTun, ProtectDomainList = [], + HasGlobalIPv6Address = hasGlobalIPv6Address, }; } diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs index ed7154be..7d8e05b1 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs @@ -588,6 +588,53 @@ public class CoreConfigV2rayServiceTests await tunInbound.settings.gateway.Should().HaveCount(enableIPv6Address ? 2 : 1); } + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task GenerateClientConfigContent_Tun_ShouldSkipIPv6RouteWithoutGlobalIPv6(bool enableIPv6Address) + { + // A host without a global IPv6 address has no IPv6 traffic that could bypass the tunnel, + // while ::/0 would pull IPv6 attempts into a tunnel they cannot leave. + var config = CoreConfigTestFactory.CreateConfigWithTun(ECoreType.Xray, enableIPv6Address); + CoreConfigTestFactory.BindAppManagerConfig(config); + + var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main"); + var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray, hasGlobalIPv6Address: false); + + var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); + + await result.Success.Should().BeTrue(); + var cfg = JsonUtils.Deserialize(result.Data!.ToString())!; + var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun"); + + await tunInbound.Should().NotBeNull(); + await tunInbound!.settings.autoSystemRoutingTable.Should().Contain("0.0.0.0/0"); + var ipv6Routes = tunInbound.settings.autoSystemRoutingTable!.Where(x => x.Contains(':')).ToList(); + await ipv6Routes.Should().BeEmpty(); + } + + [Test] + public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldSkipIPv6RangesWithoutGlobalIPv6() + { + var config = CoreConfigTestFactory.CreateConfigWithTunRouteExcludeAddress(ECoreType.Xray); + config.TunModeItem.EnableIPv6Address = false; + CoreConfigTestFactory.BindAppManagerConfig(config); + + var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.Xray, "n-main", "main"); + var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray, hasGlobalIPv6Address: false); + + var result = new CoreConfigV2rayService(context).GenerateClientConfigContent(); + + await result.Success.Should().BeTrue(); + var cfg = JsonUtils.Deserialize(result.Data!.ToString())!; + var tunInbound = cfg.inbounds.FirstOrDefault(i => i.protocol == "tun"); + + await tunInbound.Should().NotBeNull(); + await tunInbound!.settings.autoSystemRoutingTable.Should().NotBeEmpty(); + var ipv6Routes = tunInbound.settings.autoSystemRoutingTable!.Where(x => x.Contains(':')).ToList(); + await ipv6Routes.Should().BeEmpty(); + } + [Test] public async Task GenerateClientConfigContent_TunRouteExcludeAddress_ShouldIncludeIPv6Ranges() { diff --git a/v2rayN/ServiceLib/Common/Utils.cs b/v2rayN/ServiceLib/Common/Utils.cs index f0d46fa9..2a59f380 100644 --- a/v2rayN/ServiceLib/Common/Utils.cs +++ b/v2rayN/ServiceLib/Common/Utils.cs @@ -815,6 +815,40 @@ public class Utils .Any(ni => ni.Name.Equals(inInterfaceName, StringComparison.OrdinalIgnoreCase)); } + /// + /// Whether the host holds a globally routable IPv6 address, that is one inside 2000::/3. + /// Link-local and unique local addresses are excluded: they never reach the IPv6 internet, + /// so a host holding only those has no IPv6 traffic that could bypass the tunnel, and no + /// IPv6 path that traffic sent into the tunnel could come back out of. + /// + public static bool HasGlobalIPv6Address() + { + try + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(ni => ni.OperationalStatus == OperationalStatus.Up + && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .SelectMany(ni => ni.GetIPProperties().UnicastAddresses) + .Any(ua => IsGlobalUnicastIPv6(ua.Address)); + } + catch + { + return true; + } + } + + private static bool IsGlobalUnicastIPv6(IPAddress address) + { + if (address.AddressFamily != AddressFamily.InterNetworkV6) + { + return false; + } + + // 2000::/3 is the only range currently assigned for global unicast, which leaves out + // ::1, fe80::/10, fc00::/7 and ff00::/8 in a single test. + return (address.GetAddressBytes()[0] & 0xE0) == 0x20; + } + #endregion Speed Test #region Miscellaneous diff --git a/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs b/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs index 349aee00..3f563685 100644 --- a/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs +++ b/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs @@ -49,6 +49,7 @@ public class CoreConfigContextBuilder RoutingItem = await ConfigHandler.GetDefaultRouting(config), IsWindows = Utils.IsWindows(), IsMacOS = Utils.IsMacOS(), + HasGlobalIPv6Address = Utils.HasGlobalIPv6Address(), ProtectCoreTypeList = config.TunModeItem.EnableTun ? [ECoreType.Xray, ECoreType.sing_box] : [] }; var validatorResult = NodeValidatorResult.Empty(); diff --git a/v2rayN/ServiceLib/Models/CoreConfigs/CoreConfigContext.cs b/v2rayN/ServiceLib/Models/CoreConfigs/CoreConfigContext.cs index dde919f8..6d8b340c 100644 --- a/v2rayN/ServiceLib/Models/CoreConfigs/CoreConfigContext.cs +++ b/v2rayN/ServiceLib/Models/CoreConfigs/CoreConfigContext.cs @@ -25,6 +25,10 @@ public record CoreConfigContext public bool IsWindows { get; init; } public bool IsMacOS { get; init; } + // Defaults to true so that a context built without this flag keeps routing IPv6 into the + // tunnel; only a positive detection of the host having no global IPv6 address turns it off. + public bool HasGlobalIPv6Address { get; init; } = true; + // Generation Context public Dictionary CustomOutboundMap { get; init; } = new(); } diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs index 8af0258a..296bf379 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs @@ -70,7 +70,11 @@ public partial class CoreConfigV2rayService // Route both families into the tunnel regardless of EnableIPv6Address. That option only // controls whether the interface gets an IPv6 address; leaving ::/0 out of the routing // table makes IPv6 follow the system default route and bypass the tunnel entirely. - tunInbound.settings.autoSystemRoutingTable = ["0.0.0.0/0", "::/0"]; + // A host without a global IPv6 address is the exception: it has nothing to leak, + // and IPv6 sent into the tunnel would have no way back out. + tunInbound.settings.autoSystemRoutingTable = context.HasGlobalIPv6Address + ? ["0.0.0.0/0", "::/0"] + : ["0.0.0.0/0"]; if (_config.TunModeItem.EnableIPv6Address == true) { var address6 = _config.TunModeItem.IPv6Address.NullIfEmpty() ?? Global.TunIPv6Address.First(); @@ -95,7 +99,9 @@ public partial class CoreConfigV2rayService .Where(x => x != null).ToList(); var includeList = new List { wholeInternet }; - var includeListV6 = new List { wholeInternetV6 }; + var includeListV6 = context.HasGlobalIPv6Address + ? new List { wholeInternetV6 } + : new List(); foreach (var exclude in excludeList) {