feat: support custom headers for Xray HTTP outbounds (#9690)

* feat: support custom headers for Xray HTTP outbounds

* feat: simplify HTTP outbound headers input

* feat: validate JSON syntax in HTTP headers

  Keep HTTP outbound headers stored as raw text, while validating
  header names and values against RFC 9110/9112 syntax. Support string
  arrays for multi-value headers, reject duplicate JSON header keys, and
  clarify the invalid header JSON message.

* feat: refactor HTTP header parsing into utility

* Simplify code

* Simplify code

* Update CoreConfigV2rayServiceTests.cs

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
This commit is contained in:
006lp
2026-07-11 14:31:17 +08:00
committed by GitHub
parent 2c70b018ce
commit 88d59488fd
14 changed files with 225 additions and 6 deletions
@@ -130,6 +130,25 @@ internal static class CoreConfigTestFactory
};
}
public static ProfileItem CreateHttpNode(ECoreType coreType, string indexId = "node-http-1",
string remarks = "demo-http")
{
return new ProfileItem
{
IndexId = indexId,
ConfigType = EConfigType.HTTP,
CoreType = coreType,
Remarks = remarks,
Address = "proxy.example.com",
Port = 8080,
Password = "pass",
Username = "user",
Network = nameof(ETransport.raw),
StreamSecurity = string.Empty,
Subid = string.Empty,
};
}
public static ProfileItem CreatePolicyGroupNode(ECoreType coreType, string indexId, string remarks,
IEnumerable<string> childIndexIds)
{
@@ -28,6 +28,39 @@ public class CoreConfigV2rayServiceTests
v2rayConfig.inbounds.Should().Contain(i => i.protocol == nameof(EInboundProtocol.mixed));
}
[Fact]
public void GenerateClientConfigContent_HttpOutbound_ShouldEmitHeadersInSettings()
{
var config = CoreConfigTestFactory.CreateConfig(ECoreType.Xray);
CoreConfigTestFactory.BindAppManagerConfig(config);
var node = CoreConfigTestFactory.CreateHttpNode(ECoreType.Xray);
node.SetProtocolExtra(node.GetProtocolExtra() with
{
HttpHeaders = "{\"User-Agent\":\"v2rayN\",\"Set-Cookie\":[\"a=1\",\"b=2\"]}",
});
var context = CoreConfigTestFactory.CreateContext(config, node, ECoreType.Xray);
var result = new CoreConfigV2rayService(context).GenerateClientConfigContent();
result.Success.Should().BeTrue();
var cfg = JsonUtils.Deserialize<V2rayConfig>(result.Data!.ToString())!;
var outbound = cfg.outbounds.First(o => o.tag == Global.ProxyTag && o.protocol == "http");
outbound.settings.address?.ToString().Should().Be("proxy.example.com");
outbound.settings.port.Should().Be(8080);
outbound.settings.user.Should().Be("user");
outbound.settings.pass.Should().Be("pass");
outbound.settings.level.Should().Be(1);
outbound.settings.headers.Should().NotBeNull();
var headers = JsonUtils.ParseJson(outbound.settings.headers.ToString());
headers["User-Agent"]!.GetValue<string>().Should().Be("v2rayN");
headers["Set-Cookie"]!.AsArray()
.Select(item => item!.GetValue<string>())
.Should().Equal("a=1", "b=2");
outbound.settings.servers.Should().BeNull();
outbound.settings.vnext.Should().BeNull();
}
[Fact]
public void GenerateClientConfigContent_PolicyGroup_ShouldExpandChildrenAndBuildBalancer()
{
@@ -146,6 +146,16 @@ public class Outboundsettings4Ray
public int? port { get; set; }
public string? user { get; set; }
public string? pass { get; set; }
public int? level { get; set; }
public string? email { get; set; }
public object? headers { get; set; }
public List<WireguardPeer4Ray>? peers { get; set; }
public bool? noKernelTun { get; set; }
@@ -5,6 +5,9 @@ public record ProtocolExtraItem
public bool? Uot { get; init; }
public string? CongestionControl { get; init; }
// http outbound
public string? HttpHeaders { get; init; }
// vmess
public string? AlterId { get; init; }
public string? VmessSecurity { get; init; }
+28 -1
View File
@@ -149,7 +149,7 @@ namespace ServiceLib.Resx {
return ResourceManager.GetString("Downloading", resourceCulture);
}
}
/// <summary>
/// 查找类似 Failed to convert configuration file 的本地化字符串。
/// </summary>
@@ -330,6 +330,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Please enter valid HTTP request headers JSON. 的本地化字符串。
/// </summary>
public static string InvalidHttpOutboundHeaders {
get {
return ResourceManager.GetString("InvalidHttpOutboundHeaders", resourceCulture);
}
}
/// <summary>
/// 查找类似 Invalid address (URL) 的本地化字符串。
/// </summary>
@@ -3222,6 +3231,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 HTTP headers 的本地化字符串。
/// </summary>
public static string TbHttpOutboundHeaders {
get {
return ResourceManager.GetString("TbHttpOutboundHeaders", resourceCulture);
}
}
/// <summary>
/// 查找类似 Port hopping interval 的本地化字符串。
/// </summary>
@@ -4986,6 +5004,15 @@ namespace ServiceLib.Resx {
}
}
/// <summary>
/// 查找类似 Custom HTTP outbound request headers as a JSON object with string or string array values. 的本地化字符串。
/// </summary>
public static string TipHttpOutboundHeaders {
get {
return ResourceManager.GetString("TipHttpOutboundHeaders", resourceCulture);
}
}
/// <summary>
/// 查找类似 *Default value raw 的本地化字符串。
/// </summary>
+10 -1
View File
@@ -1044,6 +1044,12 @@
<data name="TbHeaderType8" xml:space="preserve">
<value>Congestion control</value>
</data>
<data name="TbHttpOutboundHeaders" xml:space="preserve">
<value>HTTP headers</value>
</data>
<data name="TipHttpOutboundHeaders" xml:space="preserve">
<value>Custom HTTP outbound request headers as a JSON object with string or string array values.</value>
</data>
<data name="LvPrevProfile" xml:space="preserve">
<value>Previous proxy remarks</value>
</data>
@@ -1806,6 +1812,9 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="InvalidHy2RealmUrl" xml:space="preserve">
<value>Invalid Realm URL.</value>
</data>
<data name="InvalidHttpOutboundHeaders" xml:space="preserve">
<value>Please enter valid HTTP request headers JSON.</value>
</data>
<data name="TbHy2RealmUrlTip" xml:space="preserve">
<value>Format: realm://&lt;token&gt;@&lt;rendezvous-host&gt;[:port]/&lt;realm-name&gt;?stun=&lt;stun-host&gt;[:port]</value>
</data>
@@ -1821,4 +1830,4 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if
<data name="TbRootCertificateProviderTip" xml:space="preserve">
<value>Only applies to the v2rayN GUI's downloads and network requests. Does not affect the core's certificate validation.</value>
</data>
</root>
</root>
+10 -1
View File
@@ -1041,6 +1041,12 @@
<data name="TbHeaderType8" xml:space="preserve">
<value>拥塞控制算法</value>
</data>
<data name="TbHttpOutboundHeaders" xml:space="preserve">
<value>HTTP 请求头</value>
</data>
<data name="TipHttpOutboundHeaders" xml:space="preserve">
<value>自定义 HTTP 出站请求头,请输入值为字符串或字符串数组的 JSON 对象。</value>
</data>
<data name="LvPrevProfile" xml:space="preserve">
<value>前置代理配置别名</value>
</data>
@@ -1803,6 +1809,9 @@
<data name="InvalidHy2RealmUrl" xml:space="preserve">
<value>Realm URL 不正确。</value>
</data>
<data name="InvalidHttpOutboundHeaders" xml:space="preserve">
<value>请填写合法的HTTP请求头JSON。</value>
</data>
<data name="TbHy2RealmUrlTip" xml:space="preserve">
<value>格式:realm://&lt;token&gt;@&lt;rendezvous-host&gt;[:port]/&lt;realm-name&gt;?stun=&lt;stun-host&gt;[:port]</value>
</data>
@@ -1818,4 +1827,4 @@
<data name="TbRootCertificateProviderTip" xml:space="preserve">
<value>仅用于 v2rayN 界面程序的下载及网络请求,不影响核心的证书验证。</value>
</data>
</root>
</root>
+10 -1
View File
@@ -1041,6 +1041,12 @@
<data name="TbHeaderType8" xml:space="preserve">
<value>擁塞控制算法</value>
</data>
<data name="TbHttpOutboundHeaders" xml:space="preserve">
<value>HTTP 請求頭</value>
</data>
<data name="TipHttpOutboundHeaders" xml:space="preserve">
<value>自訂 HTTP 出站請求頭,請輸入值為字串或字串陣列的 JSON 物件。</value>
</data>
<data name="LvPrevProfile" xml:space="preserve">
<value>前置代理節點別名</value>
</data>
@@ -1797,4 +1803,7 @@
<data name="TbLegacyProtectTip" xml:space="preserve">
<value>啟用則使用 sing-box TUN ,否則使用 xray TUN</value>
</data>
</root>
<data name="InvalidHttpOutboundHeaders" xml:space="preserve">
<value>請填寫合法的HTTP請求頭JSON。</value>
</data>
</root>
@@ -136,7 +136,6 @@ public partial class CoreConfigV2rayService
break;
}
case EConfigType.SOCKS:
case EConfigType.HTTP:
{
ServersItem4Ray serversItem;
if (outbound.settings.servers.Count <= 0)
@@ -171,6 +170,31 @@ public partial class CoreConfigV2rayService
outbound.settings.vnext = null;
break;
}
case EConfigType.HTTP:
{
outbound.settings.address = _node.Address;
outbound.settings.port = _node.Port;
if(protocolExtra.HttpHeaders.IsNotEmpty())
{
outbound.settings.headers = JsonUtils.ParseJson(protocolExtra.HttpHeaders);
}
if (_node.Username.IsNotEmpty()
&& _node.Password.IsNotEmpty())
{
outbound.settings.user = _node.Username;
outbound.settings.pass = _node.Password;
outbound.settings.level = 1;
outbound.settings.email = Global.UserEMail;
}
FillOutboundMux(outbound);
outbound.settings.vnext = null;
outbound.settings.servers = null;
break;
}
case EConfigType.VLESS:
{
VnextItem4Ray vnextItem;
@@ -80,6 +80,9 @@ public class AddServerViewModel : MyReactiveObject
[Reactive]
public bool NaiveQuic { get; set; }
[Reactive]
public string HttpHeadersJson { get; set; }
[Reactive]
public string Hy2RealmUrl { get; set; }
@@ -255,7 +258,6 @@ public class AddServerViewModel : MyReactiveObject
{
await SaveServerAsync();
});
this.WhenAnyValue(x => x.Cert)
.Subscribe(_ => UpdateCertTip());
@@ -311,6 +313,7 @@ public class AddServerViewModel : MyReactiveObject
CongestionControl = protocolExtra.CongestionControl ?? string.Empty;
InsecureConcurrency = protocolExtra.InsecureConcurrency > 0 ? protocolExtra.InsecureConcurrency : null;
NaiveQuic = protocolExtra.NaiveQuic ?? false;
HttpHeadersJson = protocolExtra.HttpHeaders ?? string.Empty;
Hy2RealmUrl = protocolExtra.Hy2RealmUrl ?? string.Empty;
GeckoMinPacketSize = protocolExtra.GeckoMinPacketSize.ToInt();
GeckoMaxPacketSize = protocolExtra.GeckoMaxPacketSize.ToInt();
@@ -379,6 +382,11 @@ public class AddServerViewModel : MyReactiveObject
return;
}
}
if (HttpHeadersJson.IsNotEmpty() && JsonUtils.ParseJson(HttpHeadersJson) == null)
{
NoticeManager.Instance.Enqueue(ResUI.InvalidHttpOutboundHeaders);
return;
}
SelectedSource.CoreType = CoreType.IsNullOrEmpty() ? null : Enum.Parse<ECoreType>(CoreType);
SelectedSource.AllowInsecure = AllowInsecure ? Global.StringTrue : Global.StringFalse;
SelectedSource.MuxEnabled = MuxEnabled;
@@ -416,6 +424,7 @@ public class AddServerViewModel : MyReactiveObject
VmessSecurity = VmessSecurity.NullIfEmpty(),
VlessEncryption = VlessEncryption.NullIfEmpty(),
SsMethod = SsMethod.NullIfEmpty(),
HttpHeaders = SelectedSource.ConfigType == EConfigType.HTTP ? HttpHeadersJson.NullIfEmpty() : null,
WgPublicKey = WgPublicKey.NullIfEmpty(),
WgPresharedKey = WgPresharedKey.NullIfEmpty(),
WgInterfaceAddress = WgInterfaceAddress.NullIfEmpty(),
@@ -256,6 +256,27 @@
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}" />
<TextBlock
x:Name="tbHttpHeaders"
Grid.Row="3"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Center"
IsVisible="False"
Text="{x:Static resx:ResUI.TbHttpOutboundHeaders}"
ToolTip.Tip="{x:Static resx:ResUI.TipHttpOutboundHeaders}" />
<views:JsonEditor
x:Name="txtHttpHeadersJson"
Grid.Row="3"
Grid.Column="1"
Width="400"
MinHeight="100"
Margin="{StaticResource Margin4}"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
IsVisible="False"
ToolTip.Tip="{x:Static resx:ResUI.TipHttpOutboundHeaders}" />
</Grid>
<Grid
x:Name="gridVLESS"
@@ -1,4 +1,5 @@
using v2rayN.Desktop.Base;
using v2rayN.Desktop.Common;
namespace v2rayN.Desktop.Views;
@@ -54,8 +55,13 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
break;
case EConfigType.SOCKS:
gridSocks.IsVisible = true;
break;
case EConfigType.HTTP:
gridSocks.IsVisible = true;
tbHttpHeaders.IsVisible = true;
txtHttpHeadersJson.IsVisible = true;
break;
case EConfigType.VLESS:
@@ -148,9 +154,14 @@ public partial class AddServerWindow : WindowBase<AddServerViewModel>
break;
case EConfigType.SOCKS:
this.Bind(ViewModel, vm => vm.SelectedSource.Password, v => v.txtId4.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Username, v => v.txtSecurity4.Text).DisposeWith(disposables);
break;
case EConfigType.HTTP:
this.Bind(ViewModel, vm => vm.SelectedSource.Password, v => v.txtId4.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Username, v => v.txtSecurity4.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.HttpHeadersJson, v => v.txtHttpHeadersJson.Text).DisposeWith(disposables);
break;
case EConfigType.VLESS:
+25
View File
@@ -349,6 +349,31 @@
Width="400"
Margin="{StaticResource Margin4}"
Style="{StaticResource DefTextBox}" />
<TextBlock
x:Name="tbHttpHeaders"
Grid.Row="3"
Grid.Column="0"
Margin="{StaticResource Margin4}"
VerticalAlignment="Top"
Style="{StaticResource ToolbarTextBlock}"
Text="{x:Static resx:ResUI.TbHttpOutboundHeaders}"
Visibility="Collapsed" />
<TextBox
x:Name="txtHttpHeadersJson"
Grid.Row="3"
Grid.Column="1"
Width="400"
Margin="{StaticResource Margin4}"
materialDesign:HintAssist.Hint="{x:Static resx:ResUI.TipHttpOutboundHeaders}"
AcceptsReturn="True"
HorizontalScrollBarVisibility="Auto"
MaxLines="8"
MinLines="4"
Style="{StaticResource MyOutlinedTextBox}"
TextWrapping="NoWrap"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed" />
</Grid>
<Grid
x:Name="gridVLESS"
@@ -53,8 +53,13 @@ public partial class AddServerWindow
break;
case EConfigType.SOCKS:
gridSocks.Visibility = Visibility.Visible;
break;
case EConfigType.HTTP:
gridSocks.Visibility = Visibility.Visible;
tbHttpHeaders.Visibility = Visibility.Visible;
txtHttpHeadersJson.Visibility = Visibility.Visible;
break;
case EConfigType.VLESS:
@@ -147,9 +152,14 @@ public partial class AddServerWindow
break;
case EConfigType.SOCKS:
this.Bind(ViewModel, vm => vm.SelectedSource.Password, v => v.txtId4.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Username, v => v.txtSecurity4.Text).DisposeWith(disposables);
break;
case EConfigType.HTTP:
this.Bind(ViewModel, vm => vm.SelectedSource.Password, v => v.txtId4.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.SelectedSource.Username, v => v.txtSecurity4.Text).DisposeWith(disposables);
this.Bind(ViewModel, vm => vm.HttpHeadersJson, v => v.txtHttpHeadersJson.Text).DisposeWith(disposables);
break;
case EConfigType.VLESS: