211 lines
11 KiB
Diff
211 lines
11 KiB
Diff
diff --git a/Directory.Packages.props b/Directory.Packages.props
|
|
--- a/Directory.Packages.props
|
|
+++ b/Directory.Packages.props
|
|
@@ -242,7 +242,7 @@
|
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.6.2" />
|
|
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
|
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.5.0" />
|
|
- <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.4.0" />
|
|
- <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="0.4.0" />
|
|
+ <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="1.0.0" />
|
|
+ <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="1.0.0" />
|
|
</ItemGroup>
|
|
</Project>
|
|
\ 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 ("<name> 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 <c>h2c</c> gateway (an <c>http://</c> endpoint with <c>UseTls=false</c>) must
|
|
/// construct, using nothing but documented defaults.
|
|
/// </summary>
|
|
- /// <remarks>
|
|
- /// <para>
|
|
- /// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig.
|
|
- /// <c>docs/Historian.md</c> and CLAUDE.md both document <c>http://</c> as a supported
|
|
- /// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact
|
|
- /// <b>unreachable</b>: this factory forwarded the TLS-only options unconditionally, and
|
|
- /// <see cref="ServerHistorianOptions.AllowUntrustedServerCertificate"/> defaults to
|
|
- /// <see langword="false"/>, so it always sent <c>RequireCertificateValidation=true</c> —
|
|
- /// which the client rejects outright when <c>UseTls=false</c>
|
|
- /// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true").
|
|
- /// </para>
|
|
- /// <para>
|
|
- /// So <b>every</b> h2c deployment crashed at startup, and the only way to avoid it was to
|
|
- /// set <c>AllowUntrustedServerCertificate=true</c> — asserting a certificate posture for a
|
|
- /// connection that has no certificate at all.
|
|
- /// </para>
|
|
- /// </remarks>
|
|
[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;
|
|
+
|
|
+/// <summary>historiangw#131: verify the mapped TLS posture through an actual local gRPC connection.</summary>
|
|
+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<HistorianGatewayUnavailableException>(
|
|
+ () => 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<GetTagMetadataReply> 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<Server> 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<TagsService>();
|
|
+ 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(); }
|
|
+ }
|
|
+ }
|
|
+}
|