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) {