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();