From 4562d4cf0076646af33eb17e922e1687877fc2bf Mon Sep 17 00:00:00 2001 From: 2dust <31833384+2dust@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:34:13 +0800 Subject: [PATCH] Add ECH config support to profile and UI Introduces EchConfigList and EchForceQuery fields to ProfileItem and V2rayConfig models, updates related handlers and services to process these fields, and extends the AddServerWindow UI to allow user input for ECH configuration. Also adds localization entries for the new fields and updates extension methods for string handling. --- v2rayN/ServiceLib/Common/Extension.cs | 14 ++++---- v2rayN/ServiceLib/Global.cs | 7 ++++ v2rayN/ServiceLib/Handler/ConfigHandler.cs | 2 ++ v2rayN/ServiceLib/Handler/Fmt/BaseFmt.cs | 5 +++ v2rayN/ServiceLib/Models/ProfileItem.cs | 2 ++ v2rayN/ServiceLib/Models/V2rayConfig.cs | 2 ++ v2rayN/ServiceLib/Resx/ResUI.Designer.cs | 20 ++++++++++- v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx | 6 ++++ v2rayN/ServiceLib/Resx/ResUI.fr.resx | 8 ++++- v2rayN/ServiceLib/Resx/ResUI.hu.resx | 6 ++++ v2rayN/ServiceLib/Resx/ResUI.resx | 6 ++++ v2rayN/ServiceLib/Resx/ResUI.ru.resx | 6 ++++ v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx | 6 ++++ v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx | 6 ++++ .../CoreConfig/Singbox/SingboxDnsService.cs | 4 +-- .../Singbox/SingboxOutboundService.cs | 10 +++--- .../Singbox/SingboxRoutingService.cs | 2 +- .../CoreConfig/V2ray/V2rayOutboundService.cs | 8 +++-- .../Views/AddServerWindow.axaml | 32 +++++++++++++++-- .../Views/AddServerWindow.axaml.cs | 4 +++ v2rayN/v2rayN/Views/AddServerWindow.xaml | 35 ++++++++++++++++++- v2rayN/v2rayN/Views/AddServerWindow.xaml.cs | 5 +++ 22 files changed, 172 insertions(+), 24 deletions(-) diff --git a/v2rayN/ServiceLib/Common/Extension.cs b/v2rayN/ServiceLib/Common/Extension.cs index d36b8169..b6ee8154 100644 --- a/v2rayN/ServiceLib/Common/Extension.cs +++ b/v2rayN/ServiceLib/Common/Extension.cs @@ -6,17 +6,17 @@ public static class Extension { public static bool IsNullOrEmpty([NotNullWhen(false)] this string? value) { - return string.IsNullOrEmpty(value) || string.IsNullOrWhiteSpace(value); - } - - public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? value) - { - return string.IsNullOrWhiteSpace(value); + return string.IsNullOrWhiteSpace(value) || string.IsNullOrEmpty(value); } public static bool IsNotEmpty([NotNullWhen(false)] this string? value) { - return !string.IsNullOrEmpty(value); + return !string.IsNullOrWhiteSpace(value); + } + + public static string? NullIfEmpty(this string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value; } public static bool BeginWithAny(this string s, IEnumerable chars) diff --git a/v2rayN/ServiceLib/Global.cs b/v2rayN/ServiceLib/Global.cs index f0a24028..2a4ba9a5 100644 --- a/v2rayN/ServiceLib/Global.cs +++ b/v2rayN/ServiceLib/Global.cs @@ -626,5 +626,12 @@ public class Global "" ]; + public static readonly List EchForceQuerys = + [ + "none", + "half", + "full", + ]; + #endregion const } diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index 41f990b7..3718a73f 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -253,6 +253,8 @@ public static class ConfigHandler item.Extra = profileItem.Extra; item.MuxEnabled = profileItem.MuxEnabled; item.Cert = profileItem.Cert; + item.EchConfigList = profileItem.EchConfigList; + item.EchForceQuery = profileItem.EchForceQuery; } var ret = item.ConfigType switch diff --git a/v2rayN/ServiceLib/Handler/Fmt/BaseFmt.cs b/v2rayN/ServiceLib/Handler/Fmt/BaseFmt.cs index 3665afdf..34f30505 100644 --- a/v2rayN/ServiceLib/Handler/Fmt/BaseFmt.cs +++ b/v2rayN/ServiceLib/Handler/Fmt/BaseFmt.cs @@ -70,6 +70,10 @@ public class BaseFmt } ToUriQueryAllowInsecure(item, ref dicQuery); } + if (item.EchConfigList.IsNotEmpty()) + { + dicQuery.Add("ech", Utils.UrlEncode(item.EchConfigList)); + } dicQuery.Add("type", item.Network.IsNotEmpty() ? item.Network : nameof(ETransport.tcp)); @@ -209,6 +213,7 @@ public class BaseFmt item.ShortId = GetQueryDecoded(query, "sid"); item.SpiderX = GetQueryDecoded(query, "spx"); item.Mldsa65Verify = GetQueryDecoded(query, "pqv"); + item.EchConfigList = GetQueryDecoded(query, "ech"); if (_allowInsecureArray.Any(k => GetQueryDecoded(query, k) == "1")) { diff --git a/v2rayN/ServiceLib/Models/ProfileItem.cs b/v2rayN/ServiceLib/Models/ProfileItem.cs index 00ed38da..fa7b16ba 100644 --- a/v2rayN/ServiceLib/Models/ProfileItem.cs +++ b/v2rayN/ServiceLib/Models/ProfileItem.cs @@ -161,4 +161,6 @@ public class ProfileItem : ReactiveObject public string Extra { get; set; } public bool? MuxEnabled { get; set; } public string Cert { get; set; } + public string EchConfigList { get; set; } + public string EchForceQuery { get; set; } } diff --git a/v2rayN/ServiceLib/Models/V2rayConfig.cs b/v2rayN/ServiceLib/Models/V2rayConfig.cs index b84a6a66..2d9c1d2d 100644 --- a/v2rayN/ServiceLib/Models/V2rayConfig.cs +++ b/v2rayN/ServiceLib/Models/V2rayConfig.cs @@ -356,6 +356,8 @@ public class TlsSettings4Ray public string? mldsa65Verify { get; set; } public List? certificates { get; set; } public bool? disableSystemRoot { get; set; } + public string? echConfigList { get; set; } + public string? echForceQuery { get; set; } } public class CertificateSettings4Ray diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index 64ca0292..d7c7b43e 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -19,7 +19,7 @@ namespace ServiceLib.Resx { // 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。 // 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen // (以 /str 作为命令选项),或重新生成 VS 项目。 - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class ResUI { @@ -2790,6 +2790,24 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 EchConfigList 的本地化字符串。 + /// + public static string TbEchConfigList { + get { + return ResourceManager.GetString("TbEchConfigList", resourceCulture); + } + } + + /// + /// 查找类似 EchForceQuery 的本地化字符串。 + /// + public static string TbEchForceQuery { + get { + return ResourceManager.GetString("TbEchForceQuery", resourceCulture); + } + } + /// /// 查找类似 Edit 的本地化字符串。 /// diff --git a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx index 4f5467b3..47bae402 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx @@ -1641,4 +1641,10 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Configuration Item 2, Select and add from self-built + + EchConfigList + + + EchForceQuery + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.fr.resx b/v2rayN/ServiceLib/Resx/ResUI.fr.resx index db2c6abf..a2c95286 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fr.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fr.resx @@ -1638,4 +1638,10 @@ Si un certificat auto-signé est utilisé ou si le système contient une CA non Élément de config 2 : choisir et ajouter depuis self-hosted - + + EchConfigList + + + EchForceQuery + + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.hu.resx b/v2rayN/ServiceLib/Resx/ResUI.hu.resx index 0841b111..9a1235da 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.hu.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.hu.resx @@ -1641,4 +1641,10 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Configuration Item 2, Select and add from self-built + + EchConfigList + + + EchForceQuery + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index ceb7c91e..569798da 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1641,4 +1641,10 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Configuration Item 2, Select and add from self-built + + EchConfigList + + + EchForceQuery + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.ru.resx b/v2rayN/ServiceLib/Resx/ResUI.ru.resx index 33102733..6e1ee37c 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.ru.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.ru.resx @@ -1641,4 +1641,10 @@ The "Get Certificate" action may fail if a self-signed certificate is used or if Configuration Item 2, Select and add from self-built + + EchConfigList + + + EchForceQuery + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx index e53c4203..d37cd62c 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1638,4 +1638,10 @@ 子配置项二,从自建中选择添加 + + EchConfigList + + + EchForceQuery + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx index 92421199..89edc2f3 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx @@ -1638,4 +1638,10 @@ 子配置項二,從自建中選擇新增 + + EchConfigList + + + EchForceQuery + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxDnsService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxDnsService.cs index f6190ea9..c273eb57 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxDnsService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxDnsService.cs @@ -157,13 +157,13 @@ public partial class CoreConfigSingboxService new Rule4Sbox { server = Global.SingboxRemoteDNSTag, - strategy = simpleDNSItem.SingboxStrategy4Proxy.IsNullOrEmpty() ? null : simpleDNSItem.SingboxStrategy4Proxy, + strategy = simpleDNSItem.SingboxStrategy4Proxy.NullIfEmpty(), clash_mode = ERuleMode.Global.ToString() }, new Rule4Sbox { server = Global.SingboxDirectDNSTag, - strategy = simpleDNSItem.SingboxStrategy4Direct.IsNullOrEmpty() ? null : simpleDNSItem.SingboxStrategy4Direct, + strategy = simpleDNSItem.SingboxStrategy4Direct.NullIfEmpty(), clash_mode = ERuleMode.Direct.ToString() } }); diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs index 79b10176..50b1807f 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs @@ -354,7 +354,7 @@ public partial class CoreConfigSingboxService case nameof(ETransport.h2): transport.type = nameof(ETransport.http); transport.host = node.RequestHost.IsNullOrEmpty() ? null : Utils.String2List(node.RequestHost); - transport.path = node.Path.IsNullOrEmpty() ? null : node.Path; + transport.path = node.Path.NullIfEmpty(); break; case nameof(ETransport.tcp): //http @@ -362,7 +362,7 @@ public partial class CoreConfigSingboxService { transport.type = nameof(ETransport.http); transport.host = node.RequestHost.IsNullOrEmpty() ? null : Utils.String2List(node.RequestHost); - transport.path = node.Path.IsNullOrEmpty() ? null : node.Path; + transport.path = node.Path.NullIfEmpty(); } break; @@ -396,7 +396,7 @@ public partial class CoreConfigSingboxService } } - transport.path = wsPath.IsNullOrEmpty() ? null : wsPath; + transport.path = wsPath.NullIfEmpty(); if (node.RequestHost.IsNotEmpty()) { transport.headers = new() @@ -408,8 +408,8 @@ public partial class CoreConfigSingboxService case nameof(ETransport.httpupgrade): transport.type = nameof(ETransport.httpupgrade); - transport.path = node.Path.IsNullOrEmpty() ? null : node.Path; - transport.host = node.RequestHost.IsNullOrEmpty() ? null : node.RequestHost; + transport.path = node.Path.NullIfEmpty(); + transport.host = node.RequestHost.NullIfEmpty(); break; diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs index b77a227b..b9e1cc1f 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxRoutingService.cs @@ -113,7 +113,7 @@ public partial class CoreConfigSingboxService clash_mode = ERuleMode.Global.ToString() }); - var domainStrategy = _config.RoutingBasicItem.DomainStrategy4Singbox.IsNullOrEmpty() ? null : _config.RoutingBasicItem.DomainStrategy4Singbox; + var domainStrategy = _config.RoutingBasicItem.DomainStrategy4Singbox.NullIfEmpty(); var defaultRouting = await ConfigHandler.GetDefaultRouting(_config); if (defaultRouting.DomainStrategy4Singbox.IsNotEmpty()) { diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs index 6389f86a..d4285a09 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs @@ -272,7 +272,9 @@ public partial class CoreConfigV2rayService { allowInsecure = Utils.ToBool(node.AllowInsecure.IsNullOrEmpty() ? _config.CoreBasicItem.DefAllowInsecure.ToString().ToLower() : node.AllowInsecure), alpn = node.GetAlpn(), - fingerprint = node.Fingerprint.IsNullOrEmpty() ? _config.CoreBasicItem.DefFingerprint : node.Fingerprint + fingerprint = node.Fingerprint.IsNullOrEmpty() ? _config.CoreBasicItem.DefFingerprint : node.Fingerprint, + echConfigList = node.EchConfigList.NullIfEmpty(), + echForceQuery = node.EchForceQuery.NullIfEmpty() }; if (sni.IsNotEmpty()) { @@ -340,7 +342,7 @@ public partial class CoreConfigV2rayService kcpSettings.header = new Header4Ray { type = node.HeaderType, - domain = host.IsNullOrEmpty() ? null : host + domain = host.NullIfEmpty() }; if (path.IsNotEmpty()) { @@ -450,7 +452,7 @@ public partial class CoreConfigV2rayService case nameof(ETransport.grpc): GrpcSettings4Ray grpcSettings = new() { - authority = host.IsNullOrEmpty() ? null : host, + authority = host.NullIfEmpty(), serviceName = path, multiMode = node.HeaderType == Global.GrpcMultiMode, idle_timeout = _config.GrpcItem.IdleTimeout, diff --git a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml index 9556f26a..e7a45a77 100644 --- a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml @@ -713,7 +713,7 @@ Grid.Row="7" ColumnDefinitions="300,Auto" IsVisible="False" - RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"> + RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto"> - + + + + + + diff --git a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml.cs b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml.cs index fe37f6f5..d7799edc 100644 --- a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml.cs +++ b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml.cs @@ -28,6 +28,7 @@ public partial class AddServerWindow : WindowBase cmbFingerprint2.ItemsSource = Global.Fingerprints; cmbAllowInsecure.ItemsSource = Global.AllowInsecure; cmbAlpn.ItemsSource = Global.Alpns; + cmbEchForceQuery.ItemsSource = Global.EchForceQuerys; var lstStreamSecurity = new List(); lstStreamSecurity.Add(string.Empty); @@ -187,6 +188,9 @@ public partial class AddServerWindow : WindowBase this.Bind(ViewModel, vm => vm.SelectedSource.Alpn, v => v.cmbAlpn.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.CertTip, v => v.labCertPinning.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.Cert, v => v.txtCert.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.SelectedSource.EchConfigList, v => v.txtEchConfigList.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.SelectedSource.EchForceQuery, v => v.cmbEchForceQuery.SelectedValue).DisposeWith(disposables); + //reality this.Bind(ViewModel, vm => vm.SelectedSource.Sni, v => v.txtSNI2.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SelectedSource.Fingerprint, v => v.cmbFingerprint2.SelectedValue).DisposeWith(disposables); diff --git a/v2rayN/v2rayN/Views/AddServerWindow.xaml b/v2rayN/v2rayN/Views/AddServerWindow.xaml index 99778dd2..980496c8 100644 --- a/v2rayN/v2rayN/Views/AddServerWindow.xaml +++ b/v2rayN/v2rayN/Views/AddServerWindow.xaml @@ -929,6 +929,8 @@ + + @@ -1003,9 +1005,40 @@ Margin="{StaticResource Margin4}" VerticalAlignment="Center" Style="{StaticResource ToolbarTextBlock}" + Text="{x:Static resx:ResUI.TbEchConfigList}" /> + + + + + + diff --git a/v2rayN/v2rayN/Views/AddServerWindow.xaml.cs b/v2rayN/v2rayN/Views/AddServerWindow.xaml.cs index 2eadd06a..09a6b507 100644 --- a/v2rayN/v2rayN/Views/AddServerWindow.xaml.cs +++ b/v2rayN/v2rayN/Views/AddServerWindow.xaml.cs @@ -23,6 +23,7 @@ public partial class AddServerWindow cmbFingerprint2.ItemsSource = Global.Fingerprints; cmbAllowInsecure.ItemsSource = Global.AllowInsecure; cmbAlpn.ItemsSource = Global.Alpns; + cmbEchForceQuery.ItemsSource = Global.EchForceQuerys; var lstStreamSecurity = new List(); lstStreamSecurity.Add(string.Empty); @@ -182,6 +183,10 @@ public partial class AddServerWindow this.Bind(ViewModel, vm => vm.SelectedSource.Alpn, v => v.cmbAlpn.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.CertTip, v => v.labCertPinning.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.Cert, v => v.txtCert.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.Cert, v => v.txtCert.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.SelectedSource.EchConfigList, v => v.txtEchConfigList.Text).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.SelectedSource.EchForceQuery, v => v.cmbEchForceQuery.Text).DisposeWith(disposables); + //reality this.Bind(ViewModel, vm => vm.SelectedSource.Sni, v => v.txtSNI2.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SelectedSource.Fingerprint, v => v.cmbFingerprint2.Text).DisposeWith(disposables);