From 52a4476b214aad74b766dfb720c6cc348443f863 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 10:08:12 -0500 Subject: [PATCH] refactor: migrate OPC UA driver and browser to SDK 2.0 --- .../OpcUaClientBrowseSession.cs | 24 +-- .../OpcUaClientDriverBrowser.cs | 13 +- ....OtOpcUa.Driver.OpcUaClient.Browser.csproj | 1 - .../NamespaceMap.cs | 13 +- .../OpcUaUserIdentityFactory.cs | 33 +++- .../UpstreamValueMapping.cs | 26 +++ ....OtOpcUa.Driver.OpcUaClient.Session.csproj | 1 - .../OpcUaClientDriver.cs | 170 +++++++++--------- .../OpcUaClientDriverProbe.cs | 2 +- ...B.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj | 1 - 10 files changed, 171 insertions(+), 113 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamValueMapping.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs index 9224a80a..e7e6767f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs @@ -73,10 +73,10 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank, Attributes.UserAccessLevel, ]; - var toRead = new ReadValueIdCollection(attributeIds.Length); + var toRead = new List(attributeIds.Length); foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id }); - DataValueCollection values; + ArrayOf values; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { @@ -97,15 +97,15 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode) ? values[i] : null; - var nodeClass = At(0)?.Value is int nc ? (NodeClass)nc : NodeClass.Unspecified; + var nodeClass = At(0)?.WrappedValue.AsBoxedObject() is int nc ? (NodeClass)nc : NodeClass.Unspecified; if (nodeClass != NodeClass.Variable) return Array.Empty(); - var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId; - var dataType = At(2)?.Value is NodeId dt + var name = (At(1)?.WrappedValue.AsBoxedObject() as LocalizedText?)?.Text ?? resolved.ToString() ?? nodeId; + var dataType = At(2)?.WrappedValue.AsBoxedObject() is NodeId dt ? UpstreamAttributeMapping.MapDataType(dt) : ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String; - var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar; - var access = At(4)?.Value is byte ab ? ab : (byte)0; + var valueRank = At(3)?.WrappedValue.AsBoxedObject() is int vr ? vr : ValueRanks.Scalar; + var access = At(4)?.WrappedValue.AsBoxedObject() is byte ab ? ab : (byte)0; return [ @@ -126,7 +126,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession await _gate.WaitAsync(ct).ConfigureAwait(false); try { - var descriptions = new BrowseDescriptionCollection + var descriptions = new List { new() { @@ -154,7 +154,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession } var result = resp.Results[0]; - var refs = result.References; + var refs = result.References.ToList(); // Follow browse continuation points so folders larger than the server's per-call // cap aren't silently truncated. @@ -190,7 +190,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession if (next.Results.Count == 0) break; var nextResult = next.Results[0]; if (nextResult.References is { Count: > 0 }) - refs.AddRange(nextResult.References); + refs.AddRange(nextResult.References.ToList()); cp = nextResult.ContinuationPoint; } @@ -214,8 +214,8 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession { var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris); var isObject = rf.NodeClass == NodeClass.Object; - var displayName = rf.DisplayName?.Text - ?? rf.BrowseName?.Name + var displayName = rf.DisplayName.Text + ?? rf.BrowseName.Name ?? childId.ToString() ?? "(unnamed)"; return new BrowseNode( diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs index da1411b1..dad1be23 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs @@ -101,7 +101,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser sessionName: "OtOpcUa AdminUI Browse", (uint)opts.SessionTimeout.TotalMilliseconds, identity, - preferredLocales: null, + preferredLocales: default, connectCts.Token).ConfigureAwait(false); try @@ -224,8 +224,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser return (OpcUaUserIdentityFactory.Build(resolved), null); } - var certificate = OpcUaUserIdentityFactory.LoadCertificate(resolved); - return (new UserIdentity(certificate), certificate); + return (OpcUaUserIdentityFactory.FromCertificate(resolved), null); } /// Select the endpoint matching the requested SecurityPolicy + SecurityMode pair. @@ -240,7 +239,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser { using var client = await DiscoveryClient.CreateAsync( appConfig, new Uri(url), DiagnosticsMasks.None, ct).ConfigureAwait(false); - var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false); + var all = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false); var wantedPolicy = MapPolicy(policy); var wantedMode = mode switch @@ -250,11 +249,11 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt, _ => throw new ArgumentOutOfRangeException(nameof(mode)), }; - var match = all.FirstOrDefault(e => + var match = all.ToList().FirstOrDefault(e => e.SecurityPolicyUri == wantedPolicy && e.SecurityMode == wantedMode); if (match is null) { - var advertised = string.Join(", ", all.Select(e => + var advertised = string.Join(", ", all.ToList().Select(e => $"{ShortName(e.SecurityPolicyUri)}/{e.SecurityMode}")); throw new InvalidOperationException( $"No endpoint at '{url}' matches SecurityPolicy={policy} + SecurityMode={mode}. " + @@ -278,7 +277,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser /// Render an OPC UA security-policy URI as its short suffix for diag messages. /// Full policy URI to shorten. - private static string ShortName(string uri) => + private static string ShortName(string? uri) => uri?.Substring(uri.LastIndexOf('#') + 1) ?? "(null)"; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj index a023fb83..94fd7e3b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj @@ -13,7 +13,6 @@ - diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/NamespaceMap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/NamespaceMap.cs index a71dc1c0..7e17c774 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/NamespaceMap.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/NamespaceMap.cs @@ -196,7 +196,6 @@ public sealed class NamespaceMap /// The server-stable reference string for . public string ToStableReference(NodeId nodeId) { - ArgumentNullException.ThrowIfNull(nodeId); // Namespace 0 is fixed by the spec — the compact form is already stable. if (nodeId.NamespaceIndex == 0) return nodeId.ToString() ?? string.Empty; @@ -238,7 +237,7 @@ public sealed class NamespaceMap // reorder since the reference was captured resolves correctly. Plain ns=N forms // resolve directly. nodeId = NodeId.Parse(currentSession.MessageContext, reference); - return !NodeId.IsNull(nodeId); + return !nodeId.IsNull; } catch { @@ -249,10 +248,10 @@ public sealed class NamespaceMap /// Render just the identifier portion (s=…, i=…, g=…, b=…) of a NodeId. private static string IdentifierPart(NodeId nodeId) => nodeId.IdType switch { - IdType.Numeric => $"i={nodeId.Identifier}", - IdType.String => $"s={nodeId.Identifier}", - IdType.Guid => $"g={nodeId.Identifier}", - IdType.Opaque => $"b={Convert.ToBase64String((byte[])nodeId.Identifier)}", - _ => $"s={nodeId.Identifier}", + IdType.Numeric => $"i={nodeId.IdentifierAsString}", + IdType.String => $"s={nodeId.IdentifierAsString}", + IdType.Guid => $"g={nodeId.IdentifierAsString}", + IdType.Opaque => $"b={nodeId.IdentifierAsString}", + _ => throw new ArgumentOutOfRangeException(nameof(nodeId)), }; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs index 29510067..20605be4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs @@ -1,4 +1,5 @@ using Opc.Ua; +using Opc.Ua.Security.Certificates; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; @@ -37,7 +38,37 @@ public static class OpcUaUserIdentityFactory /// /// Driver options containing certificate configuration. /// The certificate-backed . - public static UserIdentity FromCertificate(OpcUaClientDriverOptions options) => new(LoadCertificate(options)); + public static UserIdentity FromCertificate(OpcUaClientDriverOptions options) + { + using var certificate = LoadCertificate(options); + var identifier = new CertificateIdentifier { Thumbprint = certificate.Thumbprint }; + return new UserIdentity(new X509IdentityTokenHandler(identifier, + new CertificatePasswordProvider(), new FileCertificateProvider(options, certificate.Thumbprint))); + } + + // The configured credential is a PFX file, rather than an SDK certificate store. SDK 2 resolves + // signing keys on demand and disposes each returned Certificate. Pin the public identity for this + // lifecycle; a file/secret rotation is picked up only by the next initialise/open. + private sealed class FileCertificateProvider(OpcUaClientDriverOptions options, string thumbprint) : ICertificateProvider + { + public Certificate? TryGetPrivateKeyCertificate(string requestedThumbprint) => null; + + public ValueTask GetPrivateKeyCertificateAsync(CertificateIdentifier identifier, + ICertificatePasswordProvider? passwordProvider = null, string? applicationUri = null, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var certificate = LoadCertificate(options); + if (!string.Equals(certificate.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase) + || !string.Equals(identifier.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)) + { + certificate.Dispose(); + throw new ServiceResultException(StatusCodes.BadIdentityTokenInvalid, + "The user certificate changed; reopen the session to use the rotated credential."); + } + return ValueTask.FromResult(Certificate.From(certificate)); + } + } /// /// Loads (and validates) the user-authentication certificate wraps. The caller owns diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamValueMapping.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamValueMapping.cs new file mode 100644 index 00000000..2123467c --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamValueMapping.cs @@ -0,0 +1,26 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// Detaches SDK values at the driver boundary while preserving deployed CLR payload types. +public static class UpstreamValueMapping +{ + /// Materializes scalar and one-dimensional array values without flattening matrices. + /// The upstream value. + /// The independent CLR payload. + public static object? ToClr(Variant value) => Detach(value.AsBoxedObject(Variant.BoxingBehavior.Legacy)); + + private static object? Detach(object? value) => value switch + { + ByteString bytes => bytes.IsNull ? null : bytes.ToArray(), + DateTimeUtc time => time.ToDateTime(), + Uuid uuid => (Guid)uuid, + ByteString[] bytes => bytes.Select(b => b.IsNull ? null : b.ToArray()).ToArray(), + DateTimeUtc[] times => times.Select(t => t.ToDateTime()).ToArray(), + Uuid[] uuids => uuids.Select(u => (Guid)u).ToArray(), + Variant[] variants => variants.Select(ToClr).ToArray(), + object?[] objects => objects.Select(Detach).ToArray(), + Array array => array.Clone(), + _ => value, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj index db5458cb..a495b149 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj @@ -34,7 +34,6 @@ ContractsIsDependencyLightTests in Driver.OpcUaClient.Tests. --> -