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, NetworkInterfaceType.FastEthernetT,
]; ];
private static readonly string[] _allowedPhysicalNameKeywords = private static readonly string[] _allowedPhysicalNamePrefixes =
[ [
// Windows / Generic
"ethernet", "wlan", "wi-fi", "wifi",
// Linux // Linux
"eth", "enp", "ens", "eno", "enx", "wlp", "wlan", "eth", "enp", "ens", "eno", "enx", "wlp", "wlan",
// macOS // macOS
"en0", "en1", "en2", "airport", "en0", "en1", "en2",
];
private static readonly string[] _allowedPhysicalNameKeywords =
[
// Windows / macOS / Generic
"ethernet", "wlan", "wi-fi", "wifi", "airport",
// Chinese // Chinese
"以太网", "无线", "局域网" "以太网", "无线", "局域网"
@@ -31,14 +34,74 @@ public static class NetworkUtils
// Generic // Generic
"virtual", "loopback", "loop back", "virtual", "loopback", "loop back",
// Common virtual adapters // Common virtual / VPN / tunnel adapters
"hyper-v", "vmware", "virtualbox", "vbox", "docker", "veth", "virbr", "cni", "hyper-v", "vmware", "virtualbox", "vbox", "docker", "veth", "virbr", "cni",
"flannel", "cali", "kube", "wintun", "wireguard", "zerotier", "tailscale", "utun", "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 // 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() public static List<string> GetLocalNetworkInterfaceNames()
{ {
try try
@@ -51,15 +114,16 @@ public static class NetworkUtils
.OrderBy(x => x) .OrderBy(x => x)
.ToList(); .ToList();
} }
catch catch (Exception ex)
{ {
Logging.SaveLog("GetLocalNetworkInterfaceNames", ex);
return []; return [];
} }
} }
private static bool IsAllowedPhysicalInterface(NetworkInterface ni) private static bool IsAllowedPhysicalInterface(NetworkInterface ni)
{ {
if (ni.OperationalStatus != OperationalStatus.Up) if (ni.OperationalStatus != OperationalStatus.Up || !SupportsIP(ni))
{ {
return false; return false;
} }
@@ -75,6 +139,38 @@ public static class NetworkUtils
return true; return true;
} }
if (_allowedPhysicalNamePrefixes.Any(prefix => ni.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
return _allowedPhysicalNameKeywords.Any(keyword => interfaceText.Contains(keyword, StringComparison.OrdinalIgnoreCase)); 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 MozillaRootProvider = "mozilla";
public const string DefaultSecurity = "auto"; public const string DefaultSecurity = "auto";
public const string BindInterfaceAuto = "AUTO";
public const string DefaultNetwork = "raw"; public const string DefaultNetwork = "raw";
public const string RawHeaderHttp = "http"; public const string RawHeaderHttp = "http";
public const string None = "none"; public const string None = "none";
@@ -121,17 +121,28 @@ public class CoreConfigContextBuilder
&& !Utils.IsLocalIP(context.AppConfig.CoreBasicItem.SendThrough)) && !Utils.IsLocalIP(context.AppConfig.CoreBasicItem.SendThrough))
{ {
validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsSendThrough)); 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; appConfig.CoreBasicItem.SendThrough = string.Empty;
context = context with { AppConfig = appConfig }; context = context with { AppConfig = appConfig };
} }
if (!context.AppConfig.CoreBasicItem.BindInterface.IsNullOrEmpty() var bindInterface = context.AppConfig.CoreBasicItem.BindInterface?.TrimEx();
&& (!Utils.ContainsInterfaceName(context.AppConfig.CoreBasicItem.BindInterface) if (bindInterface.IsNotEmpty())
|| !(context.IsTunEnabled || context.IsWindows)))
{ {
validatorResult.Warnings.Add(string.Format(ResUI.MsgInvalidProperty, ResUI.TbSettingsBindInterface)); if (NetworkUtils.IsAutoBindInterface(bindInterface))
var appConfig = JsonUtils.DeepCopy(config); {
appConfig.CoreBasicItem.BindInterface = string.Empty; 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 }; context = context with { AppConfig = appConfig };
} }
@@ -145,7 +145,7 @@ public partial class CoreConfigSingboxService
private void ApplyOutboundBindInterface() private void ApplyOutboundBindInterface()
{ {
var bindInterface = _config.CoreBasicItem.BindInterface?.TrimEx(); var bindInterface = NetworkUtils.GetRealBindInterfaceName(_config.CoreBasicItem.BindInterface);
if (bindInterface.IsNullOrEmpty()) if (bindInterface.IsNullOrEmpty())
{ {
return; return;
@@ -224,7 +224,7 @@ public partial class CoreConfigV2rayService
private void ApplyOutboundBindInterface() private void ApplyOutboundBindInterface()
{ {
var bindInterface = _config.CoreBasicItem.BindInterface?.TrimEx(); var bindInterface = NetworkUtils.GetRealBindInterfaceName(_config.CoreBasicItem.BindInterface);
if (bindInterface.IsNullOrEmpty()) if (bindInterface.IsNullOrEmpty())
{ {
return; return;
@@ -79,7 +79,7 @@ public partial class CoreConfigV2rayService
tunInbound.settings.gateway.Add(address6); tunInbound.settings.gateway.Add(address6);
} }
var bindInterface = _config.CoreBasicItem.BindInterface?.TrimEx(); var bindInterface = NetworkUtils.GetRealBindInterfaceName(_config.CoreBasicItem.BindInterface);
if (!bindInterface.IsNullOrEmpty()) if (!bindInterface.IsNullOrEmpty())
{ {
tunInbound.settings.autoOutboundsInterface = bindInterface; tunInbound.settings.autoOutboundsInterface = bindInterface;
@@ -444,7 +444,7 @@
x:Name="cmbbindInterface" x:Name="cmbbindInterface"
Grid.Row="22" Grid.Row="22"
Grid.Column="1" Grid.Column="1"
Width="300" Width="200"
Margin="{StaticResource Margin4}" Margin="{StaticResource Margin4}"
IsEditable="True" /> IsEditable="True" />
<TextBlock <TextBlock
@@ -60,7 +60,7 @@ public partial class OptionSettingWindow : WindowBase<OptionSettingViewModel>
cmbRoutingRulesSourceUrl.ItemsSource = Global.RoutingRulesSources; cmbRoutingRulesSourceUrl.ItemsSource = Global.RoutingRulesSources;
cmbIPAPIUrl.ItemsSource = Global.IPAPIUrls; cmbIPAPIUrl.ItemsSource = Global.IPAPIUrls;
cmbRootCertificateProvider.ItemsSource = Global.RootCertProviders; cmbRootCertificateProvider.ItemsSource = Global.RootCertProviders;
cmbbindInterface.ItemsSource = NetworkUtils.GetLocalNetworkInterfaceNames().AppendEmpty(); cmbbindInterface.ItemsSource = NetworkUtils.GetBindInterfaceNames();
cmbMainGirdOrientation.ItemsSource = Utils.GetEnumNames<EGirdOrientation>(); cmbMainGirdOrientation.ItemsSource = Utils.GetEnumNames<EGirdOrientation>();
@@ -56,7 +56,7 @@ public partial class OptionSettingWindow
cmbRoutingRulesSourceUrl.ItemsSource = Global.RoutingRulesSources; cmbRoutingRulesSourceUrl.ItemsSource = Global.RoutingRulesSources;
cmbIPAPIUrl.ItemsSource = Global.IPAPIUrls; cmbIPAPIUrl.ItemsSource = Global.IPAPIUrls;
cmbRootCertificateProvider.ItemsSource = Global.RootCertProviders; cmbRootCertificateProvider.ItemsSource = Global.RootCertProviders;
txtbindInterface.ItemsSource = NetworkUtils.GetLocalNetworkInterfaceNames().AppendEmpty(); txtbindInterface.ItemsSource = NetworkUtils.GetBindInterfaceNames();
cmbMainGirdOrientation.ItemsSource = Utils.GetEnumNames<EGirdOrientation>(); cmbMainGirdOrientation.ItemsSource = Utils.GetEnumNames<EGirdOrientation>();