From 968beb44324cc0dd592895f26d6f33819690f01f Mon Sep 17 00:00:00 2001 From: Lyu Bingrong <129352855+YOIMIYA66@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:53:06 -0800 Subject: [PATCH] feat(core): support AUTO option and filter non-IP NDIS interfaces for BindInterface (#10519) * feat(core): support AUTO detection and manual selection for BindInterface (#10519) - Implements the 3-state design agreed in #10519: 1. Empty: do not bind interface, preserve existing behavior. 2. AUTO: automatically detect default active physical network interface and bind to it. 3. Specific interface: prioritize user-selected network interface. - Centralizes interface resolution in NetworkUtils.GetRealBindInterfaceName(). - Supports AUTO in CoreConfigContextBuilder, Singbox, and V2ray template services. - Adds comprehensive unit tests covering AUTO detection, context validation, and config generation. * refactor: optimize AUTO bindInterface resolution and add mock-based tests - Resolve AUTO interface once in CoreConfigContextBuilder and store in CoreConfigContext.ResolvedBindInterface as single-source-of-truth - Downstream sing-box and Xray outbound/TUN services consume ResolvedBindInterface directly - Unify TrimEx and case-insensitive check via NetworkUtils.IsAuto to tolerate user input whitespace - Log and issue validator warning when AUTO fails to resolve any physical adapter - Extract pure SelectDefaultInterface function with NetworkInterfaceCandidate for deterministic, mock-based unit testing - Add comprehensive unit tests covering Ethernet vs Wi-Fi preference, no-gateway fallback, and virtual adapter filtering * refactor(network): filter non-IP NDIS interfaces and simplify AUTO bindInterface resolution * fix(ui,network): align BindInterface ComboBox width and refine physical NIC matching * Update CoreConfigContextBuilder.cs --------- Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com> --- v2rayN/ServiceLib/Common/NetworkUtils.cs | 112 ++++++++++++++++-- v2rayN/ServiceLib/Global.cs | 1 + .../Builder/CoreConfigContextBuilder.cs | 25 ++-- .../Singbox/SingboxConfigTemplateService.cs | 2 +- .../V2ray/V2rayConfigTemplateService.cs | 2 +- .../CoreConfig/V2ray/V2rayInboundService.cs | 2 +- .../Views/OptionSettingWindow.axaml | 2 +- .../Views/OptionSettingWindow.axaml.cs | 2 +- .../v2rayN/Views/OptionSettingWindow.xaml.cs | 2 +- 9 files changed, 129 insertions(+), 21 deletions(-) diff --git a/v2rayN/ServiceLib/Common/NetworkUtils.cs b/v2rayN/ServiceLib/Common/NetworkUtils.cs index 209455f4..dd32e0c8 100644 --- a/v2rayN/ServiceLib/Common/NetworkUtils.cs +++ b/v2rayN/ServiceLib/Common/NetworkUtils.cs @@ -11,16 +11,19 @@ public static class NetworkUtils NetworkInterfaceType.FastEthernetT, ]; - private static readonly string[] _allowedPhysicalNameKeywords = + private static readonly string[] _allowedPhysicalNamePrefixes = [ - // Windows / Generic - "ethernet", "wlan", "wi-fi", "wifi", - // Linux "eth", "enp", "ens", "eno", "enx", "wlp", "wlan", // macOS - "en0", "en1", "en2", "airport", + "en0", "en1", "en2", + ]; + + private static readonly string[] _allowedPhysicalNameKeywords = + [ + // Windows / macOS / Generic + "ethernet", "wlan", "wi-fi", "wifi", "airport", // Chinese "以太网", "无线", "局域网" @@ -31,14 +34,74 @@ public static class NetworkUtils // Generic "virtual", "loopback", "loop back", - // Common virtual adapters + // Common virtual / VPN / tunnel adapters "hyper-v", "vmware", "virtualbox", "vbox", "docker", "veth", "virbr", "cni", "flannel", "cali", "kube", "wintun", "wireguard", "zerotier", "tailscale", "utun", + "tap-windows", "tap adapter", "openvpn", "anyconnect", "vpn", "clash", "mihomo", "xray_tun", + + // Windows NDIS filter sub-layers & pseudo-interfaces + "wan miniport", "lightweight filter", "filter driver", "packet scheduler", "npcap", "kernel debug", // Chinese "虚拟", "回环", "环回" ]; + public static bool IsAutoBindInterface(string? bindInterface) + { + return string.Equals(bindInterface?.TrimEx(), Global.BindInterfaceAuto, StringComparison.OrdinalIgnoreCase); + } + + public static List GetBindInterfaceNames() + { + return GetLocalNetworkInterfaceNames().Prepend(Global.BindInterfaceAuto).AppendEmpty(); + } + + public static string? GetRealBindInterfaceName(string? bindInterface) + { + var trimmed = bindInterface?.TrimEx(); + if (trimmed.IsNullOrEmpty()) + { + return null; + } + + return IsAutoBindInterface(trimmed) ? GetDefaultPhysicalInterfaceName() : trimmed; + } + + /// + /// Resolves a default physical outbound interface using a heuristic (active IP-enabled physical NICs + /// with an IPv4 gateway, preferring Ethernet over Wi-Fi). This does not inspect OS routing metrics; + /// in multi-NIC environments with custom routing tables, specify the target interface name manually. + /// + public static string? GetDefaultPhysicalInterfaceName() + { + try + { + var upPhysicalNics = NetworkInterface.GetAllNetworkInterfaces() + .Where(IsAllowedPhysicalInterface) + .ToList(); + + if (upPhysicalNics.Count == 0) + { + return null; + } + + var gatewayNics = upPhysicalNics.Where(HasIPv4Gateway).ToList(); + var candidates = gatewayNics.Count > 0 ? gatewayNics : upPhysicalNics; + + var bestNic = candidates.FirstOrDefault(ni => + ni.NetworkInterfaceType is NetworkInterfaceType.Ethernet or NetworkInterfaceType.GigabitEthernet) + ?? candidates.FirstOrDefault(ni => ni.NetworkInterfaceType == NetworkInterfaceType.Wireless80211) + ?? candidates.FirstOrDefault(); + + return bestNic?.Name; + } + catch (Exception ex) + { + Logging.SaveLog("GetDefaultPhysicalInterfaceName", ex); + return null; + } + } + public static List GetLocalNetworkInterfaceNames() { try @@ -51,15 +114,16 @@ public static class NetworkUtils .OrderBy(x => x) .ToList(); } - catch + catch (Exception ex) { + Logging.SaveLog("GetLocalNetworkInterfaceNames", ex); return []; } } private static bool IsAllowedPhysicalInterface(NetworkInterface ni) { - if (ni.OperationalStatus != OperationalStatus.Up) + if (ni.OperationalStatus != OperationalStatus.Up || !SupportsIP(ni)) { return false; } @@ -75,6 +139,38 @@ public static class NetworkUtils return true; } + if (_allowedPhysicalNamePrefixes.Any(prefix => ni.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + return _allowedPhysicalNameKeywords.Any(keyword => interfaceText.Contains(keyword, StringComparison.OrdinalIgnoreCase)); } + + private static bool SupportsIP(NetworkInterface ni) + { + try + { + return ni.Supports(NetworkInterfaceComponent.IPv4) || ni.Supports(NetworkInterfaceComponent.IPv6); + } + catch + { + return false; + } + } + + private static bool HasIPv4Gateway(NetworkInterface ni) + { + try + { + return ni.GetIPProperties().GatewayAddresses.Any(g => + g.Address.AddressFamily == AddressFamily.InterNetwork + && !IPAddress.Any.Equals(g.Address) + && !IPAddress.None.Equals(g.Address)); + } + catch + { + return false; + } + } } diff --git a/v2rayN/ServiceLib/Global.cs b/v2rayN/ServiceLib/Global.cs index 3808f57b..d72ae827 100644 --- a/v2rayN/ServiceLib/Global.cs +++ b/v2rayN/ServiceLib/Global.cs @@ -48,6 +48,7 @@ public class Global public const string MozillaRootProvider = "mozilla"; public const string DefaultSecurity = "auto"; + public const string BindInterfaceAuto = "AUTO"; public const string DefaultNetwork = "raw"; public const string RawHeaderHttp = "http"; public const string None = "none"; diff --git a/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs b/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs index abce5de6..5d4e8a5c 100644 --- a/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs +++ b/v2rayN/ServiceLib/Handler/Builder/CoreConfigContextBuilder.cs @@ -121,17 +121,28 @@ public class CoreConfigContextBuilder && !Utils.IsLocalIP(context.AppConfig.CoreBasicItem.SendThrough)) { validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsSendThrough)); - var appConfig = JsonUtils.DeepCopy(config); + var appConfig = JsonUtils.DeepCopy(context.AppConfig); appConfig.CoreBasicItem.SendThrough = string.Empty; context = context with { AppConfig = appConfig }; } - if (!context.AppConfig.CoreBasicItem.BindInterface.IsNullOrEmpty() - && (!Utils.ContainsInterfaceName(context.AppConfig.CoreBasicItem.BindInterface) - || !(context.IsTunEnabled || context.IsWindows))) + var bindInterface = context.AppConfig.CoreBasicItem.BindInterface?.TrimEx(); + if (bindInterface.IsNotEmpty()) { - validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsBindInterface)); - var appConfig = JsonUtils.DeepCopy(config); - appConfig.CoreBasicItem.BindInterface = string.Empty; + if (NetworkUtils.IsAutoBindInterface(bindInterface)) + { + bindInterface = NetworkUtils.GetDefaultPhysicalInterfaceName()?.TrimEx(); + } + + if (bindInterface.IsNullOrEmpty() + || !Utils.ContainsInterfaceName(bindInterface) + || !(context.IsTunEnabled || context.IsWindows)) + { + validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsBindInterface)); + bindInterface = string.Empty; + } + + var appConfig = JsonUtils.DeepCopy(context.AppConfig); + appConfig.CoreBasicItem.BindInterface = bindInterface; context = context with { AppConfig = appConfig }; } diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxConfigTemplateService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxConfigTemplateService.cs index df4117f8..a5d9a2fe 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxConfigTemplateService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxConfigTemplateService.cs @@ -145,7 +145,7 @@ public partial class CoreConfigSingboxService private void ApplyOutboundBindInterface() { - var bindInterface = _config.CoreBasicItem.BindInterface?.TrimEx(); + var bindInterface = NetworkUtils.GetRealBindInterfaceName(_config.CoreBasicItem.BindInterface); if (bindInterface.IsNullOrEmpty()) { return; diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayConfigTemplateService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayConfigTemplateService.cs index 50dfe001..9b45a697 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayConfigTemplateService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayConfigTemplateService.cs @@ -224,7 +224,7 @@ public partial class CoreConfigV2rayService private void ApplyOutboundBindInterface() { - var bindInterface = _config.CoreBasicItem.BindInterface?.TrimEx(); + var bindInterface = NetworkUtils.GetRealBindInterfaceName(_config.CoreBasicItem.BindInterface); if (bindInterface.IsNullOrEmpty()) { return; diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs index 0da0c29c..0c89e731 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs @@ -79,7 +79,7 @@ public partial class CoreConfigV2rayService tunInbound.settings.gateway.Add(address6); } - var bindInterface = _config.CoreBasicItem.BindInterface?.TrimEx(); + var bindInterface = NetworkUtils.GetRealBindInterfaceName(_config.CoreBasicItem.BindInterface); if (!bindInterface.IsNullOrEmpty()) { tunInbound.settings.autoOutboundsInterface = bindInterface; diff --git a/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml b/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml index 0217f67a..240fd4b5 100644 --- a/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml @@ -444,7 +444,7 @@ x:Name="cmbbindInterface" Grid.Row="22" Grid.Column="1" - Width="300" + Width="200" Margin="{StaticResource Margin4}" IsEditable="True" /> cmbRoutingRulesSourceUrl.ItemsSource = Global.RoutingRulesSources; cmbIPAPIUrl.ItemsSource = Global.IPAPIUrls; cmbRootCertificateProvider.ItemsSource = Global.RootCertProviders; - cmbbindInterface.ItemsSource = NetworkUtils.GetLocalNetworkInterfaceNames().AppendEmpty(); + cmbbindInterface.ItemsSource = NetworkUtils.GetBindInterfaceNames(); cmbMainGirdOrientation.ItemsSource = Utils.GetEnumNames(); diff --git a/v2rayN/v2rayN/Views/OptionSettingWindow.xaml.cs b/v2rayN/v2rayN/Views/OptionSettingWindow.xaml.cs index c01e2895..b8d05abb 100644 --- a/v2rayN/v2rayN/Views/OptionSettingWindow.xaml.cs +++ b/v2rayN/v2rayN/Views/OptionSettingWindow.xaml.cs @@ -56,7 +56,7 @@ public partial class OptionSettingWindow cmbRoutingRulesSourceUrl.ItemsSource = Global.RoutingRulesSources; cmbIPAPIUrl.ItemsSource = Global.IPAPIUrls; cmbRootCertificateProvider.ItemsSource = Global.RootCertProviders; - txtbindInterface.ItemsSource = NetworkUtils.GetLocalNetworkInterfaceNames().AppendEmpty(); + txtbindInterface.ItemsSource = NetworkUtils.GetBindInterfaceNames(); cmbMainGirdOrientation.ItemsSource = Utils.GetEnumNames();