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>
This commit is contained in:
Lyu Bingrong
2026-11-08 00:53:06 -08:00
committed by GitHub
parent 1e167449ea
commit 968beb4432
9 changed files with 129 additions and 21 deletions
+104 -8
View File
@@ -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<string> 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;
}
/// <summary>
/// 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.
/// </summary>
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<string> 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;
}
}
}
+1
View File
@@ -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";
@@ -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 };
}
@@ -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;
@@ -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;
@@ -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;
@@ -444,7 +444,7 @@
x:Name="cmbbindInterface"
Grid.Row="22"
Grid.Column="1"
Width="300"
Width="200"
Margin="{StaticResource Margin4}"
IsEditable="True" />
<TextBlock
@@ -60,7 +60,7 @@ public partial class OptionSettingWindow : WindowBase<OptionSettingViewModel>
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<EGirdOrientation>();
@@ -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<EGirdOrientation>();