From ef21571febc8b2760be6aac93be22f876f83efa7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 14:23:54 -0500 Subject: [PATCH] test: prepare HistorianGateway 1.0 consumer TLS migration --- scripts/validation/historiangw-131/README.md | 56 +++++ scripts/validation/historiangw-131/RESULTS.md | 13 ++ .../historiangw-131/consumer-1.0.0.patch | 210 ++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 scripts/validation/historiangw-131/README.md create mode 100644 scripts/validation/historiangw-131/RESULTS.md create mode 100644 scripts/validation/historiangw-131/consumer-1.0.0.patch diff --git a/scripts/validation/historiangw-131/README.md b/scripts/validation/historiangw-131/README.md new file mode 100644 index 00000000..1e0b2622 --- /dev/null +++ b/scripts/validation/historiangw-131/README.md @@ -0,0 +1,56 @@ +# HistorianGateway consumer TLS migration — issue 131 + +`consumer-1.0.0.patch` is a future consumer change, kept separate from the buildable +application while the paired Client/Contracts 1.0.0 packages are being released. +The application in this checkout remains pinned to published 0.4.0. + +The patch changes both central package pins to 1.0.0 and maps the new option as: + +```csharp +AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate, +``` + +The old inverse-mapping and obsolete h2c-history comments are removed. The existing +h2c construction test now includes a stale opt-out as well as a stale CA path. +Three new bounded local wire tests verify behavior through the real adapter and +published client API, without reflection or replacing the transport handler: + +- TLS with normal defaults rejects a self-signed server before the metadata RPC. +- Explicit opt-out permits that self-signed server shape and executes one RPC. +- Plaintext with stale opt-out and CA settings executes one RPC, proving TLS-only + settings were not forwarded to the client's validating options. + +The local server uses an ephemeral loopback port and a fresh short-lived +self-signed certificate. RPC completion is bounded to 15 seconds; the consumer +call timeout is two seconds. No gateway, deployment, or global trust store is used. + +Before applying the patch, query the live Gitea NuGet version indexes for BOTH +packages and require actual stable 1.0.0. Source `main` having the new API is not a +package release. At initial inspection, both feed indexes listed exactly +0.4.0, 0.3.0, 0.2.0, 0.1.0, and the new API was only in Unreleased at HG commit +`e4321915ebc5e0704a68ecad21b85c1cc1c80d6b`. + +```bash +git apply --check scripts/validation/historiangw-131/consumer-1.0.0.patch +git apply scripts/validation/historiangw-131/consumer-1.0.0.patch +# From the consumer checkout, using a fresh run/results directory: +dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests \ + -c Release --logger trx --results-directory /absolute/fresh/results +# Preserve dotnet test's exit; run the repository execution-count guard separately. +MIN_EXECUTED=1 bash scripts/ci/assert-not-all-skipped.sh /absolute/fresh/results/*.trx +``` + +Published-package verification must use the real 1.0.0 packages, preserve all test +and guard exits, run the full owning project, and complete the consumer solution +build/review before issue 131 is closed. The patch is transport-only and can be +applied independently of the OPC UA SDK 2 rollout. + +If release availability lags, validate only in an archived staging copy: materialize +exact HG commit e432191 with `git archive`, pack both packages with the local-only +version `1.0.0-validation.131`, set an isolated `NUGET_PACKAGES`, and map only those +two package IDs to a task-local feed. Apply the future patch to a separate consumer +archive, then replace ONLY its two package pins with the validation prerelease. +Never pack/build the user's HG checkout, publish these validation packages, place +them in the normal NuGet cache, or pass prerelease validation off as stable-release +verification. Local validation and release availability results are recorded in +`RESULTS.md` when the run completes. diff --git a/scripts/validation/historiangw-131/RESULTS.md b/scripts/validation/historiangw-131/RESULTS.md new file mode 100644 index 00000000..9dd18c51 --- /dev/null +++ b/scripts/validation/historiangw-131/RESULTS.md @@ -0,0 +1,13 @@ +# Validation status + +Preparation only: future patch passes `git apply --check` in the SDK 2 candidate +checkout. Published Client/Contracts 1.0.0 package verification is pending. + +Initial primary feed query returned versions 0.4.0, 0.3.0, 0.2.0, 0.1.0 for both +packages. HG e4321915ebc5e0704a68ecad21b85c1cc1c80d6b contains the new option under +Unreleased; both source package version declarations still read 0.4.0. The cached +published Client 0.4.0 exposes RequireCertificateValidation and does not expose the +new property. Current consumer files remain on 0.4.0 and unchanged. + +No issue comment/close, package publication, or HG user-checkout modification has +been performed. Test results will be added after actual execution. diff --git a/scripts/validation/historiangw-131/consumer-1.0.0.patch b/scripts/validation/historiangw-131/consumer-1.0.0.patch new file mode 100644 index 00000000..b22cd7a6 --- /dev/null +++ b/scripts/validation/historiangw-131/consumer-1.0.0.patch @@ -0,0 +1,210 @@ +diff --git a/Directory.Packages.props b/Directory.Packages.props +--- a/Directory.Packages.props ++++ b/Directory.Packages.props +@@ -242,7 +242,7 @@ + + + +- +- ++ ++ + + +\ No newline at end of file +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 +--- 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 @@ + $"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 +--- 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 @@ + /// 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 @@ + 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 +--- /dev/null ++++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs +@@ -0,0 +1,126 @@ ++using System.Net; ++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 ++{ ++ 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); ++ ++ await Should.ThrowAsync( ++ () => adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)); ++ 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(); ++ options.AllowUntrustedServerCertificate = 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(); ++ options.AllowUntrustedServerCertificate = true; ++ options.CaCertificatePath = "/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 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() => new() ++ { ++ Endpoint = endpoint, ApiKey = "histgw_131_test", UseTls = certificate is not null, ++ CallTimeout = TimeSpan.FromSeconds(2), ++ }; ++ ++ 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(); } ++ } ++ } ++}