feat(dcl): OPC UA verify-endpoint probe with untrusted-cert capture (T17)

This commit is contained in:
Joseph Doherty
2026-06-18 03:00:55 -04:00
parent 90abb4b8e2
commit 733c7bf66c
5 changed files with 551 additions and 2 deletions
@@ -1,8 +1,12 @@
using Akka.Actor;
using Akka.Event;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
@@ -20,6 +24,11 @@ public class DataConnectionManagerActor : ReceiveActor
private readonly DataConnectionOptions _options;
private readonly ISiteHealthCollector _healthCollector;
private readonly ISiteEventLogger? _siteEventLogger;
// T17: deployment-wide OPC UA application identity / cert-store paths — the same
// global options the DataConnectionFactory feeds to RealOpcUaClient when creating OPC
// 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 Dictionary<string, IActorRef> _connectionActors = new();
/// <summary>
@@ -29,16 +38,23 @@ public class DataConnectionManagerActor : ReceiveActor
/// <param name="options">Configuration options for data connections.</param>
/// <param name="healthCollector">Collector for site health metrics reported by connection actors.</param>
/// <param name="siteEventLogger">Optional logger for site event entries; null disables site event logging.</param>
/// <param name="opcUaGlobalOptions">
/// Deployment-wide OPC UA application identity / cert-store paths used by the
/// verify-endpoint probe; null falls back to defaults (mirrors
/// <see cref="DataConnectionFactory"/>'s default-options constructor).
/// </param>
public DataConnectionManagerActor(
IDataConnectionFactory factory,
DataConnectionOptions options,
ISiteHealthCollector healthCollector,
ISiteEventLogger? siteEventLogger = null)
ISiteEventLogger? siteEventLogger = null,
OpcUaGlobalOptions? opcUaGlobalOptions = null)
{
_factory = factory;
_options = options;
_healthCollector = healthCollector;
_siteEventLogger = siteEventLogger;
_opcUaGlobalOptions = opcUaGlobalOptions ?? new OpcUaGlobalOptions();
Receive<CreateConnectionCommand>(HandleCreateConnection);
Receive<SubscribeTagsRequest>(HandleRoute);
@@ -52,6 +68,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive<BrowseNodeCommand>(HandleBrowse);
Receive<SearchAddressSpaceCommand>(HandleSearch);
Receive<ReadTagValuesCommand>(HandleReadTagValues);
Receive<VerifyEndpointCommand>(HandleVerifyEndpoint);
}
private void HandleCreateConnection(CreateConnectionCommand command)
@@ -243,6 +260,46 @@ public class DataConnectionManagerActor : ReceiveActor
}
}
/// <summary>
/// T17: Handles a <see cref="VerifyEndpointCommand"/> from the Central UI's "Verify"
/// action — probes the endpoint config WITHOUT persisting it (connect → capture an
/// untrusted cert → disconnect) and pipes a structured <see cref="VerifyEndpointResult"/>
/// back to the sender. Verify does NOT require an existing connection (the config may be
/// brand-new and unsaved), so — unlike the routed browse/read handlers — it does not look
/// up a connection actor; it runs the probe directly. Only OPC UA is supported today.
/// </summary>
private void HandleVerifyEndpoint(VerifyEndpointCommand cmd)
{
if (!string.Equals(cmd.Protocol, "OpcUa", StringComparison.OrdinalIgnoreCase))
{
Sender.Tell(new VerifyEndpointResult(
false, VerifyFailureKind.ServerError,
"Verify is only supported for OPC UA connections.", null));
return;
}
OpcUaEndpointConfig config;
try
{
(config, _) = OpcUaEndpointConfigSerializer.Deserialize(cmd.ConfigJson);
}
catch (Exception ex)
{
// Defensive: Deserialize is designed not to throw (it classifies Malformed), but
// a verify must never crash the manager — surface the parse failure as ServerError.
_log.Warning(ex, "Verify config for {0} could not be parsed", cmd.ConnectionName);
Sender.Tell(new VerifyEndpointResult(
false, VerifyFailureKind.ServerError,
"The endpoint configuration could not be parsed.", null));
return;
}
var probeLogger = NullLogger.Instance;
RealOpcUaClient
.VerifyEndpointAsync(config, _opcUaGlobalOptions, probeLogger, TimeSpan.FromSeconds(6), CancellationToken.None)
.PipeTo(Sender);
}
private void HandleRemoveConnection(RemoveConnectionCommand command)
{
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))