diff --git a/v2rayN/Directory.Packages.props b/v2rayN/Directory.Packages.props index 3d918f99..4d05baff 100644 --- a/v2rayN/Directory.Packages.props +++ b/v2rayN/Directory.Packages.props @@ -12,6 +12,7 @@ + @@ -26,7 +27,9 @@ + + - \ No newline at end of file + diff --git a/v2rayN/ServiceLib.Tests/CoreConfigV2rayServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfigV2rayServiceTests.cs new file mode 100644 index 00000000..b085e5d9 --- /dev/null +++ b/v2rayN/ServiceLib.Tests/CoreConfigV2rayServiceTests.cs @@ -0,0 +1,228 @@ +using System.Text.Json.Nodes; +using ServiceLib; +using ServiceLib.Enums; +using ServiceLib.Models; +using ServiceLib.Services.CoreConfig; +using Xunit; + +namespace ServiceLib.Tests; + +public class CoreConfigV2rayServiceTests +{ + private const string SendThrough = "198.51.100.10"; + + [Fact] + public void GenerateClientConfigContent_OnlyAppliesSendThroughToRemoteProxyOutbounds() + { + var node = CreateProxyNode("proxy-1", "198.51.100.1", 443); + var service = new CoreConfigV2rayService(CreateContext(node)); + + var result = service.GenerateClientConfigContent(); + + Assert.True(result.Success); + + var outbounds = GetOutbounds(result.Data?.ToString()); + var proxyOutbound = outbounds.Single(outbound => outbound["tag"]!.GetValue() == Global.ProxyTag); + var directOutbound = outbounds.Single(outbound => outbound["tag"]!.GetValue() == Global.DirectTag); + var blockOutbound = outbounds.Single(outbound => outbound["tag"]!.GetValue() == Global.BlockTag); + + Assert.Equal(SendThrough, proxyOutbound["sendThrough"]?.GetValue()); + Assert.Null(directOutbound["sendThrough"]); + Assert.Null(blockOutbound["sendThrough"]); + } + + [Fact] + public void GenerateClientConfigContent_OnlyAppliesSendThroughToChainExitOutbounds() + { + var exitNode = CreateProxyNode("exit", "198.51.100.2", 443); + var entryNode = CreateProxyNode("entry", "198.51.100.3", 443); + var chainNode = CreateChainNode("chain", exitNode, entryNode); + + var service = new CoreConfigV2rayService(CreateContext( + chainNode, + allProxiesMap: new Dictionary + { + [exitNode.IndexId] = exitNode, + [entryNode.IndexId] = entryNode, + })); + + var result = service.GenerateClientConfigContent(); + + Assert.True(result.Success); + + var outbounds = GetOutbounds(result.Data?.ToString()) + .Where(outbound => outbound["protocol"]?.GetValue() is not ("freedom" or "blackhole" or "dns")) + .ToList(); + + var sendThroughOutbounds = outbounds + .Where(outbound => outbound["sendThrough"]?.GetValue() == SendThrough) + .ToList(); + var chainedOutbounds = outbounds + .Where(outbound => outbound["streamSettings"]?["sockopt"]?["dialerProxy"] is not null) + .ToList(); + + Assert.Single(sendThroughOutbounds); + Assert.All(chainedOutbounds, outbound => Assert.Null(outbound["sendThrough"])); + } + + [Fact] + public void GenerateClientConfigContent_DoesNotApplySendThroughToTunRelayLoopbackOutbound() + { + var node = CreateProxyNode("proxy-1", "198.51.100.4", 443); + var config = CreateConfig(); + config.TunModeItem.EnableLegacyProtect = false; + + var service = new CoreConfigV2rayService(CreateContext( + node, + config, + isTunEnabled: true, + tunProtectSsPort: 10811, + proxyRelaySsPort: 10812)); + + var result = service.GenerateClientConfigContent(); + + Assert.True(result.Success); + + var outbounds = GetOutbounds(result.Data?.ToString()); + Assert.DoesNotContain(outbounds, outbound => outbound["sendThrough"]?.GetValue() == SendThrough); + } + + private static CoreConfigContext CreateContext( + ProfileItem node, + Config? config = null, + Dictionary? allProxiesMap = null, + bool isTunEnabled = false, + int tunProtectSsPort = 0, + int proxyRelaySsPort = 0) + { + return new CoreConfigContext + { + Node = node, + RunCoreType = ECoreType.Xray, + AppConfig = config ?? CreateConfig(), + AllProxiesMap = allProxiesMap ?? new(), + SimpleDnsItem = new SimpleDNSItem(), + IsTunEnabled = isTunEnabled, + TunProtectSsPort = tunProtectSsPort, + ProxyRelaySsPort = proxyRelaySsPort, + }; + } + + private static Config CreateConfig() + { + return new Config + { + IndexId = string.Empty, + SubIndexId = string.Empty, + CoreBasicItem = new() + { + LogEnabled = false, + Loglevel = "warning", + MuxEnabled = false, + DefAllowInsecure = false, + DefFingerprint = Global.Fingerprints.First(), + DefUserAgent = string.Empty, + SendThrough = SendThrough, + EnableFragment = false, + EnableCacheFile4Sbox = true, + }, + TunModeItem = new() + { + EnableTun = false, + AutoRoute = true, + StrictRoute = true, + Stack = string.Empty, + Mtu = 9000, + EnableIPv6Address = false, + IcmpRouting = Global.TunIcmpRoutingPolicies.First(), + EnableLegacyProtect = false, + }, + KcpItem = new(), + GrpcItem = new(), + RoutingBasicItem = new() + { + DomainStrategy = Global.DomainStrategies.First(), + DomainStrategy4Singbox = Global.DomainStrategies4Sbox.First(), + RoutingIndexId = string.Empty, + }, + GuiItem = new(), + MsgUIItem = new(), + UiItem = new() + { + CurrentLanguage = "en", + CurrentFontFamily = string.Empty, + MainColumnItem = [], + WindowSizeItem = [], + }, + ConstItem = new(), + SpeedTestItem = new(), + Mux4RayItem = new() + { + Concurrency = 8, + XudpConcurrency = 8, + XudpProxyUDP443 = "reject", + }, + Mux4SboxItem = new() + { + Protocol = string.Empty, + }, + HysteriaItem = new(), + ClashUIItem = new() + { + ConnectionsColumnItem = [], + }, + SystemProxyItem = new(), + WebDavItem = new(), + CheckUpdateItem = new(), + Fragment4RayItem = null, + Inbound = [new InItem + { + Protocol = EInboundProtocol.socks.ToString(), + LocalPort = 10808, + UdpEnabled = true, + SniffingEnabled = true, + RouteOnly = false, + }], + GlobalHotkeys = [], + CoreTypeItem = [], + SimpleDNSItem = new(), + }; + } + + private static ProfileItem CreateProxyNode(string indexId, string address, int port) + { + return new ProfileItem + { + IndexId = indexId, + Remarks = indexId, + ConfigType = EConfigType.SOCKS, + CoreType = ECoreType.Xray, + Address = address, + Port = port, + }; + } + + private static ProfileItem CreateChainNode(string indexId, params ProfileItem[] nodes) + { + var chainNode = new ProfileItem + { + IndexId = indexId, + Remarks = indexId, + ConfigType = EConfigType.ProxyChain, + CoreType = ECoreType.Xray, + }; + chainNode.SetProtocolExtra(new ProtocolExtraItem + { + ChildItems = string.Join(',', nodes.Select(node => node.IndexId)), + }); + return chainNode; + } + + private static List GetOutbounds(string? json) + { + var root = JsonNode.Parse(json ?? throw new InvalidOperationException("Config JSON is missing"))?.AsObject() + ?? throw new InvalidOperationException("Failed to parse config JSON"); + return root["outbounds"]?.AsArray().Select(node => node!.AsObject()).ToList() + ?? throw new InvalidOperationException("Config JSON does not contain outbounds"); + } +} diff --git a/v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj b/v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj new file mode 100644 index 00000000..4e1ed1a7 --- /dev/null +++ b/v2rayN/ServiceLib.Tests/ServiceLib.Tests.csproj @@ -0,0 +1,21 @@ + + + + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index 13ef508a..81b46ac6 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -4014,15 +4014,6 @@ namespace ServiceLib.Resx { } } - /// - /// 查找类似 e.g. 192.168.1.10 的本地化字符串。 - /// - public static string TbSettingsSendThroughHint { - get { - return ResourceManager.GetString("TbSettingsSendThroughHint", resourceCulture); - } - } - /// /// 查找类似 Local outbound address (SendThrough) 的本地化字符串。 /// diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index dcc3e135..fbab6252 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1323,9 +1323,6 @@ Local outbound address (SendThrough) - - e.g. 192.168.1.10 - Only applies to Xray. Fill in a local IPv4 address; leave empty to disable. diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx index b1e57efe..8416287e 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1320,9 +1320,6 @@ 本地出站地址 (SendThrough) - - 例如 192.168.1.10 - 仅对 Xray 生效,填写本机 IPv4;留空则不设置。 diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs index be17e63f..b1673f3b 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/CoreConfigV2rayService.cs @@ -415,7 +415,37 @@ public partial class CoreConfigV2rayService(CoreConfigContext context) var sendThrough = _config.CoreBasicItem.SendThrough?.TrimEx(); foreach (var outbound in _coreConfig.outbounds ?? []) { - outbound.sendThrough = sendThrough.IsNullOrEmpty() ? null : sendThrough; + outbound.sendThrough = ShouldApplySendThrough(outbound, sendThrough) ? sendThrough : null; } } + + private static bool ShouldApplySendThrough(Outbounds4Ray outbound, string? sendThrough) + { + if (sendThrough.IsNullOrEmpty()) + { + return false; + } + + if (outbound.protocol is "freedom" or "blackhole" or "dns" or "loopback") + { + return false; + } + + if (outbound.streamSettings?.sockopt?.dialerProxy.IsNullOrEmpty() == false) + { + return false; + } + + var outboundAddress = outbound.settings?.servers?.FirstOrDefault()?.address + ?? outbound.settings?.vnext?.FirstOrDefault()?.address + ?? outbound.settings?.address?.ToString() + ?? string.Empty; + + if (outboundAddress.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return !IPAddress.TryParse(outboundAddress, out var address) || !IPAddress.IsLoopback(address); + } } diff --git a/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml b/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml index ea4a5979..640a4f6f 100644 --- a/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/OptionSettingWindow.axaml @@ -338,7 +338,7 @@ Grid.Column="1" Width="200" Margin="{StaticResource Margin4}" - Watermark="{x:Static resx:ResUI.TbSettingsSendThroughHint}" /> + Watermark="0.0.0.0" /> + materialDesign:HintAssist.Hint="0.0.0.0" />