diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs index 82a1e84f..870c0bbb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs @@ -33,6 +33,7 @@ public class DataConnectionManagerActor : ReceiveActor // UA connections. Needed by the verify-endpoint probe (VerifyEndpointCommand), which // builds an ApplicationConfiguration directly rather than through a connection actor. private readonly OpcUaGlobalOptions _opcUaGlobalOptions; + private readonly Microsoft.Extensions.Logging.ILogger _probeLogger; private readonly Dictionary _connectionActors = new(); // Children this manager has STOPPED (RemoveConnectionCommand) but whose Terminated has not @@ -56,13 +57,20 @@ public class DataConnectionManagerActor : ReceiveActor /// verify-endpoint probe; null falls back to defaults (mirrors /// 's default-options constructor). /// + /// + /// Logger for the verify-endpoint probe; null discards. Gitea #121: a secured probe is + /// usually what first creates the node's OPC UA application certificate, and that + /// creation's Warning (with the thumbprint every server must trust) must reach the node log. + /// public DataConnectionManagerActor( IDataConnectionFactory factory, DataConnectionOptions options, ISiteHealthCollector healthCollector, ISiteEventLogger? siteEventLogger = null, - OpcUaGlobalOptions? opcUaGlobalOptions = null) + OpcUaGlobalOptions? opcUaGlobalOptions = null, + Microsoft.Extensions.Logging.ILogger? probeLogger = null) { + _probeLogger = probeLogger ?? NullLogger.Instance; _factory = factory; _options = options; _healthCollector = healthCollector; @@ -403,9 +411,8 @@ public class DataConnectionManagerActor : ReceiveActor return; } - var probeLogger = NullLogger.Instance; RealOpcUaClient - .VerifyEndpointAsync(config, _opcUaGlobalOptions, probeLogger, TimeSpan.FromSeconds(6), CancellationToken.None) + .VerifyEndpointAsync(config, _opcUaGlobalOptions, _probeLogger, TimeSpan.FromSeconds(6), CancellationToken.None) .PipeTo(Sender); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs index 525debfd..c65a5f48 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs @@ -872,7 +872,9 @@ akka {{ ?? new ZB.MOM.WW.ScadaBridge.DataConnectionLayer.OpcUaGlobalOptions(); dclManager = _actorSystem!.ActorOf( Props.Create(() => new ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors.DataConnectionManagerActor( - dclFactory, dclOptions, healthCollector, siteEventLogger, opcUaGlobalOptions)), + dclFactory, dclOptions, healthCollector, siteEventLogger, opcUaGlobalOptions, + _serviceProvider.GetRequiredService() + .CreateLogger("ZB.MOM.WW.ScadaBridge.DataConnectionLayer.VerifyEndpoint"))), "dcl-manager"); _logger.LogInformation("Data Connection Layer manager actor created"); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs index 2e4d0b69..33ecb8a4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections; @@ -179,6 +180,42 @@ public sealed class RealOpcUaSecureConnectTests : IDisposable Assert.Contains("80130000", result.Error); // BadSecurityChecksFailed, not BadConfigurationError } + /// + /// The probe is usually what first creates a node's identity, so creation must reach the + /// supplied logger as a Warning carrying the thumbprint servers must trust — once, not on reuse. + /// + [Fact] + public async Task Verify_FirstSecuredProbe_LogsCertificateCreationOnce() + { + await using var server = await SecureOpcUaTestServer.StartAsync(); + var log = new CapturingLogger(); + + for (var i = 0; i < 2; i++) + await RealOpcUaClient.VerifyEndpointAsync( + ProbeConfig(server.Url, OpcUaSecurityMode.SignAndEncrypt), ClientOptions(), log, + TimeSpan.FromSeconds(30), CancellationToken.None); + + var der = Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der")); + using var cert = X509CertificateLoader.LoadCertificate(await File.ReadAllBytesAsync(der)); + var created = Assert.Single(log.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("CREATED")); + Assert.Contains(cert.Thumbprint, created.Message); + } + + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + } + /// None is unchanged: it connects without reading or creating the own store. [Fact] public async Task Connect_None_DoesNotTouchTheOwnStore()