diff --git a/v2rayN/ServiceLib/Common/Utils.cs b/v2rayN/ServiceLib/Common/Utils.cs index 2075342a..b2b2bf6e 100644 --- a/v2rayN/ServiceLib/Common/Utils.cs +++ b/v2rayN/ServiceLib/Common/Utils.cs @@ -1,4 +1,6 @@ using System.Collections.Specialized; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using System.Security.Principal; using CliWrap; using CliWrap.Buffered; @@ -762,6 +764,83 @@ public class Utils return null; } + public static async Task GetCertPem(string target, string serverName) + { + try + { + var (domain, _, port, _) = ParseUrl(target); + + using var client = new TcpClient(); + await client.ConnectAsync(domain, port > 0 ? port : 443); + + using var ssl = new SslStream(client.GetStream(), false, + (sender, cert, chain, errors) => true); + + await ssl.AuthenticateAsClientAsync(serverName); + + var remote = ssl.RemoteCertificate; + if (remote == null) + { + return null; + } + + var leaf = new X509Certificate2(remote); + return ExportCertToPem(leaf); + } + catch (Exception ex) + { + Logging.SaveLog(_tag, ex); + return null; + } + } + + public static async Task> GetCertChainPem(string target, string serverName) + { + try + { + var pemList = new List(); + var (domain, _, port, _) = ParseUrl(target); + + using var client = new TcpClient(); + await client.ConnectAsync(domain, port > 0 ? port : 443); + + using var ssl = new SslStream(client.GetStream(), false, + (sender, cert, chain, errors) => true); + + await ssl.AuthenticateAsClientAsync(serverName); + + // TODO: Pinning Trusted CA certificates to avoid MITM + + if (ssl.RemoteCertificate is not X509Certificate2 certChain) + { + return pemList; + } + + var chain = new X509Chain(); + chain.Build(certChain); + + foreach (var element in chain.ChainElements) + { + var pem = ExportCertToPem(element.Certificate); + pemList.Add(pem); + } + + return pemList; + } + catch (Exception ex) + { + Logging.SaveLog(_tag, ex); + return new List(); + } + } + + public static string ExportCertToPem(X509Certificate2 cert) + { + var der = cert.Export(X509ContentType.Cert); + var b64 = Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks); + return $"-----BEGIN CERTIFICATE-----\n{b64}\n-----END CERTIFICATE-----\n"; + } + #endregion 杂项 #region TempPath diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index 7d2ca056..72addec4 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -252,6 +252,7 @@ public static class ConfigHandler item.Mldsa65Verify = profileItem.Mldsa65Verify; item.Extra = profileItem.Extra; item.MuxEnabled = profileItem.MuxEnabled; + item.Cert = profileItem.Cert; } var ret = item.ConfigType switch diff --git a/v2rayN/ServiceLib/Models/ProfileItem.cs b/v2rayN/ServiceLib/Models/ProfileItem.cs index fa84ac59..b832a5e7 100644 --- a/v2rayN/ServiceLib/Models/ProfileItem.cs +++ b/v2rayN/ServiceLib/Models/ProfileItem.cs @@ -141,4 +141,5 @@ public class ProfileItem : ReactiveObject public string Mldsa65Verify { get; set; } public string Extra { get; set; } public bool? MuxEnabled { get; set; } + public string Cert { get; set; } } diff --git a/v2rayN/ServiceLib/Models/SingboxConfig.cs b/v2rayN/ServiceLib/Models/SingboxConfig.cs index 9474631b..6dde5e7c 100644 --- a/v2rayN/ServiceLib/Models/SingboxConfig.cs +++ b/v2rayN/ServiceLib/Models/SingboxConfig.cs @@ -181,6 +181,7 @@ public class Tls4Sbox public bool? fragment { get; set; } public string? fragment_fallback_delay { get; set; } public bool? record_fragment { get; set; } + public List? certificate { get; set; } } public class Multiplex4Sbox diff --git a/v2rayN/ServiceLib/Models/V2rayConfig.cs b/v2rayN/ServiceLib/Models/V2rayConfig.cs index 22dcf9f4..e10cd0d9 100644 --- a/v2rayN/ServiceLib/Models/V2rayConfig.cs +++ b/v2rayN/ServiceLib/Models/V2rayConfig.cs @@ -354,6 +354,14 @@ public class TlsSettings4Ray public string? shortId { get; set; } public string? spiderX { get; set; } public string? mldsa65Verify { get; set; } + public List? certificates { get; set; } + public bool? disableSystemRoot { get; set; } +} + +public class CertificateSettings4Ray +{ + public List? certificate { get; set; } + public string? usage { get; set; } } public class TcpSettings4Ray diff --git a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs index 90a44ae5..3fcfc0b2 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.Designer.cs +++ b/v2rayN/ServiceLib/Resx/ResUI.Designer.cs @@ -2562,6 +2562,25 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Certificate Pinning 的本地化字符串。 + /// + public static string TbCertPinning { + get { + return ResourceManager.GetString("TbCertPinning", resourceCulture); + } + } + + /// + /// 查找类似 Server certificate (PEM format, optional). Entering a certificate will pin it. + ///Do not use the "Fetch Certificate" button when "Allow Insecure" is enabled. 的本地化字符串。 + /// + public static string TbCertPinningTips { + get { + return ResourceManager.GetString("TbCertPinningTips", resourceCulture); + } + } + /// /// 查找类似 Clear system proxy 的本地化字符串。 /// @@ -2769,6 +2788,15 @@ namespace ServiceLib.Resx { } } + /// + /// 查找类似 Fetch Certificate 的本地化字符串。 + /// + public static string TbFetchCert { + get { + return ResourceManager.GetString("TbFetchCert", resourceCulture); + } + } + /// /// 查找类似 Fingerprint 的本地化字符串。 /// diff --git a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx index edbd21da..f89c1343 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fa-Ir.resx @@ -1602,4 +1602,14 @@ Auto add filtered configuration from subscription groups + + Certificate Pinning + + + Server certificate (PEM format, optional). Entering a certificate will pin it. +Do not use the "Fetch Certificate" button when "Allow Insecure" is enabled. + + + Fetch Certificate + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.fr.resx b/v2rayN/ServiceLib/Resx/ResUI.fr.resx index 542d9ef9..9e46bad5 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.fr.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.fr.resx @@ -1599,4 +1599,14 @@ Auto add filtered configuration from subscription groups + + Certificate Pinning + + + Server certificate (PEM format, optional). Entering a certificate will pin it. +Do not use the "Fetch Certificate" button when "Allow Insecure" is enabled. + + + Fetch Certificate + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.hu.resx b/v2rayN/ServiceLib/Resx/ResUI.hu.resx index e61f279e..1b47e7e6 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.hu.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.hu.resx @@ -1602,4 +1602,14 @@ Auto add filtered configuration from subscription groups + + Certificate Pinning + + + Server certificate (PEM format, optional). Entering a certificate will pin it. +Do not use the "Fetch Certificate" button when "Allow Insecure" is enabled. + + + Fetch Certificate + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.resx b/v2rayN/ServiceLib/Resx/ResUI.resx index f1123407..62a80bf3 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.resx @@ -1602,4 +1602,14 @@ Auto add filtered configuration from subscription groups + + Certificate Pinning + + + Server certificate (PEM format, optional). Entering a certificate will pin it. +Do not use the "Fetch Certificate" button when "Allow Insecure" is enabled. + + + Fetch Certificate + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Resx/ResUI.ru.resx b/v2rayN/ServiceLib/Resx/ResUI.ru.resx index 14edb9b8..568ecdca 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.ru.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.ru.resx @@ -1602,4 +1602,14 @@ Auto add filtered configuration from subscription groups + + Certificate Pinning + + + Server certificate (PEM format, optional). Entering a certificate will pin it. +Do not use the "Fetch Certificate" button when "Allow Insecure" is enabled. + + + Fetch Certificate + \ 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 cfb89284..4c449915 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hans.resx @@ -1599,4 +1599,14 @@ 自动从订阅分组添加过滤后的配置 + + 固定证书 + + + 服务器证书(PEM 格式,可选)。填入后将固定该证书。 +启用“跳过证书验证”时,请勿使用 '获取证书'。 + + + 获取证书 + \ 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 4a3822b4..cf94f135 100644 --- a/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx +++ b/v2rayN/ServiceLib/Resx/ResUI.zh-Hant.resx @@ -1599,4 +1599,14 @@ 自動從訂閱分組新增過濾後的配置 + + Certificate Pinning + + + Server certificate (PEM format, optional). Entering a certificate will pin it. +Do not use the "Fetch Certificate" button when "Allow Insecure" is enabled. + + + Fetch Certificate + \ No newline at end of file diff --git a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs index 84111652..1f1e68a0 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/Singbox/SingboxOutboundService.cs @@ -204,54 +204,6 @@ public partial class CoreConfigSingboxService return await Task.FromResult(null); } - private async Task GenGroupOutbound(ProfileItem node, SingboxConfig singboxConfig, string baseTagName = Global.ProxyTag, bool ignoreOriginChain = false) - { - try - { - if (!node.ConfigType.IsGroupType()) - { - return -1; - } - var hasCycle = ProfileGroupItemManager.HasCycle(node.IndexId); - if (hasCycle) - { - return -1; - } - - var (childProfiles, profileGroupItem) = await ProfileGroupItemManager.GetChildProfileItems(node.IndexId); - if (childProfiles.Count <= 0) - { - return -1; - } - switch (node.ConfigType) - { - case EConfigType.PolicyGroup: - if (ignoreOriginChain) - { - await GenOutboundsList(childProfiles, singboxConfig, profileGroupItem.MultipleLoad, baseTagName); - } - else - { - await GenOutboundsListWithChain(childProfiles, singboxConfig, profileGroupItem.MultipleLoad, baseTagName); - } - - break; - - case EConfigType.ProxyChain: - await GenChainOutboundsList(childProfiles, singboxConfig, baseTagName); - break; - - default: - break; - } - } - catch (Exception ex) - { - Logging.SaveLog(_tag, ex); - } - return await Task.FromResult(0); - } - private async Task GenOutboundMux(ProfileItem node, Outbound4Sbox outbound) { try @@ -280,7 +232,7 @@ public partial class CoreConfigSingboxService { try { - if (node.StreamSecurity == Global.StreamSecurityReality || node.StreamSecurity == Global.StreamSecurity) + if (node.StreamSecurity is Global.StreamSecurityReality or Global.StreamSecurity) { var server_name = string.Empty; if (node.Sni.IsNotEmpty()) @@ -307,7 +259,18 @@ public partial class CoreConfigSingboxService fingerprint = node.Fingerprint.IsNullOrEmpty() ? _config.CoreBasicItem.DefFingerprint : node.Fingerprint }; } - if (node.StreamSecurity == Global.StreamSecurityReality) + if (node.StreamSecurity == Global.StreamSecurity) + { + var certs = node.Cert + ?.Split("-----END CERTIFICATE-----", StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.TrimEx()) + .Where(s => !s.IsNullOrEmpty()) + .Select(s => s + "\n-----END CERTIFICATE-----") + .Select(s => s.Replace("\r\n", "\n")) + .ToList() ?? new(); + tls.certificate = certs.Count > 0 ? certs : null; + } + else { tls.reality = new Reality4Sbox() { @@ -404,6 +367,54 @@ public partial class CoreConfigSingboxService return await Task.FromResult(0); } + private async Task GenGroupOutbound(ProfileItem node, SingboxConfig singboxConfig, string baseTagName = Global.ProxyTag, bool ignoreOriginChain = false) + { + try + { + if (!node.ConfigType.IsGroupType()) + { + return -1; + } + var hasCycle = ProfileGroupItemManager.HasCycle(node.IndexId); + if (hasCycle) + { + return -1; + } + + var (childProfiles, profileGroupItem) = await ProfileGroupItemManager.GetChildProfileItems(node.IndexId); + if (childProfiles.Count <= 0) + { + return -1; + } + switch (node.ConfigType) + { + case EConfigType.PolicyGroup: + if (ignoreOriginChain) + { + await GenOutboundsList(childProfiles, singboxConfig, profileGroupItem.MultipleLoad, baseTagName); + } + else + { + await GenOutboundsListWithChain(childProfiles, singboxConfig, profileGroupItem.MultipleLoad, baseTagName); + } + + break; + + case EConfigType.ProxyChain: + await GenChainOutboundsList(childProfiles, singboxConfig, baseTagName); + break; + + default: + break; + } + } + catch (Exception ex) + { + Logging.SaveLog(_tag, ex); + } + return await Task.FromResult(0); + } + private async Task GenMoreOutbounds(ProfileItem node, SingboxConfig singboxConfig) { if (node.Subid.IsNullOrEmpty()) diff --git a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs index 39338c77..46169c37 100644 --- a/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs +++ b/v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayOutboundService.cs @@ -245,6 +245,13 @@ public partial class CoreConfigV2rayService var host = node.RequestHost.TrimEx(); var path = node.Path.TrimEx(); var sni = node.Sni.TrimEx(); + var certs = node.Cert + ?.Split("-----END CERTIFICATE-----", StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.TrimEx()) + .Where(s => !s.IsNullOrEmpty()) + .Select(s => s + "\n-----END CERTIFICATE-----") + .Select(s => s.Replace("\r\n", "\n")) + .ToList() ?? new(); var useragent = ""; if (!_config.CoreBasicItem.DefUserAgent.IsNullOrEmpty()) { @@ -277,6 +284,21 @@ public partial class CoreConfigV2rayService { tlsSettings.serverName = Utils.String2List(host)?.First(); } + if (certs.Count > 0) + { + var certsettings = new List(); + foreach (var cert in certs) + { + var certPerLine = cert.Split("\n").ToList(); + certsettings.Add(new CertificateSettings4Ray + { + certificate = certPerLine, + usage = "verify", + }); + } + tlsSettings.certificates = certsettings; + tlsSettings.disableSystemRoot = true; + } streamSettings.tlsSettings = tlsSettings; } diff --git a/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs b/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs index addd5bcc..d42c4db3 100644 --- a/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/AddServerViewModel.cs @@ -8,6 +8,10 @@ public class AddServerViewModel : MyReactiveObject [Reactive] public string? CoreType { get; set; } + [Reactive] + public string Cert { get; set; } + + public ReactiveCommand FetchCertCmd { get; } public ReactiveCommand SaveCmd { get; } public AddServerViewModel(ProfileItem profileItem, Func>? updateView) @@ -15,6 +19,10 @@ public class AddServerViewModel : MyReactiveObject _config = AppManager.Instance.Config; _updateView = updateView; + FetchCertCmd = ReactiveCommand.CreateFromTask(async () => + { + await FetchCert(); + }); SaveCmd = ReactiveCommand.CreateFromTask(async () => { await SaveServerAsync(); @@ -33,6 +41,7 @@ public class AddServerViewModel : MyReactiveObject SelectedSource = JsonUtils.DeepCopy(profileItem); } CoreType = SelectedSource?.CoreType?.ToString(); + Cert = SelectedSource?.Cert?.ToString() ?? string.Empty; } private async Task SaveServerAsync() @@ -77,6 +86,7 @@ public class AddServerViewModel : MyReactiveObject } } SelectedSource.CoreType = CoreType.IsNullOrEmpty() ? null : (ECoreType)Enum.Parse(typeof(ECoreType), CoreType); + SelectedSource.Cert = Cert.IsNullOrEmpty() ? null : Cert; if (await ConfigHandler.AddServer(_config, SelectedSource) == 0) { @@ -88,4 +98,23 @@ public class AddServerViewModel : MyReactiveObject NoticeManager.Instance.Enqueue(ResUI.OperationFailed); } } + + private async Task FetchCert() + { + if (SelectedSource.StreamSecurity != Global.StreamSecurity) + { + return; + } + var domain = SelectedSource.Address; + var serverName = SelectedSource.Sni.IsNullOrEmpty() ? SelectedSource.Address : SelectedSource.Sni; + if (!Utils.IsDomain(serverName)) + { + return; + } + if (SelectedSource.Port > 0) + { + domain += $":{SelectedSource.Port}"; + } + Cert = await Utils.GetCertPem(domain, serverName); + } } diff --git a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml index 7e3dda10..f9848f63 100644 --- a/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml +++ b/v2rayN/v2rayN.Desktop/Views/AddServerWindow.axaml @@ -713,7 +713,7 @@ Grid.Row="7" ColumnDefinitions="180,Auto" IsVisible="False" - RowDefinitions="Auto,Auto,Auto,Auto,Auto"> + RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"> + + + + this.Bind(ViewModel, vm => vm.SelectedSource.AllowInsecure, v => v.cmbAllowInsecure.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SelectedSource.Fingerprint, v => v.cmbFingerprint.SelectedValue).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SelectedSource.Alpn, v => v.cmbAlpn.SelectedValue).DisposeWith(disposables); + this.Bind(ViewModel, vm => vm.Cert, v => v.txtCert.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.SelectedValue).DisposeWith(disposables); @@ -193,6 +194,7 @@ public partial class AddServerWindow : WindowBase this.Bind(ViewModel, vm => vm.SelectedSource.SpiderX, v => v.txtSpiderX.Text).DisposeWith(disposables); this.Bind(ViewModel, vm => vm.SelectedSource.Mldsa65Verify, v => v.txtMldsa65Verify.Text).DisposeWith(disposables); + this.BindCommand(ViewModel, vm => vm.FetchCertCmd, v => v.btnFetchCert).DisposeWith(disposables); this.BindCommand(ViewModel, vm => vm.SaveCmd, v => v.btnSave).DisposeWith(disposables); }); diff --git a/v2rayN/v2rayN/Views/AddServerWindow.xaml b/v2rayN/v2rayN/Views/AddServerWindow.xaml index b293a115..dff7abca 100644 --- a/v2rayN/v2rayN/Views/AddServerWindow.xaml +++ b/v2rayN/v2rayN/Views/AddServerWindow.xaml @@ -928,6 +928,7 @@ + @@ -995,6 +996,47 @@ Width="200" Margin="{StaticResource Margin4}" Style="{StaticResource DefComboBox}" /> + + + + + + +