From d1fc023a3c91975d84a56d9a988596cbea6d22ff Mon Sep 17 00:00:00 2001 From: Zero <1270128439@qq.com> Date: Sat, 11 Jul 2026 15:16:13 +0800 Subject: [PATCH] fix: pin real-latency speed test to physical NIC under TUN mode The "real delay" speed test spawns a throwaway second xray core. While the live core owns a full-route TUN, that helper's outbound traffic was being captured by the TUN and looped through the live proxy, so the test either measured the wrong thing or timed out. TunService.ResolveOutboundInterface picks the interface the helper core should bind to: an explicitly configured NIC (validated for existence, Up state, and not being the TUN adapter itself, falling back to auto- selection when stale), or an automatically chosen physical NIC with a real default gateway (IPv4 preferred; IPv6 link-local gateways only count when backed by a global IPv6 address). RealLatencyProbeService gates this on a live TUN-active callback (IsTunActive, wired from MainViewModel) rather than the persisted settings.IsTunMode flag, since the latter lags the UI toggle and can survive a crash as a stale true. XrayConfigBuilder.BuildSpeedtestConfig applies the resolved interface as a sockopt.interface pin per proxy outbound (ApplyOutboundInterface now centrally no-ops for wireguard, since those outbounds have no streamSettings). The live TUN config's process-routing fallback also gained a bare "xray" entry alongside the "self/"/"xray/" path sugars, as defense-in-depth for when the resolver finds no usable NIC. TunConfirmationDialog's adapter picker now shares TunService's TUN-adapter exclusion filter so it no longer offers interfaces (Tunnel type, xray-tun itself) that the resolver would silently reject later. --- Services/RealLatencyProbeService.cs | 38 +++++++++-- Services/TunService.cs | 97 +++++++++++++++++++++++++++++ Services/XrayConfigBuilder.cs | 32 +++++++--- ViewModels/MainViewModel.cs | 5 +- Views/TunConfirmationDialog.xaml.cs | 2 +- 5 files changed, 159 insertions(+), 15 deletions(-) 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();