diff --git a/Services/RealLatencyProbeService.cs b/Services/RealLatencyProbeService.cs
index 1a790e4..577a7e5 100644
--- a/Services/RealLatencyProbeService.cs
+++ b/Services/RealLatencyProbeService.cs
@@ -24,6 +24,23 @@ namespace XrayUI.Services
///
public sealed class RealLatencyProbeService
{
+ private readonly SettingsService _settings;
+ private readonly TunService _tunService;
+
+ ///
+ /// Live "a TUN session currently owns the default route" check, wired by the composition
+ /// root (same callback pattern as ControlPanelViewModel.GetSelectedServer). Preferred over
+ /// settings.IsTunMode, which is persisted runtime state that lags the UI toggle and can
+ /// survive a crash as a stale true.
+ ///
+ public Func IsTunActive { get; set; } = () => false;
+
+ public RealLatencyProbeService(SettingsService settings, TunService tunService)
+ {
+ _settings = settings;
+ _tunService = tunService;
+ }
+
private const string TestUrl = "http://www.gstatic.com/generate_204";
// Overall budget for one server's whole warm-up-then-measure cycle. 7s (vs v2rayN's 10s):
@@ -96,8 +113,17 @@ void Report(ServerEntry s, int ms)
foreach (var s in servers)
entries.Add((s, GetFreeLoopbackPort()));
- // 2. Build + write the dedicated multi-inbound speed-test config.
- string configJson = XrayConfigBuilder.BuildSpeedtestConfig(entries);
+ // 2. Bind the throwaway core to the physical egress while TUN owns the default
+ // route. autoOutboundsInterface belongs to the live core's TUN inbound and does not
+ // protect this second xray process, so its proxy outbounds need an explicit sockopt.
+ var settings = await _settings.LoadSettingsAsync().ConfigureAwait(false);
+ var outboundInterface = IsTunActive()
+ ? _tunService.ResolveOutboundInterface(settings.TunOutboundInterface)
+ : null;
+ Debug.WriteLine($"[RealLatencyProbe] 测速出口网卡: {outboundInterface ?? "系统默认路由"}");
+
+ // 3. Build + write the dedicated multi-inbound speed-test config.
+ string configJson = XrayConfigBuilder.BuildSpeedtestConfig(entries, outboundInterface);
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!);
await File.WriteAllTextAsync(ConfigPath, configJson, ct).ConfigureAwait(false);
@@ -107,7 +133,7 @@ void Report(ServerEntry s, int ms)
try
{
- // 3. Launch the throwaway core.
+ // 4. Launch the throwaway core.
var psi = new ProcessStartInfo
{
FileName = XrayService.ExePath,
@@ -136,7 +162,7 @@ void Capture(string? line)
process.BeginOutputReadLine();
process.BeginErrorReadLine();
- // 4. Wait until the core reports every inbound is up. An early exit falls
+ // 5. Wait until the core reports every inbound is up. An early exit falls
// through to the HasExited check below, which reads the captured log.
await readySignal.WaitAsync(CoreReadyCap, ct).ConfigureAwait(false);
@@ -152,7 +178,7 @@ void Capture(string? line)
return;
}
- // 5. Probe each server through its own socks port (capped concurrency).
+ // 6. Probe each server through its own socks port (capped concurrency).
using var throttle = new SemaphoreSlim(MaxConcurrency);
var tasks = new List(entries.Count);
foreach (var (server, port) in entries)
@@ -172,7 +198,7 @@ void Capture(string? line)
}
finally
{
- // 6. Tear down the throwaway core and its temp config.
+ // 7. Tear down the throwaway core and its temp config.
try
{
if (process is not null && !process.HasExited)
diff --git a/Services/TunService.cs b/Services/TunService.cs
index e504d60..47eee5d 100644
--- a/Services/TunService.cs
+++ b/Services/TunService.cs
@@ -3,7 +3,9 @@
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
+using System.Linq;
using System.Net;
+using System.Net.NetworkInformation;
using System.Net.Sockets;
using XrayUI.Helpers;
@@ -38,6 +40,101 @@ public bool IsWintunAvailable()
/// Gets the expected wintun.dll path for error messages.
public string GetExpectedWintunPath() => Path.Combine(_engineDirectory, "wintun.dll");
+ ///
+ /// Resolves the interface that helper cores must bind to while TUN owns the default route.
+ /// An explicitly configured interface wins when it still exists, is up and is not the TUN
+ /// adapter itself; a stale name falls back to automatic selection instead of pinning every
+ /// outbound to a dead adapter. In automatic mode, choose an active non-TUN interface with a
+ /// real default gateway; virtual host-only adapters normally have none and are therefore
+ /// ignored. Returning preserves the old process-routing fallback when
+ /// Windows exposes no usable interface.
+ ///
+ public string? ResolveOutboundInterface(string? configuredInterface)
+ {
+ var configured = XrayConfigBuilder.NormalizeTunOutboundInterface(configuredInterface);
+
+ try
+ {
+ var interfaces = NetworkInterface.GetAllNetworkInterfaces();
+
+ if (configured is not null)
+ {
+ var match = interfaces.FirstOrDefault(ni =>
+ string.Equals(ni.Name, configured, StringComparison.OrdinalIgnoreCase)
+ && ni.OperationalStatus == OperationalStatus.Up
+ && !IsTunLikeInterface(ni));
+ if (match is not null)
+ return match.Name;
+ Debug.WriteLine($"[TunService] 配置的测速出口网卡不可用,回退自动选择: {configured}");
+ }
+
+ var candidate = interfaces
+ .Where(IsAutomaticOutboundCandidate)
+ // Prefer an IPv4 gateway because the speed-test target and most node endpoints
+ // are IPv4-capable. Speed provides a deterministic tie-break for multi-NIC PCs.
+ .OrderByDescending(HasIPv4Gateway)
+ .ThenByDescending(ni => ni.Speed)
+ .ThenBy(ni => ni.Name, StringComparer.OrdinalIgnoreCase)
+ .FirstOrDefault();
+
+ if (candidate is not null)
+ return candidate.Name;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[TunService] 自动选择测速出口网卡失败: {ex.Message}");
+ // Enumeration itself failed, so the stale-name check never ran — an explicit user
+ // choice is still the best remaining guess.
+ if (configured is not null)
+ return configured;
+ }
+
+ Debug.WriteLine("[TunService] 未找到可用于测速的物理出口网卡");
+ return null;
+ }
+
+ /// Adapters that must never carry pinned helper-core traffic: loopback, tunnels,
+ /// and the xray-tun/wintun adapter itself (pinning to it recreates the proxy loop). Also used
+ /// by to keep the interface picker in sync with what
+ /// will actually accept.
+ internal static bool IsTunLikeInterface(NetworkInterface networkInterface) =>
+ networkInterface.NetworkInterfaceType is NetworkInterfaceType.Loopback
+ or NetworkInterfaceType.Tunnel
+ || string.Equals(networkInterface.Name, TunInterfaceName, StringComparison.OrdinalIgnoreCase)
+ || networkInterface.Description.Contains("Xray Tunnel", StringComparison.OrdinalIgnoreCase)
+ || networkInterface.Description.Contains("Wintun", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsAutomaticOutboundCandidate(NetworkInterface networkInterface)
+ {
+ if (networkInterface.OperationalStatus != OperationalStatus.Up
+ || IsTunLikeInterface(networkInterface))
+ {
+ return false;
+ }
+
+ // IPv6 default routers legitimately appear as link-local (fe80::) gateways via router
+ // advertisements, so they count — but only when the adapter also holds a global IPv6
+ // address. A lone fe80 gateway without one (stale RA on an isolated segment) is no
+ // evidence of an internet path and must not beat the null fallback.
+ var ipProperties = networkInterface.GetIPProperties();
+ return ipProperties.GatewayAddresses.Any(gateway =>
+ !gateway.Address.Equals(IPAddress.Any)
+ && !gateway.Address.Equals(IPAddress.IPv6Any)
+ && !IPAddress.IsLoopback(gateway.Address)
+ && (!gateway.Address.IsIPv6LinkLocal || HasGlobalIPv6Address(ipProperties)));
+ }
+
+ private static bool HasGlobalIPv6Address(IPInterfaceProperties ipProperties) =>
+ ipProperties.UnicastAddresses.Any(address =>
+ address.Address.AddressFamily == AddressFamily.InterNetworkV6
+ && !address.Address.IsIPv6LinkLocal
+ && !IPAddress.IsLoopback(address.Address));
+
+ private static bool HasIPv4Gateway(NetworkInterface networkInterface) =>
+ networkInterface.GetIPProperties().GatewayAddresses.Any(gateway =>
+ gateway.Address.AddressFamily == AddressFamily.InterNetwork
+ && !gateway.Address.Equals(IPAddress.Any));
+
///
/// Best-effort reset of stale DNS server entries that Windows can persist on the
/// xray-tun adapter between runs. Uses netsh (fast, ~10ms) so it doesn't stall
diff --git a/Services/XrayConfigBuilder.cs b/Services/XrayConfigBuilder.cs
index 5298d01..422fdf8 100644
--- a/Services/XrayConfigBuilder.cs
+++ b/Services/XrayConfigBuilder.cs
@@ -215,9 +215,7 @@ private static JsonArray BuildOutbounds(
foreach (var outbound in list.OfType())
{
var tag = outbound["tag"]?.GetValue();
- var protocol = outbound["protocol"]?.GetValue();
- if (tag is ProxyOutboundTag or DirectOutboundTag or ChainEntryOutboundTag
- && !string.Equals(protocol, "wireguard", StringComparison.OrdinalIgnoreCase))
+ if (tag is ProxyOutboundTag or DirectOutboundTag or ChainEntryOutboundTag)
{
ApplyOutboundInterface(outbound, outboundInterface);
}
@@ -227,7 +225,7 @@ private static JsonArray BuildOutbounds(
return list;
}
- private static string? NormalizeTunOutboundInterface(string? interfaceName)
+ internal static string? NormalizeTunOutboundInterface(string? interfaceName)
{
if (string.IsNullOrWhiteSpace(interfaceName))
return null;
@@ -280,6 +278,13 @@ private static void ApplyProxySettings(JsonObject outbound, string tag)
private static void ApplyOutboundInterface(JsonObject outbound, string interfaceName)
{
+ // Wireguard outbounds carry no streamSettings, so a sockopt pin cannot apply there —
+ // the process-routing rule stays their only cover. Centralized here so every caller
+ // gets the exemption without repeating it.
+ var protocol = outbound["protocol"]?.GetValue();
+ if (string.Equals(protocol, "wireguard", StringComparison.OrdinalIgnoreCase))
+ return;
+
var streamSettings = outbound["streamSettings"] as JsonObject;
if (streamSettings is null)
{
@@ -861,7 +866,9 @@ private static void AppendTunLeadRules(JsonArray rules, AppSettings settings)
{
["type"] = "field",
["outboundTag"] = DirectOutboundTag,
- ["process"] = CreateStringArray("self/", "xray/")
+ // Keep the plain process-name fallback as well as Xray's path sugars. Process
+ // attribution for a second helper core can occasionally lack the full path.
+ ["process"] = CreateStringArray("self/", "xray/", "xray")
});
}
@@ -1083,8 +1090,14 @@ private static void AddValue(JsonArray array, string value)
/// the caller must filter them out.
///
/// Each server paired with the local socks port it should listen on.
+ /// Resolved physical interface name to pin every proxy
+ /// outbound to (see ), or null for no
+ /// pin. The pin is required when another Xray process owns a full-route TUN, otherwise
+ /// this helper core's node connections can be captured and proxy-looped. Taken verbatim —
+ /// callers resolve the "auto" sentinel first.
public static string BuildSpeedtestConfig(
- IReadOnlyList<(ServerEntry server, int port)> entries)
+ IReadOnlyList<(ServerEntry server, int port)> entries,
+ string? outboundInterface)
{
var inbounds = new JsonArray();
var outbounds = new JsonArray();
@@ -1109,7 +1122,12 @@ public static string BuildSpeedtestConfig(
}
});
- AddNode(outbounds, BuildProxyOutbound(server, outTag));
+ var outbound = BuildProxyOutbound(server, outTag);
+ if (outboundInterface is not null)
+ {
+ ApplyOutboundInterface(outbound, outboundInterface);
+ }
+ AddNode(outbounds, outbound);
AddNode(rules, new JsonObject
{
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index d3c7df7..2245cd5 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -77,7 +77,7 @@ public MainViewModel(
var latencyProbe = new LatencyProbeService(
new TcpConnectProbeService(),
new PingProbeService());
- var realLatencyProbe = new RealLatencyProbeService();
+ var realLatencyProbe = new RealLatencyProbeService(settings, tunService);
var aiUnlockCheck = new AiUnlockCheckService();
Title = "Proxy Console";
@@ -94,6 +94,9 @@ public MainViewModel(
ServerDetail.GetAllServers = () => ServerList.Servers;
ServerList.RequestSwitchToSelectedServer = ControlPanel.SwitchToSelectedServerAsync;
Personalize.IsProxyRunning = () => ControlPanel.IsRunning;
+ // Live TUN state for the speed test's egress pin — settings.IsTunMode alone lags
+ // the UI toggle and can survive a crash as a stale true (see IDialogService remarks).
+ realLatencyProbe.IsTunActive = () => ControlPanel.IsRunning && ControlPanel.IsTunMode;
ServerList.PropertyChanged += OnServerListPropertyChanged;
ControlPanel.PropertyChanged += OnControlPanelPropertyChanged;
diff --git a/Views/TunConfirmationDialog.xaml.cs b/Views/TunConfirmationDialog.xaml.cs
index e212a0c..248efa4 100644
--- a/Views/TunConfirmationDialog.xaml.cs
+++ b/Views/TunConfirmationDialog.xaml.cs
@@ -84,7 +84,7 @@ private static List EnumerateActiveInterfaceNames()
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(ni => ni.OperationalStatus == OperationalStatus.Up
- && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
+ && !TunService.IsTunLikeInterface(ni))
.Select(ni => ni.Name)
.OrderBy(name => name)
.ToList();