From e01717d8326a4f5060b335523590c5fda943fe03 Mon Sep 17 00:00:00 2001 From: liuclare Date: Mon, 10 Aug 2026 15:09:04 +0800 Subject: [PATCH] Narrow the TUN self-address drop rule to single addresses (#9935) The rule added in #9897 takes the TUN inbound's `address` verbatim as `ip_cidr`, so a /30 or /126 interface prefix becomes the match range. sing-tun derives the TUN's DNS entry from the address right after the interface's own and hands it to the system resolver: Windows through luid.SetDNS in tun_windows.go, Linux through systemd-resolved in tun_linux.go, both guarded only by AutoRoute && !EXP_DisableDNSHijack. HasNextAddress keeps that address inside the interface prefix, every preset in Global.TunIPv4Address is a /30 and every IPv6 preset a /126, and the sing-box system stack rejects single-address prefixes, so there is no configuration where it falls outside. Queries from the system resolver then hit the drop rule and time out with no response and no ICMP. Name resolution fails for the whole system while the proxy path itself stays healthy, which makes it read as a DNS outage rather than a routing rule. Reported in #9934 and #9926. Matching each address on its own keeps what #9897 set out to block - the loop it diagnosed was addressed to the interface address itself - and leaves the DNS entry to sing-box. Also restores the two regression tests #9897 came with, removed by eff58459 (#9817) while its implementation and template fix stayed in place. ShouldRejectTrafficToTunOwnAddresses now asserts the single-address form and additionally pins the prefix length, so it covers both the loop it was written for and the resolver address it must not cover. Verified on Linux by running sing-box directly from a generated config, changing only this rule's prefix length between runs: ip_cidr ["172.18.0.1/30"] getent hosts www.google.com -> empty, 3/3 ip_cidr ["172.18.0.1/32"] getent hosts www.google.com -> resolved, 3/3 dig against a public resolver, naked-IP HTTPS and the local mixed port were unaffected in both runs. End to end, a build of this branch emits drop ip_cidr ["172.18.0.1/32"] and system resolution works while its TUN is up. Co-authored-by: liuclare <177657698+liuclare@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .../Singbox/CoreConfigSingboxServiceTests.cs | 79 +++++++++++++++++++ .../Singbox/SingboxRoutingService.cs | 16 +++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs index 1b19e2c3..f1692f7c 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/Singbox/CoreConfigSingboxServiceTests.cs @@ -56,6 +56,85 @@ public class CoreConfigSingboxServiceTests cfg.inbounds.Should().Contain(i => i.type == "tun"); } + [Fact] + public void GenerateClientConfigContent_TunEnabled_ShouldKeepEmbeddedTunRules() + { + // The embedded tun rules reject local-network noise (NetBIOS/mDNS, multicast). + // They are deserialized into List, so a schema mismatch in the + // embedded template makes JsonUtils.Deserialize return null and silently + // drops every one of them. + var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); + config.TunModeItem.EnableTun = true; + CoreConfigTestFactory.BindAppManagerConfig(config); + + var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box); + var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with + { + IsTunEnabled = true, + }; + + var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); + + result.Success.Should().BeTrue($"ret msg: {result.Msg}"); + var cfg = JsonUtils.Deserialize(result.Data!.ToString())!; + + cfg.route.rules.Should().Contain( + r => r.action == "reject" + && r.network != null && r.network.Contains("udp") + && r.port != null && r.port.Contains(5353), + "the embedded tun rules must reject mDNS/NetBIOS noise"); + cfg.route.rules.Should().Contain( + r => r.action == "reject" + && r.ip_cidr != null && r.ip_cidr.Contains("224.0.0.0/3"), + "the embedded tun rules must reject multicast traffic"); + } + + [Fact] + public void GenerateClientConfigContent_TunEnabled_ShouldRejectTrafficToTunOwnAddresses() + { + // Regression test: traffic addressed to the TUN interface's own addresses must + // never reach an outbound. auto_route hijacks the default route, so `direct` + // writes such a packet straight back into the TUN, which routes it to the + // outbound again - an infinite loop that pins a CPU core. Observed in the wild + // with WebRTC ICE connectivity checks against the TUN's own fc00::/7 ULA + // address, sustaining ~8k packets/s out of the TUN interface. + var config = CoreConfigTestFactory.CreateConfig(ECoreType.sing_box); + config.TunModeItem.EnableTun = true; + config.TunModeItem.EnableIPv6Address = true; + CoreConfigTestFactory.BindAppManagerConfig(config); + + var node = CoreConfigTestFactory.CreateVmessNode(ECoreType.sing_box); + var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.sing_box) with + { + IsTunEnabled = true, + }; + + var result = new CoreConfigSingboxService(context).GenerateClientConfigContent(); + + result.Success.Should().BeTrue($"ret msg: {result.Msg}"); + var cfg = JsonUtils.Deserialize(result.Data!.ToString())!; + var tun = cfg.inbounds.First(i => i.type == "tun"); + tun.address.Should().NotBeNullOrEmpty(); + + foreach (var address in tun.address!) + { + var self = IPAddress.Parse(address.Split('/').First()); + var hostBits = self.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32; + var expected = $"{self}/{hostBits}"; + cfg.route.rules.Should().Contain( + r => r.action == "reject" && r.ip_cidr != null && r.ip_cidr.Contains(expected), + $"traffic to the TUN's own address '{address}' must be rejected, not routed"); + } + + // The match has to stay on the addresses themselves. sing-tun derives the TUN's DNS + // entry from the address right after the interface's own, and every prefix offered + // here leaves room for it, so a prefix match would drop system name lookups too. + var dropRule = cfg.route.rules.First(r => + r.action == "reject" && r.method == "drop" && r.ip_cidr?.Count > 0); + dropRule.ip_cidr!.Should().OnlyContain(c => + c.EndsWith("/32", StringComparison.Ordinal) || c.EndsWith("/128", StringComparison.Ordinal)); + } + [Fact] public void GenerateClientConfigContent_BindInterface_ShouldUseDialBindInterface() { diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs index 2a0e2dc2..aae1a65c 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs @@ -48,12 +48,18 @@ public partial class CoreConfigSingboxService // packet straight back into the TUN, which hands it to the outbound again - // an infinite loop that pins a CPU core. Drop instead of rejecting so no // ICMP unreachable is generated back towards the same addresses. + // + // Match each address on its own, not the prefix it carries. On Linux sing-tun + // registers Inet4Address[0].Addr().Next() with systemd-resolved as a "~." DNS + // upstream, and every prefix offered here is a /30 or /126, so carrying the + // prefix through would cover that resolver address too and drop every system + // name lookup along with the loop. var tunAddresses = _coreConfig.inbounds.FirstOrDefault(i => i.type == "tun")?.address; if (tunAddresses?.Count > 0) { _coreConfig.route.rules.Add(new() { - ip_cidr = [.. tunAddresses], + ip_cidr = [.. tunAddresses.Select(ToSingleAddressPrefix)], action = "reject", method = "drop", }); @@ -284,6 +290,14 @@ public partial class CoreConfigSingboxService } } + private static string ToSingleAddressPrefix(string address) + { + var addr = address.Split('/').First(); + return IPAddress.TryParse(addr, out var ip) + ? $"{addr}/{(ip.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32)}" + : address; + } + private List BuildRoutingDirectExe() { var directExeSet = new HashSet(StringComparer.OrdinalIgnoreCase);