Merge remote-tracking branch 'origin/master' into codex/opcua-sdk-2
This commit is contained in:
+1
-17
@@ -27,23 +27,6 @@ public sealed class HistorianGatewayClientAdapterTests
|
||||
/// 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 @@ 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);
|
||||
|
||||
+142
@@ -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;
|
||||
|
||||
/// <summary>historiangw#131: verify the mapped TLS posture through an actual local gRPC connection.</summary>
|
||||
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<HistorianGatewayException>(
|
||||
() => 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<Exception>();
|
||||
var seen = new HashSet<Exception>(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<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(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<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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user