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" />