diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs
index a563c524..3ea53192 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs
@@ -337,13 +337,26 @@ public sealed class GrpcPullAuditEventsInvoker
private GrpcChannel CreateChannel(string siteId, string endpoint) =>
GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
{
- HttpHandler = new SocketsHttpHandler
- {
- KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay,
- KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
- KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
- },
- }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
+ HttpHandler = CreateHandler(),
+ }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId, endpoint));
+
+ ///
+ /// Test seam (ScadaBridge#108): lets a test trust a generated server certificate on the
+ /// handler. Production leaves it null, so the OS trust store applies.
+ ///
+ internal Action? ConfigureHandler { get; init; }
+
+ private SocketsHttpHandler CreateHandler()
+ {
+ var handler = new SocketsHttpHandler
+ {
+ KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay,
+ KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
+ KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
+ };
+ ConfigureHandler?.Invoke(handler);
+ return handler;
+ }
///
public void RetainOnly(IReadOnlyList liveSites) =>
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs
index 8aabe554..f5d83431 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs
@@ -354,13 +354,26 @@ public sealed class GrpcPullSiteCallsInvoker
private GrpcChannel CreateChannel(string siteId, string endpoint) =>
GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
{
- HttpHandler = new SocketsHttpHandler
- {
- KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay,
- KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
- KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
- },
- }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
+ HttpHandler = CreateHandler(),
+ }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId, endpoint));
+
+ ///
+ /// Test seam (ScadaBridge#108): lets a test trust a generated server certificate on the
+ /// handler. Production leaves it null, so the OS trust store applies.
+ ///
+ internal Action? ConfigureHandler { get; init; }
+
+ private SocketsHttpHandler CreateHandler()
+ {
+ var handler = new SocketsHttpHandler
+ {
+ KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay,
+ KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
+ KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
+ };
+ ConfigureHandler?.Invoke(handler);
+ return handler;
+ }
///
public void RetainOnly(IReadOnlyList liveSites) =>
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs
index f9e0d7ac..11e4fe7b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs
@@ -216,13 +216,26 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull
private GrpcChannel CreateChannel(string siteId, string endpoint) =>
GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
{
- HttpHandler = new SocketsHttpHandler
- {
- KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay,
- KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
- KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
- },
- }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
+ HttpHandler = CreateHandler(),
+ }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId, endpoint));
+
+ ///
+ /// Test seam (ScadaBridge#108): lets a test trust a generated server certificate on the
+ /// handler. Production leaves it null, so the OS trust store applies.
+ ///
+ internal Action? ConfigureHandler { get; init; }
+
+ private SocketsHttpHandler CreateHandler()
+ {
+ var handler = new SocketsHttpHandler
+ {
+ KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay,
+ KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
+ KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
+ };
+ ConfigureHandler?.Invoke(handler);
+ return handler;
+ }
///
public void RetainOnly(IReadOnlyList liveSites) =>
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj
index b50f3ae1..1073b3a0 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj
@@ -33,6 +33,7 @@
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AuditLogPullTlsEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AuditLogPullTlsEndToEndTests.cs
new file mode 100644
index 00000000..83d03ecd
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AuditLogPullTlsEndToEndTests.cs
@@ -0,0 +1,174 @@
+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.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
+using ZB.MOM.WW.ScadaBridge.Communication;
+using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
+
+namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
+
+///
+/// The three AuditLog pull invokers over a real HTTPS gRPC listener (ScadaBridge#108): real Kestrel
+/// HTTP/2 over TLS (ALPN), the real , and the production
+/// invokers. Same shape as (ScadaBridge#64). Before the
+/// invokers passed their endpoint to WithSiteCredentials they composed the PSK over insecure
+/// credentials and threw at channel construction for any https site address.
+///
+public sealed class AuditLogPullTlsEndToEndTests : IAsyncLifetime
+{
+ private const string SiteKey = "the-site-a-preshared-key";
+
+ private X509Certificate2 _certificate = null!;
+ private IHost _host = null!;
+ private string _address = null!;
+
+ /// Generates a localhost certificate and starts a TLS gRPC host with the real interceptor.
+ /// A task that represents the asynchronous operation.
+ public async Task InitializeAsync()
+ {
+ using (var rsa = RSA.Create(2048))
+ {
+ var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var san = new SubjectAlternativeNameBuilder();
+ san.AddDnsName("localhost");
+ request.CertificateExtensions.Add(san.Build());
+ using var ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
+ _certificate = X509CertificateLoader.LoadPkcs12(ephemeral.Export(X509ContentType.Pfx), password: null);
+ }
+
+ _host = await new HostBuilder()
+ .ConfigureWebHost(web => web
+ .UseKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, 0, listen =>
+ {
+ listen.Protocols = HttpProtocols.Http2;
+ listen.UseHttps(_certificate);
+ }))
+ .ConfigureServices(services =>
+ {
+ services.AddGrpc(o => o.Interceptors.Add());
+ services.AddSingleton(Options.Create(new CommunicationOptions { GrpcPsk = SiteKey }));
+ })
+ .Configure(app =>
+ {
+ app.UseRouting();
+ app.UseEndpoints(e => e.MapGrpcService());
+ }))
+ .StartAsync();
+
+ var bound = _host.Services.GetRequiredService().Features.Get()!.Addresses.Single();
+ _address = $"https://localhost:{new Uri(bound).Port}";
+ }
+
+ /// Stops the host and releases the certificate.
+ /// A task that represents the asynchronous operation.
+ public async Task DisposeAsync()
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ _certificate.Dispose();
+ }
+
+ private void Trust(SocketsHttpHandler handler) =>
+ handler.SslOptions.RemoteCertificateValidationCallback = (_, cert, _, _) =>
+ cert is not null && string.Equals(cert.GetCertHashString(), _certificate.Thumbprint, StringComparison.OrdinalIgnoreCase);
+
+ /// Verifies the audit-events pull succeeds over TLS with the right key.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task AuditEvents_pull_works_over_https_with_the_right_key()
+ {
+ using var invoker = new GrpcPullAuditEventsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) { ConfigureHandler = Trust };
+ var reply = await invoker.InvokeAsync("site-a", _address, new PullAuditEventsRequest { BatchSize = 1 }, CancellationToken.None);
+ Assert.False(reply.MoreAvailable);
+ }
+
+ /// Verifies the site-calls pull succeeds over TLS with the right key.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task SiteCalls_pull_works_over_https_with_the_right_key()
+ {
+ using var invoker = new GrpcPullSiteCallsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) { ConfigureHandler = Trust };
+ var reply = await invoker.InvokeAsync("site-a", _address, new PullSiteCallsRequest { BatchSize = 1 }, CancellationToken.None);
+ Assert.False(reply.MoreAvailable);
+ }
+
+ /// Verifies the trace-events pull succeeds over TLS with the right key.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task TraceEvents_pull_works_over_https_with_the_right_key()
+ {
+ using var invoker = new GrpcPullTraceEventsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) { ConfigureHandler = Trust };
+ var reply = await invoker.InvokeAsync("site-a", _address, new PullTraceEventsRequest(), CancellationToken.None);
+ Assert.NotNull(reply);
+ }
+
+ /// Verifies TLS does not replace the PSK: a wrong key is still PermissionDenied on all three pulls.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Wrong_key_is_still_rejected_over_https()
+ {
+ var wrong = new FixedPskProvider("some-other-sites-key");
+ using var a = new GrpcPullAuditEventsInvoker(new CommunicationOptions(), wrong) { ConfigureHandler = Trust };
+ using var s = new GrpcPullSiteCallsInvoker(new CommunicationOptions(), wrong) { ConfigureHandler = Trust };
+ using var t = new GrpcPullTraceEventsInvoker(new CommunicationOptions(), wrong) { ConfigureHandler = Trust };
+
+ foreach (var call in new Func[]
+ {
+ () => a.InvokeAsync("site-a", _address, new PullAuditEventsRequest(), CancellationToken.None),
+ () => s.InvokeAsync("site-a", _address, new PullSiteCallsRequest(), CancellationToken.None),
+ () => t.InvokeAsync("site-a", _address, new PullTraceEventsRequest(), CancellationToken.None),
+ })
+ {
+ var ex = await Assert.ThrowsAsync(call);
+ Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
+ }
+ }
+
+ /// Verifies an untrusted server certificate fails the call before the PSK is sent.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Untrusted_server_certificate_fails_the_pull()
+ {
+ using var invoker = new GrpcPullAuditEventsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey))
+ {
+ ConfigureHandler = h => h.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => false,
+ };
+ var ex = await Assert.ThrowsAsync(
+ () => invoker.InvokeAsync("site-a", _address, new PullAuditEventsRequest(), CancellationToken.None));
+ Assert.NotEqual(StatusCode.PermissionDenied, ex.StatusCode);
+ Assert.NotEqual(StatusCode.OK, ex.StatusCode);
+ }
+
+ private sealed class FixedPskProvider(string key) : ISitePskProvider
+ {
+ ///
+ public ValueTask GetAsync(string siteId, CancellationToken ct) => new(key);
+
+ ///
+ public void Invalidate(string siteId) { }
+ }
+
+ private sealed class StubSiteStreamService : SiteStreamService.SiteStreamServiceBase
+ {
+ ///
+ public override Task PullAuditEvents(PullAuditEventsRequest request, ServerCallContext context) =>
+ Task.FromResult(new PullAuditEventsResponse());
+
+ ///
+ public override Task PullSiteCalls(PullSiteCallsRequest request, ServerCallContext context) =>
+ Task.FromResult(new PullSiteCallsResponse());
+
+ ///
+ public override Task PullTraceEvents(PullTraceEventsRequest request, ServerCallContext context) =>
+ Task.FromResult(new PullTraceEventsResponse());
+ }
+}