From d51e6d0c3b9c5dfd169a8e5753a705642ac84f74 Mon Sep 17 00:00:00 2001 From: PtJade Ceramic <185668489+PtJade-Ceramic@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:12:32 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=A8=20=E4=BF=AE=E5=A4=8D=E5=8A=A0?= =?UTF-8?q?=E9=80=9F=E5=90=8E=20Git=20for=20Windows=20SDK=20=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E8=AE=BF=E9=97=AE=20GitHub=EF=BC=9A=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=20CRL=20=E5=B9=B6=E4=B8=BA=20MITM=20?= =?UTF-8?q?=E8=AF=81=E4=B9=A6=E6=B7=BB=E5=8A=A0=E5=90=8A=E9=94=80=E5=88=86?= =?UTF-8?q?=E5=8F=91=E7=82=B9=20(#4123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KestrelServerOptionsExtensions.cs | 124 ++++++---- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../IReverseProxyConfig.GlobalListener.cs | 7 + .../Certificate/CertGenerator.cs | 42 +++- .../HttpServer/Certificates/CertService.cs | 34 ++- .../HttpServer/Middleware/CrlMiddleware.cs | 41 +++ .../YarpReverseProxyServiceImpl.Startup.cs | 4 + .../YarpReverseProxyServiceImpl.cs | 1 + .../CertGeneratorCrlUnitTest.cs | 234 ++++++++++++++++++ 9 files changed, 442 insertions(+), 46 deletions(-) create mode 100644 src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/HttpServer/Middleware/CrlMiddleware.cs create mode 100644 src/BD.WTTS.UnitTest/CertGeneratorCrlUnitTest.cs diff --git a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Extensions/KestrelServerOptionsExtensions.cs b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Extensions/KestrelServerOptionsExtensions.cs index c342a38607d..ccd6ae7eda5 100644 --- a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Extensions/KestrelServerOptionsExtensions.cs +++ b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Extensions/KestrelServerOptionsExtensions.cs @@ -33,21 +33,23 @@ public static void ListenHttpProxy(this KestrelServerOptions options) $"TCP port {httpProxyPort} is already occupied by other processes."); } - options.Listen(IReverseProxyService.Constants.Instance.ProxyIp, httpProxyPort, listen => - { - listen.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; - var proxyMiddleware = options.ApplicationServices.GetRequiredService(); - var tunnelMiddleware = options.ApplicationServices.GetRequiredService(); - - listen.UseFlowAnalyze(); - listen.Use(next => context => proxyMiddleware.InvokeAsync(next, context)); - listen.UseTls(); - listen.Use(next => context => tunnelMiddleware.InvokeAsync(next, context)); - }); - - options.GetLogger().LogInformation( + var proxyMiddleware = options.ApplicationServices.GetRequiredService(); + var tunnelMiddleware = options.ApplicationServices.GetRequiredService(); + + options.ListenAndLog( + IReverseProxyService.Constants.Instance.ProxyIp, + httpProxyPort, + listen => + { + listen.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + listen.UseFlowAnalyze(); + listen.Use(next => context => proxyMiddleware.InvokeAsync(next, context)); + listen.UseTls(); + listen.Use(next => context => tunnelMiddleware.InvokeAsync(next, context)); + }, "Listened http://{ProxyIp}:{httpProxyPort}, HTTP proxy service startup completed.", - IReverseProxyService.Constants.Instance.ProxyIp, httpProxyPort); + IReverseProxyService.Constants.Instance.ProxyIp, + httpProxyPort); } #if WINDOWS @@ -59,15 +61,10 @@ public static void ListenHttpProxy(this KestrelServerOptions options) public static void ListenSshReverseProxy(this KestrelServerOptions options) { var sshPort = IReverseProxyConfig.SshPort; - options.ListenLocalhost(sshPort, listen => - { - listen.UseFlowAnalyze(); - listen.UseConnectionHandler(); - }); - - var logger = options.GetLogger(); - logger.LogInformation( - "Listened ssh://localhost:{sshPort}, the SSH reverse proxy service of GitHub is started.", sshPort); + options.ListenLocalReverseProxy( + sshPort, + "Listened ssh://localhost:{sshPort}, the SSH reverse proxy service of GitHub is started.", + sshPort); } #endif @@ -80,15 +77,10 @@ public static void ListenSshReverseProxy(this KestrelServerOptions options) public static void ListenGitReverseProxy(this KestrelServerOptions options) { var gitPort = IReverseProxyConfig.GitPort; - options.ListenLocalhost(gitPort, listen => - { - listen.UseFlowAnalyze(); - listen.UseConnectionHandler(); - }); - - var logger = options.GetLogger(); - logger.LogInformation( - "Listened git://localhost:{gitPort}, the Git reverse proxy service of GitHub has been started.", gitPort); + options.ListenLocalReverseProxy( + gitPort, + "Listened git://localhost:{gitPort}, the Git reverse proxy service of GitHub has been started.", + gitPort); } #endif @@ -100,12 +92,29 @@ public static void ListenGitReverseProxy(this KestrelServerOptions options) public static void ListenHttpReverseProxy(this KestrelServerOptions options) { var httpPort = IReverseProxyConfig.HttpPort; - options.Listen(IReverseProxyService.Constants.Instance.ProxyIp, httpPort); - - var logger = options.GetLogger(); - logger.LogInformation( + options.ListenAndLog( + IReverseProxyService.Constants.Instance.ProxyIp, + httpPort, + static _ => { }, "Listened http://{ProxyIp}:{httpPort}, HTTP reverse proxy service startup completed.", - IReverseProxyService.Constants.Instance.ProxyIp, httpPort); + IReverseProxyService.Constants.Instance.ProxyIp, + httpPort); + } + + /// + /// 监听 CRL(证书吊销列表)服务,供 Schannel 等客户端完成本地 MITM 证书的吊销检查 + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ListenCrlReverseProxy(this KestrelServerOptions options) + { + var crlPort = IReverseProxyConfig.CrlPort; + options.ListenAndLog( + IPAddress.Loopback, + crlPort, + static listen => listen.Protocols = HttpProtocols.Http1, + "Listened http://127.0.0.1:{crlPort}, CRL service startup completed.", + crlPort); } /// @@ -122,17 +131,44 @@ public static void ListenHttpsReverseProxy(this KestrelServerOptions options) domainResolver.CheckIpv6SupportAsync(); var httpsPort = IReverseProxyConfig.HttpsPort; - options.Listen(IReverseProxyService.Constants.Instance.ProxyIp, httpsPort, listen => + options.ListenAndLog( + IReverseProxyService.Constants.Instance.ProxyIp, + httpsPort, + static listen => + { + listen.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + listen.UseFlowAnalyze(); + listen.UseTls(); + }, + "Listened https://{ProxyIp}:{httpsPort}, HTTPS reverse proxy service startup completed.", + IReverseProxyService.Constants.Instance.ProxyIp, + httpsPort); + } + + /// + /// 监听本地反向代理(SSH / Git 通用) + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static void ListenLocalReverseProxy(this KestrelServerOptions options, int port, string message, params object[] args) + where TConnectionHandler : ConnectionHandler + { + options.ListenLocalhost(port, listen => { - listen.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; listen.UseFlowAnalyze(); - listen.UseTls(); + listen.UseConnectionHandler(); }); - var logger = options.GetLogger(); - logger.LogInformation( - "Listened https://{ProxyIp}:{httpsPort}, HTTPS reverse proxy service startup completed.", - IReverseProxyService.Constants.Instance.ProxyIp, httpsPort); + options.GetLogger().LogInformation(message, args); + } + + /// + /// 监听指定地址并记录启动日志 + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static void ListenAndLog(this KestrelServerOptions options, IPAddress ip, int port, Action configure, string message, params object[] args) + { + options.Listen(ip, port, configure); + options.GetLogger().LogInformation(message, args); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Extensions/ServiceCollectionExtensions.cs b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Extensions/ServiceCollectionExtensions.cs index d190bde55f8..0b146d9bb51 100644 --- a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Extensions/ServiceCollectionExtensions.cs +++ b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Extensions/ServiceCollectionExtensions.cs @@ -114,6 +114,7 @@ internal static IServiceCollection AddReverseProxyServer(this IServiceCollection .AddMemoryCache() .AddHttpForwarder() .AddSingleton() + .AddSingleton() //.AddSingleton() //.AddSingleton() //.AddSingleton() diff --git a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Models/Abstractions/IReverseProxyConfig.GlobalListener.cs b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Models/Abstractions/IReverseProxyConfig.GlobalListener.cs index 2cdc52097db..0bac181a474 100644 --- a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Models/Abstractions/IReverseProxyConfig.GlobalListener.cs +++ b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Models/Abstractions/IReverseProxyConfig.GlobalListener.cs @@ -39,6 +39,13 @@ partial interface IReverseProxyConfig /// static int HttpsPort { get; } = GetAvailableTcpPort(HttpsPortDefault); + const int CrlPortDefault = 26502; + + /// + /// CRL(证书吊销列表)服务端口,用于为本地 MITM 证书提供吊销分发点 + /// + static int CrlPort { get; } = GetAvailableTcpPort(CrlPortDefault); + /// /// 获取已监听的端口 /// diff --git a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/Certificate/CertGenerator.cs b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/Certificate/CertGenerator.cs index 04c8da2d2c2..bb0bf588f3f 100644 --- a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/Certificate/CertGenerator.cs +++ b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/Certificate/CertGenerator.cs @@ -1,5 +1,6 @@ // https://github.com/dotnetcore/FastGithub/blob/2.1.4/FastGithub.HttpServer/CertGenerator.cs +using System.Formats.Asn1; using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2; // ReSharper disable once CheckNamespace @@ -97,7 +98,8 @@ public static X509Certificate2 CreateEndCertificate( IEnumerable? extraDnsNames = default, DateTimeOffset? notBefore = default, DateTimeOffset? notAfter = default, - int rsaKeySizeInBits = 2048) + int rsaKeySizeInBits = 2048, + string? crlDistributionPointUrl = default) { using var rsa = RSA.Create(rsaKeySizeInBits); var request = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); @@ -132,6 +134,11 @@ public static X509Certificate2 CreateEndCertificate( var dnsNames = dnsBuilder.Build(); request.CertificateExtensions.Add(dnsNames); + if (!string.IsNullOrEmpty(crlDistributionPointUrl)) + { + request.CertificateExtensions.Add(CreateCrlDistributionPointExtension(crlDistributionPointUrl)); + } + if (notBefore == null || notBefore.Value < issuerCertificate.NotBefore) { notBefore = issuerCertificate.NotBefore; @@ -147,6 +154,39 @@ public static X509Certificate2 CreateEndCertificate( return certOnly.CopyWithPrivateKey(rsa); } + /// + /// 创建 CRL 分发点扩展(OID 2.5.29.31),使 Schannel 等客户端可获取本地 CRL 完成吊销检查 + /// + /// CRL 的 HTTP 地址 + /// + static X509Extension CreateCrlDistributionPointExtension(string crlUrl) + { + // CRLDistributionPoints ::= SEQUENCE OF DistributionPoint + // DistributionPoint ::= SEQUENCE { + // distributionPoint [0] DistributionPointName OPTIONAL, + // ... } + // DistributionPointName ::= CHOICE { fullName [0] GeneralNames } + // GeneralName ::= CHOICE { uniformResourceIdentifier [6] IA5String } + var writer = new AsnWriter(AsnEncodingRules.DER); + using (writer.PushSequence()) // CRLDistributionPoints + { + using (writer.PushSequence()) // DistributionPoint + { + using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0))) // distributionPoint [0] + { + using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0))) // fullName [0] + { + writer.WriteCharacterString( + UniversalTagNumber.IA5String, + crlUrl, + new Asn1Tag(TagClass.ContextSpecific, 6)); // uniformResourceIdentifier + } + } + } + } + return new X509Extension("2.5.29.31", writer.Encode(), critical: false); + } + private static void Add(this SubjectAlternativeNameBuilder builder, string name) { if (IPAddress.TryParse(name, out var address)) diff --git a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/HttpServer/Certificates/CertService.cs b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/HttpServer/Certificates/CertService.cs index b405012ff8d..bbac84ecbc0 100644 --- a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/HttpServer/Certificates/CertService.cs +++ b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/HttpServer/Certificates/CertService.cs @@ -14,6 +14,7 @@ sealed class CertService readonly ILogger logger; readonly IReverseProxyConfig reverseProxyConfig; private X509Certificate2? caCert; + readonly Lazy crlBytes; ReverseProxyServiceImpl ReverseProxyService => reverseProxyConfig.Service; @@ -35,6 +36,29 @@ public CertService( this.serverCertCache = serverCertCache; this.logger = logger; this.reverseProxyConfig = reverseProxyConfig; + + // 惰性初始化空 CRL(线程安全),避免对外暴露锁字段 + crlBytes = new Lazy( + () => + { + try + { + caCert ??= new X509Certificate2(fileName: CaPfxFilePath, password: default(string)); + + return new CertificateRevocationListBuilder().Build( + caCert, + System.Numerics.BigInteger.One, + DateTimeOffset.Now.AddDays(30), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + } + catch (Exception e) + { + logger.LogError(e, "CreateEmptyCrl Error"); + return null; + } + }, + LazyThreadSafetyMode.ExecutionAndPublication); } /// @@ -62,6 +86,11 @@ public static bool GitConfigSslverify(bool value) } } + /// + /// 获取由根 CA 签名的空 CRL 字节,供本地 HTTP 服务对外提供,以完成 Schannel 的吊销检查 + /// + public byte[]? CrlBytes => crlBytes.Value; + /// /// 获取颁发给指定域名的证书 /// @@ -83,7 +112,10 @@ X509Certificate2 GetOrCreateCert(ICacheEntry entry) entry.SetAbsoluteExpiration(notAfter); var subjectName = new X500DistinguishedName($"CN={domain}"); - using var serverCert = CertGenerator.CreateEndCertificate(caCert, subjectName, GetDomains()); + // 本地 CRL 服务的 HTTP 地址(写入 MITM 叶子证书的 CRL 分发点) + using var serverCert = CertGenerator.CreateEndCertificate( + caCert, subjectName, GetDomains(), + crlDistributionPointUrl: CrlBytes == null ? null : $"http://{IPAddress.Loopback}:{IReverseProxyConfig.CrlPort}/crl"); var serverCertPfx = serverCert.Export(X509ContentType.Pfx); // 将生成的证书导出后重新创建一个 return new X509Certificate2(serverCertPfx); diff --git a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/HttpServer/Middleware/CrlMiddleware.cs b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/HttpServer/Middleware/CrlMiddleware.cs new file mode 100644 index 00000000000..4c7d3050d10 --- /dev/null +++ b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/HttpServer/Middleware/CrlMiddleware.cs @@ -0,0 +1,41 @@ +// ReSharper disable once CheckNamespace +namespace BD.WTTS.Services.Implementation; + +/// +/// CRL(证书吊销列表)服务中间件,用于对外提供本地 MITM 证书的吊销分发点 +/// +sealed class CrlMiddleware +{ + readonly CertService certService; + + public CrlMiddleware(CertService certService) + { + this.certService = certService; + } + + /// + /// 处理请求 + /// + /// + /// + /// + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + if (HttpMethods.IsGet(context.Request.Method) && + context.Request.Path.Equals(new PathString("/crl"), StringComparison.OrdinalIgnoreCase)) + { + var crlBytes = certService.CrlBytes; + if (crlBytes == null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + context.Response.ContentType = "application/pkix-crl"; + await context.Response.Body.WriteAsync(crlBytes); + } + else + { + await next(context); + } + } +} diff --git a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/YarpReverseProxyServiceImpl.Startup.cs b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/YarpReverseProxyServiceImpl.Startup.cs index 884bca9cab0..8079cdd181a 100644 --- a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/YarpReverseProxyServiceImpl.Startup.cs +++ b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/YarpReverseProxyServiceImpl.Startup.cs @@ -41,6 +41,10 @@ static void StartupConfigure(IApplicationBuilder app) { app.UseHttpLocalRequest(); + // 使用 CRL 证书吊销列表中间件 + var crlMiddleware = app.ApplicationServices.GetRequiredService(); + app.Use(next => context => crlMiddleware.InvokeAsync(context, next)); + app.UseHttpProxyPac(); app.UseRequestLogging(); app.UseHttpReverseProxy(); diff --git a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/YarpReverseProxyServiceImpl.cs b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/YarpReverseProxyServiceImpl.cs index 61029cadca6..48005f2d47a 100644 --- a/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/YarpReverseProxyServiceImpl.cs +++ b/src/BD.WTTS.Client.Plugins.Accelerator.ReverseProxy/Services.Implementation/YarpReverseProxyServiceImpl.cs @@ -200,6 +200,7 @@ StartProxyResult StartProxyCore() else { options.ListenHttpsReverseProxy(); + options.ListenCrlReverseProxy(); if (EnableHttpProxyToHttps) options.ListenHttpReverseProxy(); } diff --git a/src/BD.WTTS.UnitTest/CertGeneratorCrlUnitTest.cs b/src/BD.WTTS.UnitTest/CertGeneratorCrlUnitTest.cs new file mode 100644 index 00000000000..e90f1464ca1 --- /dev/null +++ b/src/BD.WTTS.UnitTest/CertGeneratorCrlUnitTest.cs @@ -0,0 +1,234 @@ +using System.Formats.Asn1; +using System.Net; +using System.Net.Sockets; + +namespace BD.WTTS.UnitTest; + +public sealed class CertGeneratorCrlUnitTest +{ + [Test] + public void EndCertificate_ContainsCrlDistributionPoint() + { + using var caCert = CreateTestCa(); + + const string crlUrl = "http://127.0.0.1:26502/crl"; + using var endCert = CertGenerator.CreateEndCertificate( + caCert, new X500DistinguishedName("CN=github.com"), null, crlDistributionPointUrl: crlUrl); + + var cdp = endCert.Extensions["2.5.29.31"]; + Assert.That(cdp, Is.Not.Null, "叶子证书应包含 CRL 分发点扩展"); + + // 从 CRLDistributionPoints 扩展的 DER 中读取第一个 uniformResourceIdentifier 并断言 + Assert.That( + new AsnReader(cdp!.RawData, AsnEncodingRules.DER) + .ReadSequence() // CRLDistributionPoints + .ReadSequence() // DistributionPoint + .ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0)) // distributionPoint [0] + .ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0)) // fullName [0] + .ReadCharacterString( + UniversalTagNumber.IA5String, + new Asn1Tag(TagClass.ContextSpecific, 6)), // uniformResourceIdentifier + Is.EqualTo(crlUrl)); + } + + [Test] + public void EmptyCrl_Issuer_IsCa() + { + using var caCert = CreateTestCa(); + + var crlBytes = BuildEmptyCrl(caCert); + + Assert.That(crlBytes, Is.Not.Null); + Assert.That(crlBytes.Length, Is.GreaterThan(0)); + Assert.That(ContainsSubsequence(crlBytes, caCert.SubjectName.RawData), Is.True, + "CRL 应包含根 CA 的 Issuer 名称"); + } + + [Test] + public void EndCertificate_ChainsToCa_WithNoRevocationCheck() + { + using var caCert = CreateTestCa(); + + using var endCert = CertGenerator.CreateEndCertificate( + caCert, new X500DistinguishedName("CN=github.com"), null, + crlDistributionPointUrl: "http://127.0.0.1:26502/crl"); + + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + chain.ChainPolicy.ExtraStore.Add(caCert); + + Assert.That(chain.Build(endCert), Is.True, + $"叶子证书应能构建到 CA: {string.Join("; ", chain.ChainStatus.Select(s => s.StatusInformation))}"); + } + + [Test] + [Platform("Win")] + public void CrlServer_ServesValidCrlOverHttp() + { + using var caCert = CreateTestCa(); + var crlBytes = BuildEmptyCrl(caCert); + + using var server = new LocalCrlServer(crlBytes); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + Assert.That(http.GetByteArrayAsync(server.CrlUrl).GetAwaiter().GetResult(), Is.EqualTo(crlBytes), + "CDP 指向的 URL 应能返回完整 CRL 字节"); + } + + [Test] + [Platform("Win")] + public void EndCertificate_OnlineRevocation_PassesWithLocalCrl() + { + // 模拟真实场景:根 CA 受信任 + 本地 HttpListener 提供 CRL。 + // 根信任用 X509ChainPolicy.CustomTrustStore(临时信任库,不写系统证书库、不弹窗、无残留)。 + using var caCert = CreateTestCa(); + var crlBytes = BuildEmptyCrl(caCert); + + using var server = new LocalCrlServer(crlBytes); + + using var endCert = CertGenerator.CreateEndCertificate( + caCert, new X500DistinguishedName("CN=github.com"), null, + crlDistributionPointUrl: server.CrlUrl); + + var chain = new X509Chain(); + var chainDisposed = false; + try + { + chain.ChainPolicy.RevocationMode = X509RevocationMode.Online; + chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag; + chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10); + // 将测试根 CA 加入临时信任库,替代写系统 Root 存储(避免证书弹窗与残留) + chain.ChainPolicy.CustomTrustStore.Add(caCert); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + + var buildTask = Task.Run(() => chain.Build(endCert)); + if (!buildTask.Wait(TimeSpan.FromSeconds(30))) + { + // 超时保护:不释放 chain(后台线程可能仍在使用),避免访问冲突;仅报告失败 + Assert.Fail("X509Chain.Build(Online) 超时(30s):本地 CRL 未能在限定时间内完成吊销检查。"); + return; + } + chainDisposed = true; + Assert.That(buildTask.Result, Is.True, + $"吊销检查失败: {string.Join("; ", chain.ChainStatus.Select(s => s.StatusInformation))}"); + } + finally + { + if (chainDisposed) + { + chain.Dispose(); + } + } + } + + /// + /// 构建由指定 CA 签名的空 CRL + /// + static byte[] BuildEmptyCrl(X509Certificate2 caCert) + { + return new CertificateRevocationListBuilder().Build( + caCert, + System.Numerics.BigInteger.One, + DateTimeOffset.UtcNow.AddDays(30), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + } + + /// + /// 判断 haystack 中是否包含 needle 子序列 + /// + static bool ContainsSubsequence(byte[] haystack, byte[] needle) + { + if (needle.Length == 0) return true; + for (int i = 0; i <= haystack.Length - needle.Length; i++) + { + var match = true; + for (int j = 0; j < needle.Length; j++) + { + if (haystack[i + j] != needle[j]) + { + match = false; + break; + } + } + if (match) return true; + } + return false; + } + + /// + /// 创建测试用根 CA 证书 + /// + static X509Certificate2 CreateTestCa() + { + return CertGenerator.CreateCACertificate( + new X500DistinguishedName("CN=WattToolkitTestCA"), + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1)); + } + + /// + /// 获取一个空闲的本地端口(HttpListener 不支持端口 0 自动分配后回读) + /// + static int GetIdlePort() + { + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + int port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + /// + /// 在本地通过 HTTP 托管指定的 CRL 字节,供在线吊销检查测试使用。 + /// 端口自动分配以避免冲突;Dispose 时停止监听器。 + /// + sealed class LocalCrlServer : IDisposable + { + private readonly HttpListener _listener; + private readonly byte[] _crlBytes; + + public string CrlUrl { get; } + + public LocalCrlServer(byte[] crlBytes) + { + _crlBytes = crlBytes; + int port = GetIdlePort(); + CrlUrl = $"http://127.0.0.1:{port}/crl"; + _listener = new HttpListener(); + _listener.Prefixes.Add($"{CrlUrl}/"); + _listener.Start(); + _ = Task.Run(ServeLoop); + } + + async Task ServeLoop() + { + while (_listener.IsListening) + { + HttpListenerContext? context; + try + { + context = await _listener.GetContextAsync(); + } + catch + { + break; // 监听器已停止 + } + var response = context.Response; + response.StatusCode = 200; + response.ContentType = "application/pkix-crl"; + response.ContentLength64 = _crlBytes.Length; + await response.OutputStream.WriteAsync(_crlBytes); + response.Close(); + } + } + + public void Dispose() + { + _listener.Stop(); + _listener.Close(); + } + } +} From 59709cb91edd79c305f6fdc9f24d04574119f7ce Mon Sep 17 00:00:00 2001 From: PtJade Ceramic <185668489+PtJade-Ceramic@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:12:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20=E5=8A=A0=E9=80=9F?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=97=E8=A1=A8=E4=BC=98=E5=85=88=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E5=90=8E=E7=AB=AF=E4=B8=8D=E5=8F=AF=E8=BE=BE=E6=97=B6?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E9=95=BF=E6=97=B6=E9=97=B4=E7=A9=BA=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/Mvvm/ProxyService.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/BD.WTTS.Client.Plugins.Accelerator/Services/Mvvm/ProxyService.cs b/src/BD.WTTS.Client.Plugins.Accelerator/Services/Mvvm/ProxyService.cs index fbbdb0ee7cb..d48efc6a944 100644 --- a/src/BD.WTTS.Client.Plugins.Accelerator/Services/Mvvm/ProxyService.cs +++ b/src/BD.WTTS.Client.Plugins.Accelerator/Services/Mvvm/ProxyService.cs @@ -424,6 +424,9 @@ private void UpdateProxyTrayMenuItems() public async Task InitializeAccelerateAsync() { ProxyDomains.Clear(); + // 先加载本地缓存,避免后端不可达时面板长时间空白 + LoadOrSaveLocalAccelerate(); + // 加载代理服务数据 var client = IMicroServiceClient.Instance.Accelerate; #if DEBUG @@ -437,10 +440,10 @@ public async Task InitializeAccelerateAsync() if (result.IsSuccess) { ProxyDomains.AddOrUpdate(result.Content!); + // 用最新数据更新本地缓存 + LoadOrSaveLocalAccelerate(); } - LoadOrSaveLocalAccelerate(); - if (ProxySettings.SupportProxyServicesStatus.Value.Any_Nullable() && ProxyDomains.Items.Any_Nullable()) { var items = ProxyDomains.Items!.SelectMany(s => s.Items!);