Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions Services/RealLatencyProbeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ namespace XrayUI.Services
/// </summary>
public sealed class RealLatencyProbeService
{
private readonly SettingsService _settings;
private readonly TunService _tunService;

/// <summary>
/// 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.
/// </summary>
public Func<bool> 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):
Expand Down Expand Up @@ -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);

Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -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<Task>(entries.Count);
foreach (var (server, port) in entries)
Expand All @@ -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)
Expand Down
97 changes: 97 additions & 0 deletions Services/TunService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -38,6 +40,101 @@ public bool IsWintunAvailable()
/// <summary>Gets the expected wintun.dll path for error messages.</summary>
public string GetExpectedWintunPath() => Path.Combine(_engineDirectory, "wintun.dll");

/// <summary>
/// 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 <see langword="null"/> preserves the old process-routing fallback when
/// Windows exposes no usable interface.
/// </summary>
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select the routed egress instead of fastest NIC

In auto mode with two up non-TUN adapters that both have default gateways, this tie-break can choose a faster but non-routed/isolated interface instead of the adapter Windows would actually use for the proxy server or test URL. The speed-test config then pins every helper outbound to that wrong NIC and reports timeouts even though the live TUN path works; resolve the interface from the route table/metric for the destination rather than link speed.

Useful? React with 👍 / 👎.

.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;
}

/// <summary>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 <see cref="Views.TunConfirmationDialog"/> to keep the interface picker in sync with what
/// <see cref="ResolveOutboundInterface"/> will actually accept.</summary>
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));

/// <summary>
/// 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
Expand Down
32 changes: 25 additions & 7 deletions Services/XrayConfigBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,7 @@ private static JsonArray BuildOutbounds(
foreach (var outbound in list.OfType<JsonObject>())
{
var tag = outbound["tag"]?.GetValue<string>();
var protocol = outbound["protocol"]?.GetValue<string>();
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);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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<string>();
if (string.Equals(protocol, "wireguard", StringComparison.OrdinalIgnoreCase))
return;

var streamSettings = outbound["streamSettings"] as JsonObject;
if (streamSettings is null)
{
Expand Down Expand Up @@ -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")
});
}

Expand Down Expand Up @@ -1083,8 +1090,14 @@ private static void AddValue(JsonArray array, string value)
/// the caller must filter them out.
/// </summary>
/// <param name="entries">Each server paired with the local socks port it should listen on.</param>
/// <param name="outboundInterface">Resolved physical interface name to pin every proxy
/// outbound to (see <see cref="TunService.ResolveOutboundInterface"/>), 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.</param>
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();
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route helper DNS outside FakeDNS

When TUN is running with FakeDNS enabled and a tested server's host is a domain, pinning only the helper outbound socket still leaves the helper core's hostname lookup on system DNS. The live TUN rules send tun-in port 53 to dns-out before the xray process bypass, so the helper can receive a 198.18/15 FakeDNS address and then dial that synthetic IP on the physical NIC, making real-latency time out for common domain-based nodes. Add a DNS block/forced resolution path for the speed-test core before applying the interface pin.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this live rather than reasoning about it further: connected via TUN with FakeDNS enabled, then ran the real-latency test against several domain-hostname nodes (e.g. vless-jp01.waimaosass.icu, vless-jp02.waimaosass.icu, hk01.poke-mon.xyz). All returned real, varied round-trip times (68–601ms) — no timeouts.

Checked the actual generated xray_config.json for the live core: the FakeDNS capture rule is scoped to inboundTag: ["tun-in"], so it only fires for traffic that physically arrives at the live core's own TUN inbound. For the helper core's DNS query to hit it, an unbound UDP:53 packet would first have to get swept into the xray-tun adapter by the OS's autoSystemRoutingTable default route.

Per Xray's docs, sockopt.domainStrategy — the field that governs resolving the outbound's own server hostname — lives in the same sockopt object as interface. That strongly suggests hostname resolution for the outbound's own address happens as part of the same pinned dial, not a separate unbound path, which matches what's observed: the helper's DNS query for its own proxy server's hostname appears to go out the pinned physical NIC directly, never reaching tun-in, never hitting the FakeDNS rule.

Given that, I don't think the dns block is needed here. Happy to revisit if anyone can reproduce a timeout with FakeDNS + a domain-hostname node under TUN — but it didn't reproduce after direct testing.

}
AddNode(outbounds, outbound);

AddNode(rules, new JsonObject
{
Expand Down
5 changes: 4 additions & 1 deletion ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion Views/TunConfirmationDialog.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ private static List<string> 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();
Expand Down
Loading