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 <noreply@anthropic.com>
This commit is contained in:
liuclare
2026-09-02 11:17:27 +08:00
committed by GitHub
parent 7870ae89cf
commit 64aad8027a
6 changed files with 97 additions and 3 deletions

View File

@@ -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,
};
}

View File

@@ -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<V2rayConfig>(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<V2rayConfig>(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()
{

View File

@@ -815,6 +815,40 @@ public class Utils
.Any(ni => ni.Name.Equals(inInterfaceName, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 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.
/// </summary>
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

View File

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

View File

@@ -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<object, string> CustomOutboundMap { get; init; } = new();
}

View File

@@ -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<IPNetwork2> { wholeInternet };
var includeListV6 = new List<IPNetwork2> { wholeInternetV6 };
var includeListV6 = context.HasGlobalIPv6Address
? new List<IPNetwork2> { wholeInternetV6 }
: new List<IPNetwork2>();
foreach (var exclude in excludeList)
{