From 230cef4ef0640c5e8048f2ab48d9437137e5e38d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 14:40:30 -0500 Subject: [PATCH] build: adopt HistorianGateway 1.0 TLS options --- Directory.Packages.props | 4 +- docs/Historian.md | 9 +- .../evidence/2026-11-10-historiangw-131.json | 113 ++++++++++++++ .../HistorianGatewayClientAdapter.cs | 14 +- .../HistorianGatewayClientAdapterTests.cs | 18 +-- .../HistorianGatewayTlsPolicyWireTests.cs | 142 ++++++++++++++++++ 6 files changed, 267 insertions(+), 33 deletions(-) create mode 100644 docs/plans/evidence/2026-11-10-historiangw-131.json create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index cfc831c3b..8d93af08e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -240,7 +240,7 @@ - - + + \ No newline at end of file diff --git a/docs/Historian.md b/docs/Historian.md index 487b8962b..dc3083faa 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -306,7 +306,7 @@ and is registered the same way for every backend. > **Cross-repo follow-up — a multi-tag batch `WriteLiveValues` on the gateway contract.** The drain's > unit of work is one gateway round-trip **per historian tag**, because -> `ZB.MOM.WW.HistorianGateway.Client` **0.4.0** exposes no RPC that writes values for more than one tag +> `ZB.MOM.WW.HistorianGateway.Client` **1.0.0** exposes no RPC that writes values for more than one tag > in a call. Bounded parallelism (`DrainConcurrency`) is therefore the ceiling: it overlaps the > round-trips but cannot remove them, so a wide tag set still pays `⌈tags / concurrency⌉` network > hops per drain pass. Collapsing that to a single hop needs a **batch/multi-tag `WriteLiveValues`** on @@ -314,8 +314,11 @@ and is registered the same way for every backend. > lands, raising `DrainBatchSize` widens each hop's payload but not the hop count — which is why the > concurrency window, not the batch width, is what governs drain throughput today. -The HistorianGateway **Client and Contracts packages are pinned together at 0.4.0** -([historiangw#119](https://gitea.dohertylan.com/dohertj2/historiangw/issues/119)). The removed +The HistorianGateway **Client and Contracts packages are pinned together at 1.0.0** +([historiangw#131](https://gitea.dohertylan.com/dohertj2/historiangw/issues/131)). The adapter maps +`AllowUntrustedServerCertificate` directly and only for TLS: the default verifies the server, while an +explicit opt-out applies only when `UseTls=true`. Stale certificate options are ignored on plaintext h2c. +This follows the earlier [0.4.0 update](https://gitea.dohertylan.com/dohertj2/historiangw/issues/119). The removed `ReadBlocks` RPC and `ReadBlocksAsync` wrapper were unused here. A partially imported CA PEM bundle now fails client creation instead of accepting a narrower certificate pin; repair the configured bundle if startup reports `ArgumentException` for `CaCertificatePath`. diff --git a/docs/plans/evidence/2026-11-10-historiangw-131.json b/docs/plans/evidence/2026-11-10-historiangw-131.json new file mode 100644 index 000000000..615c2e230 --- /dev/null +++ b/docs/plans/evidence/2026-11-10-historiangw-131.json @@ -0,0 +1,113 @@ +{ + "issue": "https://gitea.dohertylan.com/dohertj2/historiangw/issues/131", + "consumerBase": "16111a5e", + "opcUaSdk": "1.5.378.182 (independent of SDK2 migration)", + "build": { + "command": "dotnet build ZB.MOM.WW.OtOpcUa.slnx -c Release -m:1", + "exit": 0, + "warnings": 0, + "errors": 0, + "seconds": 37.33, + "logSha256": "cb4f1ee96bf05254e5c707179c1e81d99d8267e28c5117a84afa0b0c853fa329" + }, + "packages": [ + { + "package": "ZB.MOM.WW.HistorianGateway.Client/1.0.0", + "type": "package", + "source": "https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json", + "repositoryCommit": "ea9f21c850fb618767e12690924f9966f1e433ac", + "sha256": { + "lib/net10.0/ZB.MOM.WW.HistorianGateway.Client.dll": "b89a02e0d228ec549c4dfb755694f7f67b573a6077f06ff2781165e884fc5e12", + "zb.mom.ww.historiangateway.client.1.0.0.nupkg": "0a3f2836727671c07c0c393e705cc9737d2ed44258213a51fb4acf0d0ab7129e" + } + }, + { + "package": "ZB.MOM.WW.HistorianGateway.Contracts/1.0.0", + "type": "package", + "source": "https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json", + "repositoryCommit": "ea9f21c850fb618767e12690924f9966f1e433ac", + "sha256": { + "lib/net10.0/ZB.MOM.WW.HistorianGateway.Contracts.dll": "e60d3d4650be5d251982f7847404daf1b666f61d56e2b0ccb844af8677e97743", + "zb.mom.ww.historiangateway.contracts.1.0.0.nupkg": "83ffd12bcbbf919c973ba522560e10aa93a4cb8c686a3232030a2cfa5f00995e" + } + } + ], + "sourceOverrides": false, + "tests": [ + { + "project": "gateway", + "testExit": 0, + "executionGuardExit": 0, + "counters": { + "total": "529", + "executed": "523", + "passed": "523", + "failed": "0", + "error": "0", + "timeout": "0", + "aborted": "0", + "inconclusive": "0", + "passedButRunAborted": "0", + "notRunnable": "0", + "notExecuted": "0", + "disconnected": "0", + "warning": "0", + "completed": "0", + "inProgress": "0", + "pending": "0" + }, + "outcomes": { + "Passed": 523, + "Failed": 0, + "NotExecuted": 6 + }, + "requiredTlsWireTests": [ + { + "name": "ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.HistorianGatewayTlsPolicyWireTests.Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape", + "outcome": "Passed" + }, + { + "name": "ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.HistorianGatewayTlsPolicyWireTests.Default_tls_rejects_an_untrusted_server_before_the_rpc", + "outcome": "Passed" + }, + { + "name": "ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.HistorianGatewayTlsPolicyWireTests.Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc", + "outcome": "Passed" + } + ], + "trxSha256": "68086e66da0942214c0be805366b47c443f633d90f217c91e07ec685e8aea9d9" + }, + { + "project": "runtime", + "testExit": 0, + "executionGuardExit": 0, + "counters": { + "total": "1005", + "executed": "1002", + "passed": "1002", + "failed": "0", + "error": "0", + "timeout": "0", + "aborted": "0", + "inconclusive": "0", + "passedButRunAborted": "0", + "notRunnable": "0", + "notExecuted": "0", + "disconnected": "0", + "warning": "0", + "completed": "0", + "inProgress": "0", + "pending": "0" + }, + "outcomes": { + "Passed": 1002, + "Failed": 0, + "NotExecuted": 3 + }, + "requiredTlsWireTests": [], + "trxSha256": "c12308fd650be0646beba044a4b3301dc2aefc43097b2da667d0eb5da408fc65" + } + ], + "review": "Astra approved source/security mapping and corrected real-wire tests; actual published package verification complete.", + "deploymentPerformed": false +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs index 2a716ff6b..ab4fb07b8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs @@ -57,23 +57,15 @@ public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDi $"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'."); } - // TLS-only options must stay at their defaults on a plaintext h2c connection: the client rejects - // each of them outright when UseTls=false (" is a TLS-only option and requires - // UseTls=true"). Forwarding them unconditionally made h2c UNREACHABLE — AllowUntrustedServer- - // Certificate defaults to false, so RequireCertificateValidation was always sent as true and - // every http:// deployment crashed at startup, even though the scheme is documented as the - // supported way to select h2c. There is no certificate to have a posture about here. + // Certificate settings apply only to TLS. The client rejects an explicit opt-out or a + // CA path on h2c, where there is no certificate to validate. var clientOptions = new HistorianGatewayClientOptions { Endpoint = endpointUri, ApiKey = options.ApiKey, UseTls = options.UseTls, CaCertificatePath = options.UseTls ? options.CaCertificatePath : null, - // INVERTED mapping (TLS only): ServerHistorianOptions.AllowUntrustedServerCertificate - // (opt-in to accept a self-signed cert) is the negation of the client's - // RequireCertificateValidation. Allowing an untrusted cert == not requiring validation; a - // pinned CaCertificatePath always verifies. - RequireCertificateValidation = options.UseTls && !options.AllowUntrustedServerCertificate, + AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate, DefaultCallTimeout = options.CallTimeout, LoggerFactory = loggerFactory, }; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs index f20e440b0..a75f0f6af 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs @@ -27,23 +27,6 @@ public sealed class HistorianGatewayClientAdapterTests /// A plaintext h2c gateway (an http:// endpoint with UseTls=false) must /// construct, using nothing but documented defaults. /// - /// - /// - /// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig. - /// docs/Historian.md and CLAUDE.md both document http:// as a supported - /// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact - /// unreachable: this factory forwarded the TLS-only options unconditionally, and - /// defaults to - /// , so it always sent RequireCertificateValidation=true — - /// which the client rejects outright when UseTls=false - /// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true"). - /// - /// - /// So every h2c deployment crashed at startup, and the only way to avoid it was to - /// set AllowUntrustedServerCertificate=true — asserting a certificate posture for a - /// connection that has no certificate at all. - /// - /// [Fact] public void Adapter_constructs_for_plaintext_h2c_endpoint() { @@ -71,6 +54,7 @@ public sealed class HistorianGatewayClientAdapterTests ApiKey = "histgw_x_y", UseTls = false, CaCertificatePath = "/etc/ssl/certs/leftover-from-a-tls-config.pem", + AllowUntrustedServerCertificate = true, }; using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs new file mode 100644 index 000000000..10c422f62 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Grpc.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.HistorianGateway.Client; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +/// historiangw#131: verify the mapped TLS posture through an actual local gRPC connection. +public sealed class HistorianGatewayTlsPolicyWireTests(ITestOutputHelper output) +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Default_tls_rejects_an_untrusted_server_before_the_rpc() + { + await using var server = await Server.StartAsync(tls: true); + using var adapter = HistorianGatewayClientAdapter.Create(server.Options(), NullLoggerFactory.Instance); + + var error = await Should.ThrowAsync( + () => adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)); + output.WriteLine($"TLS rejection: {error}"); + ContainsTlsAuthenticationFailure(error).ShouldBeTrue("the failure must be TLS authentication, not an arbitrary connection failure"); + server.MetadataCalls.ShouldBe(0); + } + + [Fact] + public async Task Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape() + { + await using var server = await Server.StartAsync(tls: true); + var options = server.Options(allowUntrusted: true); + using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance); + + (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue(); + server.MetadataCalls.ShouldBe(1); + } + + [Fact] + public async Task Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc() + { + await using var server = await Server.StartAsync(tls: false); + var options = server.Options(allowUntrusted: true, caPath: "/nonexistent/historiangw-131-stale-ca.pem"); + using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance); + + (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue(); + server.MetadataCalls.ShouldBe(1); + } + + private static bool ContainsTlsAuthenticationFailure(Exception error) + { + var pending = new Stack(); + var seen = new HashSet(ReferenceEqualityComparer.Instance); + pending.Push(error); + while (pending.TryPop(out var current)) + { + if (!seen.Add(current)) continue; + if (current is AuthenticationException) return true; + if (current.InnerException is { } inner) pending.Push(inner); + if (current is RpcException { Status.DebugException: { } debug }) pending.Push(debug); + } + return false; + } + + private sealed class TagsService : HistorianTags.HistorianTagsBase + { + public int Calls; + public override Task GetTagMetadata(GetTagMetadataRequest request, ServerCallContext context) + { + Interlocked.Increment(ref Calls); + return Task.FromResult(new GetTagMetadataReply + { Metadata = new HistorianTagMetadata { Name = request.Tag } }); + } + } + + private sealed class Server(WebApplication app, TagsService service, string endpoint, + RSA? key, X509Certificate2? certificate) : IAsyncDisposable + { + public int MetadataCalls => Volatile.Read(ref service.Calls); + public ServerHistorianOptions Options(bool allowUntrusted = false, string? caPath = null) => new() + { + Endpoint = endpoint, ApiKey = "histgw_131_test", UseTls = certificate is not null, + CallTimeout = TimeSpan.FromSeconds(2), + AllowUntrustedServerCertificate = allowUntrusted, CaCertificatePath = caPath, + }; + + public static async Task StartAsync(bool tls) + { + RSA? key = null; + X509Certificate2? certificate = null; + if (tls) + { + key = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName("localhost"); names.AddIpAddress(IPAddress.Loopback); + request.CertificateExtensions.Add(names.Build()); + certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)); + } + var service = new TagsService(); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0, listen => + { + listen.Protocols = HttpProtocols.Http2; + if (certificate is not null) listen.UseHttps(certificate); + })); + builder.Services.AddGrpc(); builder.Services.AddSingleton(service); + var app = builder.Build(); + app.MapGrpcService(); + try + { + await app.StartAsync(Ct); + return new Server(app, service, app.Urls.Single(), key, certificate); + } + catch + { + await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose(); + throw; + } + } + + public async ValueTask DisposeAsync() + { + try { await app.StopAsync(Ct); } + finally { await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose(); } + } + } +}