diff --git a/Assets/engine/xray.exe b/Assets/engine/xray.exe index d2f3b96..a42cf6b 100644 Binary files a/Assets/engine/xray.exe and b/Assets/engine/xray.exe differ diff --git a/Models/AppSettings.cs b/Models/AppSettings.cs index 9b76108..b90a78b 100644 --- a/Models/AppSettings.cs +++ b/Models/AppSettings.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Text.Json.Nodes; +using XrayUI.Services; namespace XrayUI.Models { @@ -11,6 +12,8 @@ public class AppSettings /// Whether TUN mode is enabled. public bool IsTunMode { get; set; } = false; public string? LastTunServerHost { get; set; } + public int TunMtu { get; set; } = XrayConfigConstants.TunMtuDefault; + public string TunOutboundInterface { get; set; } = XrayConfigConstants.TunOutboundInterfaceAuto; public bool IsStartupEnabled { get; set; } = false; public bool IsAutoConnect { get; set; } = false; /// true = global proxy (default); false = do not take over the system proxy. diff --git a/Services/DialogService.cs b/Services/DialogService.cs index f926462..6b99f8a 100644 --- a/Services/DialogService.cs +++ b/Services/DialogService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using System.ComponentModel; @@ -469,6 +469,25 @@ public async Task ShowConfirmationAsync(string title, string message, stri return result == ContentDialogResult.Primary; } + public async Task ShowTunConfirmationDialogAsync(AppSettings settings) + { + var content = new TunConfirmationDialog(settings.TunMtu, settings.TunOutboundInterface); + + var dialog = CreateDialog(); + dialog.Title = "开启TUN模式"; + dialog.Content = content; + dialog.PrimaryButtonText = "确认"; + dialog.CloseButtonText = "取消"; + dialog.DefaultButton = ContentDialogButton.Primary; + + if (await dialog.ShowAsync() != ContentDialogResult.Primary) + return false; + + settings.TunMtu = content.Mtu; + settings.TunOutboundInterface = content.SelectedInterface; + return true; + } + public async Task ShowErrorAsync(string title, string message, XamlRoot? xamlRoot = null) { var dialog = CreateDialog(xamlRoot); diff --git a/Services/IDialogService.cs b/Services/IDialogService.cs index 473c813..b646180 100644 --- a/Services/IDialogService.cs +++ b/Services/IDialogService.cs @@ -15,6 +15,12 @@ public interface IDialogService Task ShowEditPortDialogAsync(int currentPort); Task ShowErrorAsync(string title, string message, XamlRoot? xamlRoot = null); Task ShowConfirmationAsync(string title, string message, string confirmText = "确定", string cancelText = "取消", bool isDanger = false); + /// + /// Shows the TUN confirmation dialog. Mutates .TunMtu and + /// .TunOutboundInterface in-place on confirm. Returns true if + /// the user confirmed (caller must persist), false if cancelled. + /// + Task ShowTunConfirmationDialogAsync(AppSettings settings); Task ShowShareLinkDialogAsync(string serverName, string link); Task<(bool enabled, bool autoConnect)?> ShowStartupDialogAsync(bool currentEnabled, bool currentAutoConnect); diff --git a/Services/TunService.cs b/Services/TunService.cs index 250b746..48f6290 100644 --- a/Services/TunService.cs +++ b/Services/TunService.cs @@ -1,11 +1,9 @@ -using System; +using System; using System.Collections.Generic; 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; @@ -21,8 +19,7 @@ public class TunService { private readonly string _engineDirectory; - /// Default TUN interface name; must match the name field in XrayConfigBuilder.BuildTunInbound. - private const string DefaultTunInterfaceName = "xray-tun"; + private const string TunInterfaceName = XrayConfigConstants.TunInterfaceName; public TunService() { @@ -42,54 +39,39 @@ public bool IsWintunAvailable() public string GetExpectedWintunPath() => Path.Combine(_engineDirectory, "wintun.dll"); /// - /// Finds the physical interface Windows would use for normal outbound IPv4 traffic. - /// TUN mode binds xray outbounds to this interface to avoid sending xray's own - /// proxy connection back into the TUN adapter. + /// 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 + /// startup or shutdown. Safe to call when the adapter doesn't exist — netsh emits + /// a non-zero exit code but doesn't abort the batch (commands are chained with `&`). + /// Skipped silently when not elevated: a DNS reset isn't worth a UAC prompt, and + /// the cleanup-path callers already pay UAC via . /// - public string? DetectDefaultOutboundInterfaceName() + public void ResetTunDnsServers() { + if (!AdminHelper.IsAdministrator()) + return; + try { - var localAddress = GetDefaultOutboundAddress(); - if (localAddress is null) - { - Debug.WriteLine("[TunService] Could not determine the default outbound IPv4 address."); - return null; - } - - var match = NetworkInterface.GetAllNetworkInterfaces() - .Where(IsCandidateOutboundInterface) - .Select(nic => new - { - Interface = nic, - Properties = nic.GetIPProperties() - }) - .Where(item => item.Properties.UnicastAddresses.Any(address => - address.Address.AddressFamily == AddressFamily.InterNetwork - && address.Address.Equals(localAddress))) - .Select(item => item.Interface) - .FirstOrDefault(); - - if (match is null) - { - Debug.WriteLine($"[TunService] Could not map outbound address {localAddress} to a usable interface."); - return null; - } - - Debug.WriteLine($"[TunService] Default outbound interface: {match.Name} ({localAddress})"); - return match.Name; + RunElevatedBatch(BuildDnsResetBatch()); } catch (Exception ex) { - Debug.WriteLine($"[TunService] Default outbound interface detection failed: {ex.Message}"); - return null; + Debug.WriteLine($"[TunService] TUN DNS 重置失败: {ex.Message}"); } } + private static List BuildDnsResetBatch() => + [ + $"netsh interface ipv4 set dnsservers \"{TunInterfaceName}\" source=dhcp", + $"netsh interface ipv6 set dnsservers \"{TunInterfaceName}\" source=dhcp", + ]; + /// /// Fallback cleanup: xray removes its own routes on normal exit, so this is only used /// after an abnormal xray exit or when routes remain after exit. Removes the 0.0.0.0/0 - /// fallback route plus the direct route to the server. + /// fallback route plus the direct route to the server, and resets any stale DNS entries + /// on the xray-tun adapter — all in one elevated batch so the user sees at most one UAC. /// public void CleanupTunRoutes(string? serverAddress) { @@ -103,9 +85,9 @@ public void CleanupTunRoutes(string? serverAddress) { // 0.0.0.0/0 is what current xray adds; the /1 split-routes are residue // from earlier routing schemes that may still be lying around. - $"netsh interface ipv4 delete route 0.0.0.0/0 \"{DefaultTunInterfaceName}\" store=active", - $"netsh interface ipv4 delete route 0.0.0.0/1 \"{DefaultTunInterfaceName}\" store=active", - $"netsh interface ipv4 delete route 128.0.0.0/1 \"{DefaultTunInterfaceName}\" store=active", + $"netsh interface ipv4 delete route 0.0.0.0/0 \"{TunInterfaceName}\" store=active", + $"netsh interface ipv4 delete route 0.0.0.0/1 \"{TunInterfaceName}\" store=active", + $"netsh interface ipv4 delete route 128.0.0.0/1 \"{TunInterfaceName}\" store=active", // Legacy route.exe form for the same /1 split-routes. "route delete 0.0.0.0 mask 128.0.0.0", "route delete 128.0.0.0 mask 128.0.0.0", @@ -115,13 +97,15 @@ public void CleanupTunRoutes(string? serverAddress) // does not resolve domains. Skip server-IP cleanup unless it is IPv4. if (TryParseSafeIPv4Address(serverAddress, out var serverIPv4)) { - batch.Add($"netsh interface ipv4 delete route {serverIPv4}/32 \"{DefaultTunInterfaceName}\" store=active"); + batch.Add($"netsh interface ipv4 delete route {serverIPv4}/32 \"{TunInterfaceName}\" store=active"); batch.Add($"route delete {serverIPv4} mask 255.255.255.255"); } foreach (var dns in legacyDnsServers) batch.Add($"route delete {dns} mask 255.255.255.255"); + batch.AddRange(BuildDnsResetBatch()); + RunElevatedBatch(batch); Debug.WriteLine("[TunService] TUN 路由兜底清理完成"); } @@ -147,48 +131,6 @@ private static bool TryParseSafeIPv4Address(string? value, out string address) return true; } - private static IPAddress? GetDefaultOutboundAddress() - { - using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - socket.Connect("8.8.8.8", 53); - return (socket.LocalEndPoint as IPEndPoint)?.Address; - } - - private bool IsCandidateOutboundInterface(NetworkInterface nic) - { - if (nic.OperationalStatus != OperationalStatus.Up) - return false; - - if (nic.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel) - return false; - - var name = nic.Name ?? string.Empty; - var description = nic.Description ?? string.Empty; - var combined = $"{name} {description}"; - - return !ContainsAny(combined, - DefaultTunInterfaceName, - "wintun", - "xray", - "loopback", - "pseudo-interface", - "virtualbox", - "vmware", - "hyper-v virtual", - "vethernet"); - } - - private static bool ContainsAny(string value, params string[] needles) - { - foreach (var needle in needles) - { - if (value.Contains(needle, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } - /// /// Runs a batch of full command lines (e.g. "netsh interface ipv4 ...", "route delete ...") /// in a single cmd.exe — chained with `&` so a failure in one doesn't abort the rest. diff --git a/Services/XrayConfigBuilder.cs b/Services/XrayConfigBuilder.cs index c6d0559..66ccf3f 100644 --- a/Services/XrayConfigBuilder.cs +++ b/Services/XrayConfigBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using System.Text.Json; @@ -27,7 +27,6 @@ public static class XrayConfigBuilder public static string Build( ServerEntry server, AppSettings settings, - string? tunOutboundInterfaceName = null, IEnumerable? availableServers = null) { var config = new JsonObject @@ -35,7 +34,7 @@ public static string Build( ["log"] = BuildLog(settings), ["dns"] = BuildDns(settings), ["inbounds"] = BuildInbounds(settings), - ["outbounds"] = BuildOutbounds(server, settings, tunOutboundInterfaceName, availableServers), + ["outbounds"] = BuildOutbounds(server, settings, availableServers), ["routing"] = BuildRouting(settings) }; @@ -124,11 +123,11 @@ private static JsonObject BuildTunInbound(AppSettings settings) ["protocol"] = "tun", ["settings"] = new JsonObject { - ["name"] = "xray-tun", - ["MTU"] = 9000, + ["name"] = XrayConfigConstants.TunInterfaceName, + ["MTU"] = XrayConfigConstants.NormalizeTunMtu(settings.TunMtu), ["gateway"] = CreateStringArray("172.18.0.1/30"), ["autoSystemRoutingTable"] = CreateStringArray("0.0.0.0/0"), - ["autoOutboundsInterface"] = "auto" + ["autoOutboundsInterface"] = XrayConfigConstants.TunOutboundInterfaceAuto }, ["sniffing"] = sniffing, }; @@ -137,7 +136,6 @@ private static JsonObject BuildTunInbound(AppSettings settings) private static JsonArray BuildOutbounds( ServerEntry server, AppSettings settings, - string? tunOutboundInterfaceName, IEnumerable? availableServers) { var list = new JsonArray(); @@ -194,23 +192,31 @@ private static JsonArray BuildOutbounds( }); } - if (settings.IsTunMode && !string.IsNullOrWhiteSpace(tunOutboundInterfaceName)) + var outboundInterface = NormalizeTunOutboundInterface(settings.TunOutboundInterface); + if (settings.IsTunMode && outboundInterface is not null) { - // sockopt.interface only matters for outbounds that actually open sockets - // to remote hosts. block (blackhole) drops traffic without a socket, and - // dns-out is xray-internal — applying the binding to them produces - // redundant fields in the generated config. foreach (var outbound in list.OfType()) { var tag = outbound["tag"]?.GetValue(); if (tag is ProxyOutboundTag or DirectOutboundTag or ChainEntryOutboundTag) - ApplyOutboundInterface(outbound, tunOutboundInterfaceName); + ApplyOutboundInterface(outbound, outboundInterface); } } return list; } + private static string? NormalizeTunOutboundInterface(string? interfaceName) + { + if (string.IsNullOrWhiteSpace(interfaceName)) + return null; + + var value = interfaceName.Trim(); + return string.Equals(value, XrayConfigConstants.TunOutboundInterfaceAuto, StringComparison.OrdinalIgnoreCase) + ? null + : value; + } + private static (ServerEntry entryServer, ServerEntry exitServer) ResolveChainServers( ServerEntry chain, IEnumerable? availableServers) diff --git a/Services/XrayConfigConstants.cs b/Services/XrayConfigConstants.cs index 1dce0e8..b430f1c 100644 --- a/Services/XrayConfigConstants.cs +++ b/Services/XrayConfigConstants.cs @@ -16,5 +16,16 @@ internal static class XrayConfigConstants // FakeDNS IP pools. 198.18.0.0/15 is RFC-2544 benchmarking space (safe to reuse). public const string FakeDnsPoolV4 = "198.18.0.0/15"; public const string FakeDnsPoolV6 = "fc00::/18"; + + // TUN adapter: the inbound name in the xray config must match the Windows + // interface alias used by TunService for adapter operations (DNS reset, route delete). + public const string TunInterfaceName = "xray-tun"; + public const string TunOutboundInterfaceAuto = "auto"; + public const int TunMtuMin = 68; + public const int TunMtuMax = 9000; + public const int TunMtuDefault = 1500; + + public static int NormalizeTunMtu(int mtu) => + mtu >= TunMtuMin && mtu <= TunMtuMax ? mtu : TunMtuDefault; } } diff --git a/ViewModels/ControlPanelViewModel.cs b/ViewModels/ControlPanelViewModel.cs index 8dc7d2e..d571661 100644 --- a/ViewModels/ControlPanelViewModel.cs +++ b/ViewModels/ControlPanelViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Collections.Generic; using System.Threading; @@ -235,15 +235,13 @@ private async Task StartSelectedServerAsync() if (IsAutoConnect) appSettings.LastAutoConnectServerId = server.Id; - string? tunOutboundInterfaceName = null; if (IsTunMode) { - tunOutboundInterfaceName = await RunTunPreflightAsync("TUN mode error"); - if (tunOutboundInterfaceName is null) return false; + if (!await RunTunPreflightAsync("TUN mode error")) return false; await CleanupPersistedTunRoutesAsync(appSettings); } - var configJson = XrayConfigBuilder.Build(server, appSettings, tunOutboundInterfaceName, GetAllServers()); + var configJson = XrayConfigBuilder.Build(server, appSettings, GetAllServers()); var ok = await _xray.StartAsync(configJson); if (!ok) @@ -393,29 +391,21 @@ private async Task HandleReapplyFailureAsync(string detail) /// - /// Runs the shared TUN-mode preflight: wintun availability, outbound interface detection, - /// and system-proxy clearing. Returns the detected interface name, or null when a check - /// fails (the user has already seen an error dialog with ). + /// Runs the shared TUN-mode preflight: wintun availability and system-proxy clearing. + /// Xray-core handles outbound interface selection through autoOutboundsInterface="auto". /// - private async Task RunTunPreflightAsync(string errorTitle) + private async Task RunTunPreflightAsync(string errorTitle) { if (!_tunService.IsWintunAvailable()) { await _dialogs.ShowErrorAsync(errorTitle, $"Could not find wintun.dll\nPath: {_tunService.GetExpectedWintunPath()}"); - return null; - } - - var iface = _tunService.DetectDefaultOutboundInterfaceName(); - if (string.IsNullOrWhiteSpace(iface)) - { - await _dialogs.ShowErrorAsync(errorTitle, - "Could not determine the default outbound network interface. Please check that Wi-Fi/Ethernet is connected, then try again."); - return null; + return false; } + _tunService.ResetTunDnsServers(); SystemProxyService.ClearProxy(); - return iface; + return true; } private void CleanupTunRoutesSafely() @@ -571,13 +561,10 @@ private async Task HandleTunToggleAsync(bool wantEnable) IsTunMode = false; _isTunInternalUpdate = false; - var confirmed = await _dialogs.ShowConfirmationAsync( - "开启TUN模式", - "开启 TUN 模式需要管理员权限,程序将会重启,是否继续?", - "确认", - "取消"); + var appSettings = await _settings.LoadSettingsAsync(); + if (!await _dialogs.ShowTunConfirmationDialogAsync(appSettings)) return; - if (!confirmed) return; + await TrySaveSettingsAsync(appSettings, "TUN mode settings save"); RestartAsAdmin("--tun"); } diff --git a/Views/TunConfirmationDialog.xaml b/Views/TunConfirmationDialog.xaml new file mode 100644 index 0000000..370d775 --- /dev/null +++ b/Views/TunConfirmationDialog.xaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/TunConfirmationDialog.xaml.cs b/Views/TunConfirmationDialog.xaml.cs new file mode 100644 index 0000000..d4e5580 --- /dev/null +++ b/Views/TunConfirmationDialog.xaml.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.NetworkInformation; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using XrayUI.Services; + +namespace XrayUI.Views +{ + public sealed partial class TunConfirmationDialog : UserControl + { + private const string AutoInterfaceLabel = "auto(推荐)"; + + public TunConfirmationDialog(int currentMtu, string currentInterface) + { + this.InitializeComponent(); + MtuNumberBox.Value = currentMtu; + PopulateInterfaceComboBox(currentInterface); + } + + public int Mtu => XrayConfigConstants.NormalizeTunMtu( + double.IsNaN(MtuNumberBox.Value) ? XrayConfigConstants.TunMtuDefault : (int)MtuNumberBox.Value); + + public string SelectedInterface => + (InterfaceComboBox.SelectedItem as ComboBoxItem)?.Tag as string + ?? XrayConfigConstants.TunOutboundInterfaceAuto; + + private void MoreOptionsButton_Click(object sender, RoutedEventArgs e) + { + if (AdvancedSettingsPanel.Visibility == Visibility.Visible) + { + AdvancedSettingsPanel.Visibility = Visibility.Collapsed; + MoreOptionsButton.Content = "更多选项"; + } + else + { + AdvancedSettingsPanel.Visibility = Visibility.Visible; + } + } + + private void PopulateInterfaceComboBox(string selectedInterface) + { + InterfaceComboBox.Items.Clear(); + + var autoItem = new ComboBoxItem + { + Content = AutoInterfaceLabel, + Tag = XrayConfigConstants.TunOutboundInterfaceAuto, + }; + InterfaceComboBox.Items.Add(autoItem); + + ComboBoxItem? matchingItem = null; + foreach (var name in EnumerateActiveInterfaceNames()) + { + var item = new ComboBoxItem { Content = name, Tag = name }; + InterfaceComboBox.Items.Add(item); + if (string.Equals(name, selectedInterface, StringComparison.OrdinalIgnoreCase)) + matchingItem = item; + } + + // The persisted interface may no longer be present (Wi-Fi adapter removed, + // VPN uninstalled, etc.) — surface it anyway so the user sees what's saved + // and can change it deliberately. + if (matchingItem is null + && !string.IsNullOrWhiteSpace(selectedInterface) + && !string.Equals(selectedInterface, XrayConfigConstants.TunOutboundInterfaceAuto, StringComparison.OrdinalIgnoreCase)) + { + matchingItem = new ComboBoxItem { Content = selectedInterface, Tag = selectedInterface }; + InterfaceComboBox.Items.Add(matchingItem); + } + + InterfaceComboBox.SelectedItem = matchingItem ?? autoItem; + } + + private static List EnumerateActiveInterfaceNames() + { + try + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(ni => ni.OperationalStatus == OperationalStatus.Up + && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .Select(ni => ni.Name) + .OrderBy(name => name) + .ToList(); + } + catch + { + return []; + } + } + } +} diff --git a/XrayUI-dev.csproj b/XrayUI-dev.csproj index 22983de..37c77d8 100644 --- a/XrayUI-dev.csproj +++ b/XrayUI-dev.csproj @@ -72,11 +72,11 @@ strip their runtime assets (DirectML.dll / onnxruntime.dll / Microsoft.Windows.AI.MachineLearning.dll). IncludeAssets=compile keeps the reference assemblies for compile-time use; PrivateAssets=all prevents them from flowing to consumers. --> - + all compile - + all compile