Skip to content

Commit 6c8f22a

Browse files
authored
feat(send-through): 支持配置本地出站地址 (2dust#8946)
* feat(send-through): 支持配置本地出站地址 为 Xray 增加 SendThrough 配置项,允许指定本机 IPv4 作为出站源地址。 - 在核心设置页新增 SendThrough 输入框及中英文提示文案 - 保存配置时校验并持久化本机 IPv4 地址 - 生成 Xray 配置时为所有 outbound 写入 sendThrough 字段 影响说明: - 仅对 Xray 生效,留空时不设置该字段 * fix(send-through): limit sendThrough to remote egress outbounds
1 parent 49f6557 commit 6c8f22a

17 files changed

Lines changed: 427 additions & 3 deletions

v2rayN/Directory.Packages.props

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<PackageVersion Include="ReactiveUI.Avalonia" Version="11.4.12" />
1313
<PackageVersion Include="CliWrap" Version="3.10.1" />
1414
<PackageVersion Include="Downloader" Version="5.1.0" />
15+
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
1516
<PackageVersion Include="H.NotifyIcon.Wpf" Version="2.4.1" />
1617
<PackageVersion Include="MaterialDesignThemes" Version="5.3.1" />
1718
<PackageVersion Include="MessageBox.Avalonia" Version="3.3.1.1" />
@@ -26,7 +27,9 @@
2627
<PackageVersion Include="sqlite-net-pcl" Version="1.9.172" />
2728
<PackageVersion Include="TaskScheduler" Version="2.12.2" />
2829
<PackageVersion Include="WebDav.Client" Version="2.9.0" />
30+
<PackageVersion Include="xunit" Version="2.9.3" />
31+
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
2932
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
3033
<PackageVersion Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.14" />
3134
</ItemGroup>
32-
</Project>
35+
</Project>
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
using System.Text.Json.Nodes;
2+
using ServiceLib;
3+
using ServiceLib.Enums;
4+
using ServiceLib.Models;
5+
using ServiceLib.Services.CoreConfig;
6+
using Xunit;
7+
8+
namespace ServiceLib.Tests;
9+
10+
public class CoreConfigV2rayServiceTests
11+
{
12+
private const string SendThrough = "198.51.100.10";
13+
14+
[Fact]
15+
public void GenerateClientConfigContent_OnlyAppliesSendThroughToRemoteProxyOutbounds()
16+
{
17+
var node = CreateProxyNode("proxy-1", "198.51.100.1", 443);
18+
var service = new CoreConfigV2rayService(CreateContext(node));
19+
20+
var result = service.GenerateClientConfigContent();
21+
22+
Assert.True(result.Success);
23+
24+
var outbounds = GetOutbounds(result.Data?.ToString());
25+
var proxyOutbound = outbounds.Single(outbound => outbound["tag"]!.GetValue<string>() == Global.ProxyTag);
26+
var directOutbound = outbounds.Single(outbound => outbound["tag"]!.GetValue<string>() == Global.DirectTag);
27+
var blockOutbound = outbounds.Single(outbound => outbound["tag"]!.GetValue<string>() == Global.BlockTag);
28+
29+
Assert.Equal(SendThrough, proxyOutbound["sendThrough"]?.GetValue<string>());
30+
Assert.Null(directOutbound["sendThrough"]);
31+
Assert.Null(blockOutbound["sendThrough"]);
32+
}
33+
34+
[Fact]
35+
public void GenerateClientConfigContent_OnlyAppliesSendThroughToChainExitOutbounds()
36+
{
37+
var exitNode = CreateProxyNode("exit", "198.51.100.2", 443);
38+
var entryNode = CreateProxyNode("entry", "198.51.100.3", 443);
39+
var chainNode = CreateChainNode("chain", exitNode, entryNode);
40+
41+
var service = new CoreConfigV2rayService(CreateContext(
42+
chainNode,
43+
allProxiesMap: new Dictionary<string, ProfileItem>
44+
{
45+
[exitNode.IndexId] = exitNode,
46+
[entryNode.IndexId] = entryNode,
47+
}));
48+
49+
var result = service.GenerateClientConfigContent();
50+
51+
Assert.True(result.Success);
52+
53+
var outbounds = GetOutbounds(result.Data?.ToString())
54+
.Where(outbound => outbound["protocol"]?.GetValue<string>() is not ("freedom" or "blackhole" or "dns"))
55+
.ToList();
56+
57+
var sendThroughOutbounds = outbounds
58+
.Where(outbound => outbound["sendThrough"]?.GetValue<string>() == SendThrough)
59+
.ToList();
60+
var chainedOutbounds = outbounds
61+
.Where(outbound => outbound["streamSettings"]?["sockopt"]?["dialerProxy"] is not null)
62+
.ToList();
63+
64+
Assert.Single(sendThroughOutbounds);
65+
Assert.All(chainedOutbounds, outbound => Assert.Null(outbound["sendThrough"]));
66+
}
67+
68+
[Fact]
69+
public void GenerateClientConfigContent_DoesNotApplySendThroughToTunRelayLoopbackOutbound()
70+
{
71+
var node = CreateProxyNode("proxy-1", "198.51.100.4", 443);
72+
var config = CreateConfig();
73+
config.TunModeItem.EnableLegacyProtect = false;
74+
75+
var service = new CoreConfigV2rayService(CreateContext(
76+
node,
77+
config,
78+
isTunEnabled: true,
79+
tunProtectSsPort: 10811,
80+
proxyRelaySsPort: 10812));
81+
82+
var result = service.GenerateClientConfigContent();
83+
84+
Assert.True(result.Success);
85+
86+
var outbounds = GetOutbounds(result.Data?.ToString());
87+
Assert.DoesNotContain(outbounds, outbound => outbound["sendThrough"]?.GetValue<string>() == SendThrough);
88+
}
89+
90+
private static CoreConfigContext CreateContext(
91+
ProfileItem node,
92+
Config? config = null,
93+
Dictionary<string, ProfileItem>? allProxiesMap = null,
94+
bool isTunEnabled = false,
95+
int tunProtectSsPort = 0,
96+
int proxyRelaySsPort = 0)
97+
{
98+
return new CoreConfigContext
99+
{
100+
Node = node,
101+
RunCoreType = ECoreType.Xray,
102+
AppConfig = config ?? CreateConfig(),
103+
AllProxiesMap = allProxiesMap ?? new(),
104+
SimpleDnsItem = new SimpleDNSItem(),
105+
IsTunEnabled = isTunEnabled,
106+
TunProtectSsPort = tunProtectSsPort,
107+
ProxyRelaySsPort = proxyRelaySsPort,
108+
};
109+
}
110+
111+
private static Config CreateConfig()
112+
{
113+
return new Config
114+
{
115+
IndexId = string.Empty,
116+
SubIndexId = string.Empty,
117+
CoreBasicItem = new()
118+
{
119+
LogEnabled = false,
120+
Loglevel = "warning",
121+
MuxEnabled = false,
122+
DefAllowInsecure = false,
123+
DefFingerprint = Global.Fingerprints.First(),
124+
DefUserAgent = string.Empty,
125+
SendThrough = SendThrough,
126+
EnableFragment = false,
127+
EnableCacheFile4Sbox = true,
128+
},
129+
TunModeItem = new()
130+
{
131+
EnableTun = false,
132+
AutoRoute = true,
133+
StrictRoute = true,
134+
Stack = string.Empty,
135+
Mtu = 9000,
136+
EnableIPv6Address = false,
137+
IcmpRouting = Global.TunIcmpRoutingPolicies.First(),
138+
EnableLegacyProtect = false,
139+
},
140+
KcpItem = new(),
141+
GrpcItem = new(),
142+
RoutingBasicItem = new()
143+
{
144+
DomainStrategy = Global.DomainStrategies.First(),
145+
DomainStrategy4Singbox = Global.DomainStrategies4Sbox.First(),
146+
RoutingIndexId = string.Empty,
147+
},
148+
GuiItem = new(),
149+
MsgUIItem = new(),
150+
UiItem = new()
151+
{
152+
CurrentLanguage = "en",
153+
CurrentFontFamily = string.Empty,
154+
MainColumnItem = [],
155+
WindowSizeItem = [],
156+
},
157+
ConstItem = new(),
158+
SpeedTestItem = new(),
159+
Mux4RayItem = new()
160+
{
161+
Concurrency = 8,
162+
XudpConcurrency = 8,
163+
XudpProxyUDP443 = "reject",
164+
},
165+
Mux4SboxItem = new()
166+
{
167+
Protocol = string.Empty,
168+
},
169+
HysteriaItem = new(),
170+
ClashUIItem = new()
171+
{
172+
ConnectionsColumnItem = [],
173+
},
174+
SystemProxyItem = new(),
175+
WebDavItem = new(),
176+
CheckUpdateItem = new(),
177+
Fragment4RayItem = null,
178+
Inbound = [new InItem
179+
{
180+
Protocol = EInboundProtocol.socks.ToString(),
181+
LocalPort = 10808,
182+
UdpEnabled = true,
183+
SniffingEnabled = true,
184+
RouteOnly = false,
185+
}],
186+
GlobalHotkeys = [],
187+
CoreTypeItem = [],
188+
SimpleDNSItem = new(),
189+
};
190+
}
191+
192+
private static ProfileItem CreateProxyNode(string indexId, string address, int port)
193+
{
194+
return new ProfileItem
195+
{
196+
IndexId = indexId,
197+
Remarks = indexId,
198+
ConfigType = EConfigType.SOCKS,
199+
CoreType = ECoreType.Xray,
200+
Address = address,
201+
Port = port,
202+
};
203+
}
204+
205+
private static ProfileItem CreateChainNode(string indexId, params ProfileItem[] nodes)
206+
{
207+
var chainNode = new ProfileItem
208+
{
209+
IndexId = indexId,
210+
Remarks = indexId,
211+
ConfigType = EConfigType.ProxyChain,
212+
CoreType = ECoreType.Xray,
213+
};
214+
chainNode.SetProtocolExtra(new ProtocolExtraItem
215+
{
216+
ChildItems = string.Join(',', nodes.Select(node => node.IndexId)),
217+
});
218+
return chainNode;
219+
}
220+
221+
private static List<JsonObject> GetOutbounds(string? json)
222+
{
223+
var root = JsonNode.Parse(json ?? throw new InvalidOperationException("Config JSON is missing"))?.AsObject()
224+
?? throw new InvalidOperationException("Failed to parse config JSON");
225+
return root["outbounds"]?.AsArray().Select(node => node!.AsObject()).ToList()
226+
?? throw new InvalidOperationException("Config JSON does not contain outbounds");
227+
}
228+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<IsPackable>false</IsPackable>
5+
<IsTestProject>true</IsTestProject>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
10+
<PackageReference Include="xunit" />
11+
<PackageReference Include="xunit.runner.visualstudio">
12+
<PrivateAssets>all</PrivateAssets>
13+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
14+
</PackageReference>
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<ProjectReference Include="..\ServiceLib\ServiceLib.csproj" />
19+
</ItemGroup>
20+
21+
</Project>

v2rayN/ServiceLib/Common/Utils.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,23 @@ public static bool IsIpv6(string ip)
522522
return false;
523523
}
524524

525+
public static bool IsIpv4(string? ip)
526+
{
527+
if (ip.IsNullOrEmpty())
528+
{
529+
return false;
530+
}
531+
532+
ip = ip.Trim();
533+
if (!IPAddress.TryParse(ip, out var address))
534+
{
535+
return false;
536+
}
537+
538+
return address.AddressFamily == AddressFamily.InterNetwork
539+
&& ip.Count(c => c == '.') == 3;
540+
}
541+
525542
public static bool IsIpAddress(string? ip)
526543
{
527544
if (ip.IsNullOrEmpty())

v2rayN/ServiceLib/Handler/ConfigHandler.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public static class ConfigHandler
4141
Loglevel = "warning",
4242
MuxEnabled = false,
4343
};
44+
config.CoreBasicItem.SendThrough = config.CoreBasicItem.SendThrough?.TrimEx();
4445

4546
if (config.Inbound == null)
4647
{

v2rayN/ServiceLib/Models/ConfigItems.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public class CoreBasicItem
1515

1616
public string DefUserAgent { get; set; }
1717

18+
public string? SendThrough { get; set; }
19+
1820
public bool EnableFragment { get; set; }
1921

2022
public bool EnableCacheFile4Sbox { get; set; } = true;

v2rayN/ServiceLib/Models/V2rayConfig.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ public class Outbounds4Ray
105105

106106
public string protocol { get; set; }
107107

108+
public string? sendThrough { get; set; }
109+
108110
public string? targetStrategy { get; set; }
109111

110112
public Outboundsettings4Ray settings { get; set; }

v2rayN/ServiceLib/Resx/ResUI.Designer.cs

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

v2rayN/ServiceLib/Resx/ResUI.resx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,15 @@
13201320
<data name="TbSettingsLinuxSudoPasswordTip" xml:space="preserve">
13211321
<value>The password will be validated via the command line. If a validation error causes the application to malfunction, please restart the application. The password will not be stored and must be entered again after each restart.</value>
13221322
</data>
1323+
<data name="TbSettingsSendThrough" xml:space="preserve">
1324+
<value>Local outbound address (SendThrough)</value>
1325+
</data>
1326+
<data name="TbSettingsSendThroughTip" xml:space="preserve">
1327+
<value>Only applies to Xray. Fill in a local IPv4 address; leave empty to disable.</value>
1328+
</data>
1329+
<data name="FillCorrectSendThroughIPv4" xml:space="preserve">
1330+
<value>Please fill in the correct IPv4 address for SendThrough.</value>
1331+
</data>
13231332
<data name="TransportHeaderTypeTip5" xml:space="preserve">
13241333
<value>*xhttp mode</value>
13251334
</data>
@@ -1698,4 +1707,4 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
16981707
<data name="TbLegacyProtect" xml:space="preserve">
16991708
<value>Legacy TUN Protect</value>
17001709
</data>
1701-
</root>
1710+
</root>

0 commit comments

Comments
 (0)