From 9af1a824b3e0c126e80c414c3e8461d1c9619276 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 10:04:14 -0500 Subject: [PATCH] refactor: migrate OPC UA server SDK 2.0 compilation and boundaries --- .../AdvertisedAggregateFunctions.cs | 5 +- .../AggregateFunctionMap.cs | 2 +- .../EquipmentVariableState.cs | 55 +++ .../HistoryBounds.cs | 2 +- .../HistoryContinuationStore.cs | 20 +- .../HistoryPaging.cs | 3 +- .../OpcUaApplicationHost.cs | 159 +++---- .../OtOpcUaNodeManager.cs | 416 +++++++++--------- .../OtOpcUaSdkServer.cs | 51 ++- .../SdkServiceLevelPublisher.cs | 6 +- .../SdkValueBoundary.cs | 34 ++ .../Security/DataPlaneAuthorizer.cs | 2 +- .../Security/SecureChannelRemoteEndpoint.cs | 106 ----- .../ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj | 6 - 14 files changed, 416 insertions(+), 451 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EquipmentVariableState.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkValueBoundary.cs delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedAggregateFunctions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedAggregateFunctions.cs index 7913d2cd..be8a6e8f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedAggregateFunctions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedAggregateFunctions.cs @@ -27,6 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// internal static class AdvertisedAggregateFunctions { + private static readonly object SyncGate = new(); /// Every standard aggregate-function NodeId the SDK's nodeset knows (ObjectIds.AggregateFunction_*). private static readonly NodeId[] StandardAggregateIds = typeof(ObjectIds) .GetFields(BindingFlags.Public | BindingFlags.Static) @@ -56,10 +57,10 @@ internal static class AdvertisedAggregateFunctions /// The advertised NodeIds, or null when the diagnostics node manager or folder is not available. public static IReadOnlyList? Apply(IServerInternal server, IReadOnlySet served) { - if (server?.DiagnosticsNodeManager is not CustomNodeManager2 diagnostics) return null; + if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return null; var advertised = new List(); - lock (diagnostics.Lock) + lock (SyncGate) { var folder = diagnostics.FindPredefinedNode(Folder); if (folder is null) return null; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AggregateFunctionMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AggregateFunctionMap.cs index ca0f6333..00be61fc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AggregateFunctionMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AggregateFunctionMap.cs @@ -51,5 +51,5 @@ internal static class AggregateFunctionMap /// The per-node aggregate-function NodeId from the request. /// The mapped aggregate, or null. public static HistoryAggregateType? Map(NodeId? aggregateNodeId) => - aggregateNodeId is not null && ByNodeId.TryGetValue(aggregateNodeId, out var aggregate) ? aggregate : null; + aggregateNodeId is { } nodeId && ByNodeId.TryGetValue(nodeId, out var aggregate) ? aggregate : null; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EquipmentVariableState.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EquipmentVariableState.cs new file mode 100644 index 00000000..a5528ed6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EquipmentVariableState.cs @@ -0,0 +1,55 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// +/// Preserves the application's established equipment-write conversions across SDK 2.0's +/// new pre-handler type validation. The owning callback remains the authorization and shape gate. +/// +internal sealed class EquipmentVariableState(NodeState? parent, object ownerLock) : BaseDataVariableState(parent) +{ + /// + protected override ServiceResult WriteValueAttribute(ISystemContext context, NumericRange indexRange, + Variant value, StatusCode statusCode, DateTimeUtc sourceTimestamp) + { + lock (ownerLock) + { + if (OnWriteValue is not { } write) return base.WriteValueAttribute(context, indexRange, value, statusCode, sourceTimestamp); + + if ((AccessLevel & AccessLevels.CurrentWrite) == 0) return StatusCodes.BadNotWritable; + var userAccess = UserAccessLevel; + OnReadUserAccessLevel?.Invoke(context, this, ref userAccess); + if ((userAccess & AccessLevels.CurrentWrite) == 0) return StatusCodes.BadUserAccessDenied; + + // Match BaseVariableState.CheckStatusAndTimestampWritable in exact tagged SDK 2.0.1. + if (!statusCode.Equals(StatusCodes.Good, StatusCodeComparison.CodeBitsOnly)) + { + if ((AccessLevel & AccessLevels.StatusWrite) == 0) return StatusCodes.BadWriteNotSupported; + if ((userAccess & AccessLevels.StatusWrite) == 0) return StatusCodes.BadUserAccessDenied; + } + if (sourceTimestamp != DateTimeUtc.MinValue) + { + if ((AccessLevel & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadWriteNotSupported; + if ((userAccess & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadUserAccessDenied; + } + + var result = write(context, this, indexRange, default, ref value, ref statusCode, ref sourceTimestamp); + if (ServiceResult.IsBad(result)) return result; + + // Value's first-touch setter can reset status. Commit in this order, under the shared lock. + Value = value; + StatusCode = statusCode; + Timestamp = sourceTimestamp == DateTimeUtc.MinValue ? DateTimeUtc.Now : sourceTimestamp; + ChangeMasks |= NodeStateChangeMasks.Value; + return result; + } + } + + /// + protected override ServiceResult ReadValueAttribute(ISystemContext context, NumericRange indexRange, + QualifiedName dataEncoding, ref Variant value, ref DateTimeUtc sourceTimestamp) + { + // Subscription samplers read the node directly, bypassing the manager's service lock. + lock (ownerLock) return base.ReadValueAttribute(context, indexRange, dataEncoding, ref value, ref sourceTimestamp); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs index 663cbb48..c634592a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs @@ -102,7 +102,7 @@ internal static class HistoryBounds /// The placeholder. public static DataValueSnapshot NotFound(DateTime atUtc) { - var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound, atUtc, atUtc); + var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound.Code, atUtc, atUtc); Placeholders.AddOrUpdate(placeholder, PlaceholderMark); return placeholder; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryContinuationStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryContinuationStore.cs index 14efa57d..b91a33e6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryContinuationStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryContinuationStore.cs @@ -1,3 +1,4 @@ +using Opc.Ua; using Opc.Ua.Server; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -43,8 +44,8 @@ internal interface IHistoryContinuationStore /// /// Production backed by the OPC UA SDK's per-session -/// history-continuation store ( / -/// ). Using the SDK store gives us, for free: +/// history-continuation store ( / +/// ). Using the SDK store gives us, for free: /// /// per-session lifecycle — points are disposed when the session closes, so a client that /// disconnects mid-page can never leak resume state; @@ -75,7 +76,7 @@ internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStor // A fresh Guid is the opaque point: 16 bytes, collision-free, and the SDK keys its session slot by // it. The SDK enforces ServerConfiguration.MaxHistoryContinuationPoints with oldest-eviction. var id = Guid.NewGuid(); - session.SaveHistoryContinuationPoint(id, state); + session.ContinuationPoints.SaveHistory(new SessionHistoryPoint(id, state)); return id.ToByteArray(); } @@ -85,13 +86,22 @@ internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStor if (session is null) return null; // RestoreHistoryContinuationPoint REMOVES the slot (single-use) and returns null for an unknown / // malformed (non-16-byte) point — exactly the "miss ⇒ BadContinuationPointInvalid" contract. - return session.RestoreHistoryContinuationPoint(continuationPoint) as HistoryContinuationState; + using var point = session.ContinuationPoints.RestoreHistory(ByteString.From(continuationPoint)); + return (point as SessionHistoryPoint)?.State; } /// public void Release(ISession? session, byte[] continuationPoint) => // Restoring removes the slot; we discard the value. Null session / unknown point ⇒ no-op. - session?.RestoreHistoryContinuationPoint(continuationPoint); + session?.ContinuationPoints.ReleaseHistory(ByteString.From(continuationPoint)); + private sealed class SessionHistoryPoint(Guid id, HistoryContinuationState state) : IHistoryContinuationPoint + { + public Guid Id { get; } = id; + public HistoryContinuationState State { get; } = state; + // The immutable resume state contains managed value data only. No handles or owned resources. + public void Dispose() { } + } + } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs index bee16369..062e3593 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs @@ -143,9 +143,8 @@ internal static class HistoryPaging if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return false; try { - lock (diagnostics.Lock) { - var capabilities = diagnostics.GetDefaultHistoryCapabilities(); + var capabilities = diagnostics.GetDefaultHistoryCapabilitiesAsync().AsTask().GetAwaiter().GetResult(); if (capabilities?.MaxReturnDataValues is not { } property) return false; property.Value = (uint)Math.Max(0, maxRawValuesPerRead); property.ClearChangeMasks(diagnostics.SystemContext, false); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 6a806fab..ba267785 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -2,6 +2,7 @@ using System.Text; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Configuration; +using Opc.Ua.Identity; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; @@ -172,7 +173,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable private readonly ILogger _logger; private ApplicationInstance? _application; private StandardServer? _server; - private ImpersonateEventHandler? _impersonateHandler; + private readonly List _identityAuthenticators = []; /// Initializes a new instance of the OPC UA application host. /// The host configuration options. @@ -222,30 +223,38 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable _options.PublicHostname, _options.OpcUaPort); } - /// - /// Subscribes to after the SDK has its - /// SessionManager ready (only after _application.Start). Anonymous tokens - /// pass through; UserName tokens hit and, on - /// success, attach a with the mapped role-set to the session - /// so downstream ACL checks can read it via OperationContext.UserIdentity. - /// - /// The SDK calls ImpersonateUser synchronously off the session-activation - /// thread, so the authenticator's async work is run via GetAwaiter().GetResult(). - /// LDAP binds typically complete in <100 ms; if a backing store ever gets that slow - /// it should not block the OPC UA stack — callers must enforce their own timeouts inside - /// . - /// private void AttachUserAuthenticator() { - var sessionManager = _server?.CurrentInstance?.SessionManager; - if (sessionManager is null) + var registry = _server?.CurrentInstance?.IdentityRegistry + ?? throw new InvalidOperationException("OPC UA identity registry unavailable after start."); + foreach (var type in new[] { UserTokenType.Anonymous, UserTokenType.UserName }) { - _logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled"); - return; + var adapter = new ApplicationTokenAuthenticator(this, type); + registry.Register(adapter); + _identityAuthenticators.Add(adapter); } + } - _impersonateHandler = OnImpersonateUser; - sessionManager.ImpersonateUser += _impersonateHandler; + private sealed class ApplicationTokenAuthenticator(OpcUaApplicationHost host, UserTokenType tokenType) + : IUserTokenAuthenticator + { + public UserTokenType TokenType => tokenType; + public string? IssuedTokenProfileUri => null; + + public async ValueTask AuthenticateAsync(AuthenticationContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var args = new ImpersonateEventArgs(context.TokenHandler, context.UserTokenPolicy, context.EndpointDescription); + var peer = (host._server as OtOpcUaSdkServer)?.ActivationPeerAddress; + // Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port. + await HandleImpersonationAsync(host._userAuthenticator, args, host._logger, host._options.AllowAnonymous, + peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct).ConfigureAwait(false); + return ServiceResult.IsBad(args.IdentityValidationError) + ? AuthenticationResult.Reject(args.IdentityValidationError) + : args.Identity is { } identity + ? AuthenticationResult.Accept(identity) + : AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected)); + } } /// @@ -298,46 +307,18 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer)) uris.Add(peer); } - serverObject.ServerArray.Value = uris.ToArray(); + serverObject.ServerArray!.Value = (ArrayOf)uris; } } - private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) => - HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous, - // Only a UserName activation consults the endpoint (the per-client API-key miss budget, #751), so - // the reflection walk is skipped for anonymous / X509. - args.NewIdentity is UserNameIdentityToken ? ResolveRemoteEndpoint(session) : null); - - /// The remote endpoint of the client activating , or null when unknown. - /// The session being activated. - /// The client's IP endpoint, or null. - /// - /// Read from the session's secure channel — the SDK raises impersonation without the request's channel - /// context (SecureChannelContext.Current is obsolete in 1.5.378). A client that re-activates over - /// a NEW channel after its old one closed resolves to null and is accounted process-wide only. - /// - private System.Net.IPEndPoint? ResolveRemoteEndpoint(ISession? session) => - _server is OtOpcUaSdkServer sdk ? sdk.TryResolveRemoteEndpoint(session?.SecureChannelId) : null; - /// /// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting /// the full SDK. Side-effects are confined to mutating /// and logging. /// /// - /// SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378). - /// The SDK raises this handler SYNCHRONOUSLY — it is a plain void delegate, and there is no - /// async impersonation callback in this SDK version. Worse, SessionManager.ActivateSessionAsync - /// invokes it while holding a single SessionManager-wide event lock (m_eventLock), so a - /// handler that blocks does not merely stall one SDK request thread — it SERIALIZES every session - /// activation server-wide (Anonymous and X509 included, since even those paths must take the lock to - /// raise the handler). Consequently the block-bridge below - /// (GetAwaiter().GetResult() under ) is irreducible: the - /// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded - /// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live - /// inside the authenticator — NOT here. (The LDAP authenticator was replaced with - /// ApiKeyOpcUaUserAuthenticator, whose verification does no network I/O at all, so the - /// bound/concurrency/circuit machinery that used to live there is gone rather than relocated.) + /// The registry path awaits authentication outside the SDK event lock. This synchronous observation + /// seam remains for existing handler tests; production invokes the asynchronous implementation directly. /// /// The user authenticator to validate credentials. /// The impersonation event arguments to process. @@ -351,14 +332,21 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable ILogger logger, bool allowAnonymous = true, System.Net.IPEndPoint? remoteEndpoint = null) + => HandleImpersonationAsync(authenticator, args, logger, allowAnonymous, remoteEndpoint) + .AsTask().GetAwaiter().GetResult(); + + private static async ValueTask HandleImpersonationAsync( + IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger, + bool allowAnonymous = true, System.Net.IPEndPoint? remoteEndpoint = null, + CancellationToken cancellationToken = default) { - if (args.NewIdentity is AnonymousIdentityToken anonymousToken) + if (args.UserIdentityTokenHandler.Token is AnonymousIdentityToken anonymousToken) { - HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken); + await HandleAnonymousImpersonationAsync(authenticator, args, logger, allowAnonymous, anonymousToken, cancellationToken).ConfigureAwait(false); return; } - if (args.NewIdentity is not UserNameIdentityToken token) + if (args.UserIdentityTokenHandler.Token is not UserNameIdentityToken token) { // X509 tokens — let the SDK's default validation stand. return; @@ -369,29 +357,27 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable { // 1.5.378 exposes DecryptedPassword as raw bytes (was string); UserName token passwords // are UTF-8 on the wire. - var decryptedBytes = token.DecryptedPassword; + var decryptedBytes = (args.UserIdentityTokenHandler as UserNameIdentityTokenHandler)?.DecryptedPassword; password = decryptedBytes is null ? string.Empty : Encoding.UTF8.GetString(decryptedBytes); } catch (Exception ex) { logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token"); - args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, - "UserName token decryption failed"); + args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "UserName token decryption failed"); return; } OpcUaUserAuthResult result; try { - result = authenticator - .AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, CancellationToken.None) - .GetAwaiter().GetResult(); + result = await authenticator + .AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, cancellationToken) + .ConfigureAwait(false); } catch (Exception ex) { logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName); - args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, - "Authentication failed"); + args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Authentication failed"); return; } @@ -399,8 +385,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable { logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}", token.UserName, result.Error); - args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, - result.Error ?? "Invalid credentials"); + args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Invalid credentials"); return; } @@ -421,28 +406,25 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// the UserName path uses, so every downstream gate reads /// it identically and there is no anonymous-shaped branch in any of them. /// - private static void HandleAnonymousImpersonation( + private static async ValueTask HandleAnonymousImpersonationAsync( IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger, bool allowAnonymous, - AnonymousIdentityToken token) + AnonymousIdentityToken token, CancellationToken cancellationToken) { if (!allowAnonymous) { logger.LogInformation( "OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)"); - args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, - "Anonymous access is disabled on this server."); + args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled on this server."); return; } OpcUaUserAuthResult result; try { - // Same synchronous bridge, and for the same irreducible reason, as the UserName path above. - result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None) - .GetAwaiter().GetResult(); + result = await authenticator.AuthenticateAnonymousAsync(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -450,16 +432,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable // to the SDK default identity, because that would silently ADMIT it — the previous // behaviour — at exactly the moment we could not determine what it is allowed to do. logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw"); - args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, - "Anonymous access is unavailable."); + args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is unavailable."); return; } if (!result.Success) { logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error); - args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, - result.Error ?? "Anonymous access is denied."); + args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Anonymous access is denied."); return; } @@ -500,18 +480,12 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable var serverConfig = new ServerConfiguration { - BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" }, + BaseAddresses = [$"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa"], }; ApplyRequestQuotas(serverConfig, _options.Quotas); - foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles)) - { - serverConfig.SecurityPolicies.Add(policy); - } - foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous)) - { - serverConfig.UserTokenPolicies.Add(token); - } + serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray(); + serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray(); var config = new ApplicationConfiguration { @@ -661,21 +635,20 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable { try { - // An explicit SDK stop may already have halted CurrentInstance; still continue disposal. - if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager) - sessionManager.ImpersonateUser -= _impersonateHandler; + if (_server?.CurrentInstance?.IdentityRegistry is { } registry) + foreach (var authenticator in _identityAuthenticators) registry.Unregister(authenticator); } catch (Exception ex) { - _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); + _logger.LogWarning(ex, "OpcUaApplicationHost: detaching identity authenticators threw"); } - _impersonateHandler = null; + _identityAuthenticators.Clear(); - if (_application is not null) + if (_application is { } application) { - // 1.5.378: ApplicationInstance.Stop() → StopAsync(). - try { await _application.StopAsync().ConfigureAwait(false); } - catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); } + _application = null; + try { await application.DisposeAsync().ConfigureAwait(false); } + catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); } } } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 6383b1f1..98868f3b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -131,6 +131,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 : base(server, configuration, RegisteredNamespaceUris) { _logger = logger ?? NullLogger.Instance; + // Application model events use the single ACL-filtered ReportToServerObject path. + ModelChangeEmissionEnabled = false; // SystemContext is initialised by the base ctor; both namespace URIs are now registered and their // indexes captured (in the order passed) in NamespaceIndexes. } @@ -180,15 +182,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The realm-qualified map key. private RealmKey MapKey(NodeId nodeId) { - if (nodeId.Identifier is string s) return new RealmKey(RealmOf(nodeId), s); + if (nodeId.TryGetValue(out string s)) return new RealmKey(RealmOf(nodeId), s); // Unreachable by construction — every call site passes a node this manager minted with a string // `s=` identifier. If it ever fires, the derived key resolves to nothing and the caller sees an // unexplained miss, so name the seam rather than collapsing quietly. _logger.LogWarning( "OtOpcUaNodeManager.MapKey: non-string NodeId identifier reached the map-key seam (node {NodeId}, identifier type {IdentifierType}) — the derived key will not resolve", - nodeId, nodeId.Identifier?.GetType().Name ?? "null"); - return new RealmKey(RealmOf(nodeId), nodeId.Identifier?.ToString() ?? string.Empty); + nodeId, nodeId.IdType.ToString()); + return new RealmKey(RealmOf(nodeId), nodeId.IdentifierAsString); } /// Gets the count of variable nodes currently managed. @@ -402,7 +404,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (wired) { folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead); - _eventNotifierSources[key] = nodeId.Identifier?.ToString() ?? string.Empty; + _eventNotifierSources[key] = nodeId.IdentifierAsString; } else { @@ -1073,7 +1075,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!_variables.TryGetValue(key, out var variable)) return false; - variable.Value = value; + variable.Value = SdkValueBoundary.FromClr(value, variable.DataType == DataTypeIds.Byte && variable.ValueRank == ValueRanks.OneDimension); // The caller's status code reaches the SDK verbatim, sub-code included. This used to // re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a // driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client. @@ -1160,7 +1162,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc, UtcNow) : 0); } condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity)); - condition.Message.Value = new LocalizedText(state.Message); + condition.Message!.Value = new LocalizedText(state.Message); // Project the source-data quality. A Good→Bad bucket change is a genuine condition // change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality; @@ -1176,9 +1178,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // ConditionRefresh replays it. The event firing below also depends on this Retain being // correct (a non-retained inactive+acked condition still fires its transition event, but // won't be replayed on a later ConditionRefresh). - condition.Retain.Value = state.Active || !state.Acknowledged; - condition.Time.Value = sourceTimestampUtc; - condition.ReceiveTime.Value = sourceTimestampUtc; + condition.Retain!.Value = state.Active || !state.Acknowledged; + condition.Time!.Value = sourceTimestampUtc; + condition.ReceiveTime!.Value = sourceTimestampUtc; // T20 — fire a real Part 9 condition event ONLY when this projection is a genuine state // change (the delta-gate decided above, against the node's pre-projection state). A @@ -1254,8 +1256,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Fire ONLY on a real change so a steady-state connectivity re-assert doesn't spam events. if (changed) { - condition.Time.Value = sourceTimestampUtc; - condition.ReceiveTime.Value = sourceTimestampUtc; + condition.Time!.Value = sourceTimestampUtc; + condition.ReceiveTime!.Value = sourceTimestampUtc; ReportConditionEvent(condition, sourceTimestampUtc); } } @@ -1294,12 +1296,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private void ReportConditionEvent(AlarmConditionState alarm, DateTime ts) { // Fresh GUID-bytes EventId per event — mandatory for Part 9 ack correlation (T17 relies on it). - alarm.EventId.Value = Guid.NewGuid().ToByteArray(); + alarm.EventId!.Value = ByteString.From(Guid.NewGuid().ToByteArray()); // Time/ReceiveTime were already set to sourceTimestampUtc by the WriteAlarmCondition projection // immediately above; the assignment here is a locality repeat (same value, no behavioral change) // so the restamp is co-located with the EventId and ClearChangeMasks in the same method. - alarm.Time.Value = ts; - alarm.ReceiveTime.Value = ts; + alarm.Time!.Value = ts; + alarm.ReceiveTime!.Value = ts; // Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so // the InstanceStateSnapshot captures the just-projected values. @@ -1401,7 +1403,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 Enabled: condition.EnabledState?.Id?.Value ?? true, Shelving: ReadShelvingKind(condition), MappedSeverity: condition.Severity?.Value ?? (ushort)0, - Message: condition.Message?.Value?.Text ?? string.Empty, + Message: condition.Message?.Value.Text ?? string.Empty, // Optional Part 9 child; a node without it reads as Good (matching the accidentally-Good default // and ToConditionDelta's fold), so a snapshot Quality can't create a phantom delta against it. Quality: condition.Quality?.Value ?? StatusCodes.Good); @@ -1434,7 +1436,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// unsupported shelving snapshot to, so the two stay comparable. private static AlarmShelvingKind ReadShelvingKind(AlarmConditionState condition) { - var stateId = condition.ShelvingState?.CurrentState?.Id?.Value as NodeId; + var stateId = condition.ShelvingState?.CurrentState?.Id?.Value; if (stateId == ObjectIds.ShelvedStateMachineType_OneShotShelved) return AlarmShelvingKind.OneShot; if (stateId == ObjectIds.ShelvedStateMachineType_TimedShelved) return AlarmShelvingKind.Timed; return AlarmShelvingKind.Unshelved; @@ -1552,9 +1554,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted). // Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and // is already carried by ConditionName below. See docs/AlarmTracking.md. - alarm.EventType.Value = alarm.TypeDefinitionId; - alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it - alarm.SourceName.Value = alarmNodeId; + alarm.EventType!.Value = alarm.TypeDefinitionId; + alarm.SourceNode!.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it + alarm.SourceName!.Value = alarmNodeId; // The mandatory ConditionType classification fields, unset by Create() for the same reason as // the fields above (mandatory, no default, nothing downstream synthesises them) ⇒ NodeId.Null + empty @@ -1597,8 +1599,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // represent (int.MaxValue ms ≈ 24.8 days, the same ceiling SetShelvingState already saturated a 0 to). if (alarm.MaxTimeShelved is { } maxTimeShelved && maxTimeShelved.Value <= 0) maxTimeShelved.Value = int.MaxValue; - alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet - alarm.Message.Value = new LocalizedText(displayName); + alarm.Retain!.Value = false; // inactive + acked ⇒ nothing to retain yet + alarm.Message!.Value = new LocalizedText(displayName); if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName; // T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/ @@ -1740,9 +1742,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Its carried state. private NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new( ReadConditionDelta(condition), - condition.Time?.Value ?? UtcNow, - condition.ActiveState?.TransitionTime?.Value, - condition.Quality?.SourceTimestamp?.Value, + condition.Time?.Value.ToDateTime() ?? UtcNow, + condition.ActiveState?.TransitionTime?.Value.ToDateTime(), + condition.Quality?.SourceTimestamp?.Value.ToDateTime(), condition.Comment?.Value, condition.ClientUserId?.Value); @@ -1789,12 +1791,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 alarm.SetAcknowledgedState(SystemContext, state.Acknowledged); if (alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, state.Confirmed); alarm.SetSeverity(SystemContext, (EventSeverity)state.MappedSeverity); - alarm.Message.Value = new LocalizedText(state.Message); - alarm.Retain.Value = true; - if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment; + alarm.Message!.Value = new LocalizedText(state.Message); + alarm.Retain!.Value = true; + if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment.Value; if (alarm.ClientUserId is not null && carried.ClientUserId is not null) alarm.ClientUserId.Value = carried.ClientUserId; - alarm.Time.Value = carried.Time; - alarm.ReceiveTime.Value = carried.Time; + alarm.Time!.Value = carried.Time; + alarm.ReceiveTime!.Value = carried.Time; _restoredConditionReports.Add((conditionKey, alarm)); } @@ -1888,8 +1890,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // dedups by a linear ReferenceEquals walk of the condition's notifier list, so re-wiring K // folders on an idempotent re-apply cost O(K²) for nothing. if (!wiring.Folders.Add(folder)) continue; - alarm.AddNotifier(SystemContext, null, isInverse: true, folder); - folder.AddNotifier(SystemContext, null, isInverse: false, alarm); + alarm.AddNotifier(SystemContext, NodeId.Null, isInverse: true, folder); + folder.AddNotifier(SystemContext, NodeId.Null, isInverse: false, alarm); EnsureFolderIsEventNotifier(folder); } } @@ -1982,7 +1984,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // uncommented re-ack still changes nothing and stays silent. alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) => ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true, - alwaysReport: !LocalizedText.IsNullOrEmpty(comment)); + alwaysReport: !comment.IsNullOrEmpty); } WrapComment(alarm.Confirm, alwaysReport: false); @@ -2026,7 +2028,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { var before = ReadConditionDelta(alarm); - var retainBefore = alarm.Retain.Value; + var retainBefore = alarm.Retain!.Value; var result = sdkHandler(); // A rebuild may have discarded this instance mid-call; its replacement is not what was called. @@ -2045,13 +2047,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { // Native: no confirm workflow — keep what the driver projection says (see remarks). alarm.SetConfirmedState(SystemContext, true); - alarm.Retain.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true); + alarm.Retain!.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true); } else if (before.Acknowledged) { // Re-ack: the SDK flipped Confirmed though nothing was acknowledged; put it back. alarm.SetConfirmedState(SystemContext, before.Confirmed); - alarm.Retain.Value = retainBefore; + alarm.Retain!.Value = retainBefore; } } @@ -2102,7 +2104,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (gate is not null) return gate; var cmd = new AlarmCommand( - AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty, + AlarmId: condition.NodeId.IdentifierAsString, Operation: operation, User: AlarmOperatorName(identity!), Comment: comment?.Text, @@ -2148,15 +2150,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // reverted by the driver's next transition. Nothing is routed, applied or reported. if (NativeAlarmAckPrimaryGate is { } primary && !primary()) { - return new ServiceResult(StatusCodes.BadInvalidState, - new LocalizedText("This server is not the redundancy Primary; acknowledge the alarm on the Primary (the server with the higher ServiceLevel).")); + return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText("This server is not the redundancy Primary; acknowledge the alarm on the Primary (the server with the higher ServiceLevel).")); } // Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()): // for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map // (_alarmNodeIdByDriverRef value set) keys by, so the next task can resolve it back to (driver, ref). var ack = new NativeAlarmAck( - ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty, + ConditionNodeId: condition.NodeId.IdentifierAsString, Comment: comment?.Text, OperatorUser: AlarmOperatorName(identity!), ApiKeyId: identity!.ApiKeyId); @@ -2282,7 +2283,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// private ServiceResult OnEquipmentTagWrite( ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding, - ref object value, ref StatusCode statusCode, ref DateTime timestamp) + ref Variant value, ref StatusCode statusCode, ref DateTimeUtc timestamp) { var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity; var gateway = _nodeWriteGateway; @@ -2296,7 +2297,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // "writes unavailable" on a node with no gateway. var gate = AclEnforced ? _authorizer!.Authorize(identity, OpcUaOperation.WriteOperate, node.NodeId, RealmOf(node.NodeId)) - ?? (gatewayWired ? null : new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable")) + ?? (gatewayWired ? null : ServiceResult.Create(StatusCodes.BadNotWritable, "writes unavailable")) : _authorizer?.AuthorizeKeyUsable(identity, OpcUaOperation.WriteOperate, node.NodeId) ?? EvaluateEquipmentWriteGate(identity, gatewayWired); if (gate is not null) return gate; @@ -2310,24 +2311,24 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId)); var range = EvaluateEquipmentWriteRange(indexRange); if (range is not null) return range; - var structure = EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar, out var coerced); + var structure = EvaluateEquipmentWriteStructure(SdkValueBoundary.ToClr(value), node, refuseArrayOnScalar, out var coerced); if (structure is not null) return structure; - value = coerced!; + value = SdkValueBoundary.FromClr(coerced, node is BaseDataVariableState { DataType: var dt, ValueRank: ValueRanks.OneDimension } && dt == DataTypeIds.Byte); // Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write // (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`). - var optimisticValue = value; + var optimisticValue = coerced; // v3 dual-namespace: route by the FULL (namespace-qualified) NodeId string so a Raw write and a UNS // write to nodes that share a bare id are distinguishable downstream — the write gateway keys its // (DriverInstanceId, RawPath) resolution on this ns-qualified id. Revert keys off the bare id + realm. var nodeKey = node.NodeId.ToString(); var nodeRealm = RealmOf(node.NodeId); - var nodeBareId = node.NodeId.Identifier?.ToString() ?? string.Empty; + var nodeBareId = node.NodeId.IdentifierAsString; object? priorValue = null; StatusCode priorStatus = StatusCodes.Good; if (node is BaseDataVariableState variable) { - priorValue = variable.Value; + priorValue = SdkValueBoundary.ToClr(variable.Value); priorStatus = variable.StatusCode; } // Item C: thread the writing principal's user-id string into the failure continuation so the audit @@ -2368,7 +2369,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 catch (Exception ex) { _logger.LogWarning(ex, "OtOpcUaNodeManager: write dispatch for {NodeId} threw synchronously; the write is refused", nodeKey); - return new ServiceResult(StatusCodes.BadDeviceFailure, $"write dispatch threw: {ex.Message}"); + return ServiceResult.Create(StatusCodes.BadDeviceFailure, $"write dispatch threw: {ex.Message}"); } _ = dispatch.ContinueWith( @@ -2424,7 +2425,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (!gatewayWired) { // Gate passed but no gateway wired (admin-only nodes / pre-boot) ⇒ writes unavailable. - return new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable"); + return ServiceResult.Create(StatusCodes.BadNotWritable, "writes unavailable"); } return null; @@ -2501,7 +2502,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Minimum sensible check: a null payload is never a valid value write to a value variable. if (value is null) { - return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected"); + return ServiceResult.Create(StatusCodes.BadTypeMismatch, "null value write rejected"); } // An array node (writable only when its driver encodes whole-array writes) takes a whole @@ -2514,20 +2515,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 isArrayNode = true; if (value is not Array { Rank: 1 } array) { - return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value"); + return ServiceResult.Create(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value"); } var declared = arrayNode.ArrayDimensions is { Count: > 0 } dims ? dims[0] : 0u; if (declared > 0 && array.Length != declared) { - return new ServiceResult( - StatusCodes.BadTypeMismatch, - $"array write of {array.Length} element(s) to a {declared}-element array node"); + return ServiceResult.Create(StatusCodes.BadTypeMismatch, $"array write of {array.Length} element(s) to a {declared}-element array node"); } } // Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable // with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply - // resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative. + // resolvable do we compare; otherwise proceed and let the owning driver perform its existing scalar range checks. if (node is not BaseDataVariableState variable) return null; var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard @@ -2542,7 +2541,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // takes one). An unresolved-DataType node deferred above, as it always has. if (refuseArrayOnScalar && variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[]) { - return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value"); + return ServiceResult.Create(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value"); } // SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can @@ -2583,22 +2582,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 coerced = converted; return null; } - return new ServiceResult( - StatusCodes.BadTypeMismatch, - $"array element of type {actual} is not exactly representable as node data type {expected}"); + return ServiceResult.Create(StatusCodes.BadTypeMismatch, $"array element of type {actual} is not exactly representable as node data type {expected}"); } - return new ServiceResult( - StatusCodes.BadTypeMismatch, - $"array element type {actual} does not match node data type {expected}"); + return ServiceResult.Create(StatusCodes.BadTypeMismatch, $"array element type {actual} does not match node data type {expected}"); } if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK if (!IsBuiltInTypeCompatible(expected, actual)) { - return new ServiceResult( - StatusCodes.BadTypeMismatch, - $"value built-in type {actual} is not compatible with node data type {expected}"); + return ServiceResult.Create(StatusCodes.BadTypeMismatch, $"value built-in type {actual} is not compatible with node data type {expected}"); } return null; @@ -2611,12 +2604,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// can be stale or optimistic), and the SDK would apply the range to the node before the device answered. /// A range on a scalar node is meaningless and refused the same way. Pure + static. /// - /// The write's parsed IndexRange ( when absent). + /// The write's parsed IndexRange ( when absent). /// null to proceed; otherwise BadWriteNotSupported. internal static ServiceResult? EvaluateEquipmentWriteRange(NumericRange indexRange) => - indexRange == NumericRange.Empty + indexRange == default(NumericRange) ? null - : new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array"); + : ServiceResult.Create(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array"); /// /// Conservative built-in-type compatibility test for 's @@ -2797,7 +2790,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (!_variables.TryGetValue(key, out var variable)) return; // rebuilt/removed ⇒ no-op - if (!ShouldRevert(outcome, variable.Value, optimisticValue)) return; // success, or poll moved it on + if (!ShouldRevert(outcome, SdkValueBoundary.ToClr(variable.Value), optimisticValue)) return; // success, or poll moved it on // Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore // the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's @@ -2807,7 +2800,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 variable.Timestamp = now; variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip - variable.Value = priorValue; + variable.Value = SdkValueBoundary.FromClr(priorValue, variable.DataType == DataTypeIds.Byte && variable.ValueRank == ValueRanks.OneDimension); variable.StatusCode = priorStatus; variable.Timestamp = now; variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior @@ -2874,8 +2867,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // (verified against the 1.5.378 SDK; it tolerates a null value, creating the child with Value=null). audit.SetChildValue(SystemContext, BrowseNames.AttributeId, (uint)Attributes.Value, false); audit.SetChildValue(SystemContext, BrowseNames.IndexRange, string.Empty, false); - audit.SetChildValue(SystemContext, BrowseNames.OldValue, priorValue!, false); - audit.SetChildValue(SystemContext, BrowseNames.NewValue, optimisticValue!, false); + audit.SetChildValue(SystemContext, BrowseNames.OldValue, SdkValueBoundary.FromClr(priorValue), false); + audit.SetChildValue(SystemContext, BrowseNames.NewValue, SdkValueBoundary.FromClr(optimisticValue), false); // Standard AuditEvent client-identity fields. ClientUserId is the writing principal (threaded from the // handler); ClientAuditEntryId carries the SDK context's audit entry id when present. @@ -2964,7 +2957,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (OwnsNamespace(node.NodeId)) return node.NodeId; if (node is BaseInstanceState { Parent.NodeId: { IdType: IdType.String } parentId } && OwnsNamespace(parentId) - && !QualifiedName.IsNull(node.BrowseName)) + && !node.BrowseName.IsNull) { // A client calls a Part 9 method by its TYPE's method id (ObjectId = the condition, MethodId = // e.g. AlarmConditionType_ShelvingState_OneShotShelve, i=9212), and the SDK resolves it by NodeId OR @@ -2977,7 +2970,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (node is MethodState method && ShelvingMethodDeclarationIds.Contains(method.NodeId)) method.MethodDeclarationId = method.NodeId; - return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex); + return new NodeId($"{parentId.IdentifierAsString}/{node.BrowseName.Name}", parentId.NamespaceIndex); } return node.NodeId; } @@ -3032,7 +3025,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Key by the realm-qualified RealmKey built from the NodeId so a Raw and a UNS folder that share // the same bare id are distinct sources; the VALUE stays the bare id — that is the historian // ReadEventsAsync sourceName (== the equipment id). - var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty; + var sourceName = folder.NodeId.IdentifierAsString; _eventNotifierSources[MapKey(folder.NodeId)] = sourceName; } AddRootNotifier(folder); @@ -3085,7 +3078,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { NodeId = new NodeId(folderNodeId, ns), BrowseName = new QualifiedName(folderNodeId, ns), - DisplayName = displayName, + DisplayName = new LocalizedText(displayName), EventNotifier = EventNotifiers.None, TypeDefinitionId = ObjectTypeIds.FolderType, ReferenceTypeId = ReferenceTypeIds.Organizes, @@ -3143,7 +3136,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return false; - folder.DisplayName = displayName; + folder.DisplayName = new LocalizedText(displayName); folder.ClearChangeMasks(SystemContext, includeChildren: false); return true; } @@ -3203,20 +3196,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is // CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized. var access = ComposeAccessLevel(writable, historized, _historianDataUpdater is not null); - var variable = new BaseDataVariableState(parent) + var variable = new EquipmentVariableState(parent, Lock) { NodeId = new NodeId(variableNodeId, ns), BrowseName = new QualifiedName(variableNodeId, ns), - DisplayName = displayName, + DisplayName = new LocalizedText(displayName), TypeDefinitionId = VariableTypeIds.BaseDataVariableType, ReferenceTypeId = ReferenceTypeIds.Organizes, DataType = ResolveBuiltInDataType(dataType), ValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar, - ArrayDimensions = isArray ? new ReadOnlyList(new UInt32Collection(new[] { arrayLength ?? 0u })) : null, + ArrayDimensions = isArray ? (ArrayOf)[arrayLength ?? 0u] : default, AccessLevel = access, UserAccessLevel = access, Historizing = historized, - Value = null, + Value = Variant.Null, StatusCode = StatusCodes.BadWaitingForInitialData, Timestamp = DateTime.MinValue, }; @@ -3318,11 +3311,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 v.DataType = newDataType; v.ValueRank = newValueRank; v.ArrayDimensions = isArray - ? new ReadOnlyList(new UInt32Collection(new[] { arrayLength ?? 0u })) - : null; + ? (ArrayOf)[arrayLength ?? 0u] + : default; // Drop the stale (old-typed) value — mirrors EnsureVariable's fresh-node state so a client // never reads a value whose runtime type contradicts the just-changed DataType. - v.Value = null; + v.Value = Variant.Null; v.StatusCode = StatusCodes.BadWaitingForInitialData; v.Timestamp = DateTime.MinValue; shapeChangedNode = v; @@ -3375,7 +3368,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 }; // SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder // relies on for its child PropertyStates). - e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false); + e.SetChildValue(SystemContext, BrowseNames.Changes, (ArrayOf)[change], false); return e; } @@ -3566,7 +3559,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 }; // SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder // relies on for its child PropertyStates). - e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false); + e.SetChildValue(SystemContext, BrowseNames.Changes, (ArrayOf)[change], false); return e; } @@ -3599,7 +3592,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm), Verb = (byte)ModelChangeStructureVerbMask.NodeDeleted, }; - e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false); + e.SetChildValue(SystemContext, BrowseNames.Changes, (ArrayOf)[change], false); return e; } @@ -3784,7 +3777,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The node's CURRENT Time: the carried instant when nothing reported since the restore, or the // newer instant of a report that did (a client method call on an SDK thread can land between the // restore and here) — never roll the condition's Time back. - ReportConditionEvent(alarm, alarm.Time.Value); + ReportConditionEvent(alarm, alarm.Time!.Value.ToDateTime()); reported++; } @@ -3858,9 +3851,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 node.GetReferences(SystemContext, references); foreach (var r in references) { - if (r.IsInverse || r.TargetId is null || r.TargetId.IsAbsolute) continue; + if (r.IsInverse || r.TargetId.IsNull || r.TargetId.IsAbsolute) continue; var targetNodeId = (NodeId)r.TargetId; - if (NodeId.IsNull(targetNodeId)) continue; + if (targetNodeId.IsNull) continue; if (ResolveNodeState(MapKey(targetNodeId)) is not { } target) continue; if (!target.RemoveReference(r.ReferenceTypeId, isInverse: true, node.NodeId)) continue; target.ClearChangeMasks(SystemContext, includeChildren: false); @@ -4055,8 +4048,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node id to test. /// when this manager owns the node's namespace. private bool OwnsNamespace(NodeId? nodeId) => - nodeId is not null - && (nodeId.NamespaceIndex == NamespaceIndexes[0] || nodeId.NamespaceIndex == NamespaceIndexes[1]); + nodeId is { } id + && (id.NamespaceIndex == NamespaceIndexes[0] || id.NamespaceIndex == NamespaceIndexes[1]); /// Pulls the ACL identity off an SDK operation context. /// The SDK context carrying the session identity. @@ -4089,7 +4082,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 public override void Read( OperationContext context, double maxAge, - IList nodesToRead, + ArrayOf nodesToRead, IList values, IList errors) { @@ -4106,7 +4099,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 nodeToRead.Processed = true; errors[ii] = StatusCodes.BadUserAccessDenied; - values[ii] = new DataValue(StatusCodes.BadUserAccessDenied); + values[ii] = new DataValue(Variant.Null, StatusCodes.BadUserAccessDenied); denied++; } @@ -4139,7 +4132,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!AclEnforced) { - base.Browse(context, ref continuationPoint, references); + base.Browse(context, ref continuationPoint!, references); return; } @@ -4150,13 +4143,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // only the reference filtering below. var source = (continuationPoint?.NodeToBrowse as NodeHandle)?.NodeId; - if (source is not null && !Permits(identity, OpcUaOperation.Browse, source)) + if (source is not null && !Permits(identity, OpcUaOperation.Browse, source.Value)) throw new ServiceResultException(StatusCodes.BadUserAccessDenied); // Only the references THIS call appends are ours to filter — on a BrowseNext the list already // carries the earlier pages, and re-examining them would both waste work and skew the summary. var before = references.Count; - base.Browse(context, ref continuationPoint, references); + base.Browse(context, ref continuationPoint!, references); var returned = references.Count - before; // Forward two-pointer compaction over the appended range, NOT a per-denial RemoveAt. Removing @@ -4171,11 +4164,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // ExpandedNodeId → NodeId only makes sense for a reference into our own server; a reference // to another server's node is left alone, as this manager has no grants that could speak to it. var target = references[ii].NodeId; - var keep = target is null + var keep = target.IsNull || target.IsAbsolute - || ExpandedNodeId.ToNodeId(target, Server.NamespaceUris) is not { } targetId - || !OwnsNamespace(targetId) - || Permits(identity, OpcUaOperation.Browse, targetId); + || !OwnsNamespace(ExpandedNodeId.ToNodeId(target, Server.NamespaceUris)) + || Permits(identity, OpcUaOperation.Browse, ExpandedNodeId.ToNodeId(target, Server.NamespaceUris)); if (!keep) { @@ -4230,7 +4222,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 for (var ii = before; ii < targetIds.Count; ii++) { var targetId = ExpandedNodeId.ToNodeId(targetIds[ii], Server.NamespaceUris); - var keep = targetId is null + var keep = targetId.IsNull || !OwnsNamespace(targetId) || Permits(identity, OpcUaOperation.Browse, targetId); @@ -4251,7 +4243,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 uint subscriptionId, double publishingInterval, TimestampsToReturn timestampsToReturn, - IList itemsToCreate, + ArrayOf itemsToCreate, IList errors, IList filterErrors, IList monitoredItems, @@ -4268,7 +4260,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var item = itemsToCreate[ii]; var target = item.ItemToMonitor?.NodeId; if (item.Processed || !OwnsNamespace(target)) continue; - if (Permits(identity, OpcUaOperation.CreateMonitoredItems, target!)) continue; + if (Permits(identity, OpcUaOperation.CreateMonitoredItems, target!.Value)) continue; item.Processed = true; errors[ii] = StatusCodes.BadUserAccessDenied; @@ -4327,7 +4319,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (IsHandleInNamespace(sourceId) is { } h && (!MonitoredNodes.TryGetValue(h.NodeId, out var current) - || current.EventMonitoredItems?.Contains(monitoredItem) != true)) + || current.EventMonitoredItems?.ContainsKey(monitoredItem.Id) != true)) { return ServiceResult.Good; } @@ -4366,7 +4358,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (MonitoredNodes.TryGetValue(handle.NodeId, out var monitored) - && monitored.EventMonitoredItems?.Contains(monitoredItem) == true) + && monitored.EventMonitoredItems?.ContainsKey(monitoredItem.Id) == true) { _eventItems[monitoredItem] = new LiveEventItem(handle); } @@ -4434,7 +4426,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (monitoredItem is ISampledDataChangeMonitoredItem dataItem && (!MonitoredNodes.TryGetValue(handle.NodeId, out var current) - || current.DataChangeMonitoredItems?.Contains(dataItem) != true)) + || current.DataChangeMonitoredItems?.ContainsKey(dataItem.Id) != true)) { OnMonitoredItemDeleted(context, handle, dataItem); @@ -4498,7 +4490,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 MonitoredNodes.Remove(nodeId); if (monitored.DataChangeMonitoredItems is { Count: > 0 } dataItems) { - foreach (var item in dataItems.ToArray()) + foreach (var item in dataItems.Values.ToArray()) { TellNodeRemoved(item, now); if (item is ISampledDataChangeMonitoredItem sampled) _liveItems.TryRemove(sampled, out _); @@ -4511,7 +4503,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // No component-cache release: an event item never took a reference (the master node manager // creates it through the EventManager + SubscribeToEvents, not AddNodeToComponentCache), so a // release here would take one from a DATA item on the same NodeId. - foreach (var item in eventItems.ToArray()) _eventItems.TryRemove(item, out _); + foreach (var item in eventItems.Values.ToArray()) _eventItems.TryRemove(item, out _); } } @@ -4544,7 +4536,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var rootId = _root?.NodeId; foreach (var nodeId in MonitoredNodes.Keys.ToList()) { - if (rootId is not null && nodeId == rootId) continue; + if (rootId is not null && nodeId == rootId.Value) continue; TombstoneMonitoredNode(nodeId, now); } @@ -4557,7 +4549,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { foreach (var (item, tracked) in _liveItems) { - if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; + if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId.Value)) continue; if (!_liveItems.TryRemove(item, out _)) continue; Interlocked.Decrement(ref _suppressedItems); TellNodeRemoved(item, now); @@ -4566,7 +4558,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 foreach (var (item, tracked) in _eventItems) { - if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; + if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId.Value)) continue; if (!_eventItems.TryRemove(item, out _)) continue; Interlocked.Decrement(ref _suppressedItems); // Event items hold no component-cache reference (see TombstoneMonitoredNode). @@ -4579,7 +4571,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 try { item.QueueValue( - new DataValue(StatusCodes.BadNodeIdUnknown) { SourceTimestamp = now, ServerTimestamp = now }, + new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown, now, now), new ServiceResult(StatusCodes.BadNodeIdUnknown), ignoreFilters: true); } @@ -4865,7 +4857,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (_allEventsItems.IsEmpty) return; var scopes = AclEnforced ? ConditionVisibilityScopes(node) : null; - var reportingSession = (context as ISessionSystemContext)?.SessionId?.Identifier; + var reportingSession = (context as ISessionSystemContext)?.SessionId; foreach (var item in _allEventsItems.Keys) { @@ -4873,7 +4865,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue; if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue; if (reportingSession is not null - && item.Session?.Id?.Identifier is { } own + && item.Session?.Id is { } own && !own.Equals(reportingSession)) { continue; @@ -4980,7 +4972,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { GeneralModelChangeEventState modelChange => FilterModelChangeFor(identity, modelChange), AuditEventState audit => IsAuditDeliverableTo(item) - && audit.SourceNode?.Value is { IsNullNodeId: false } source + && audit.SourceNode?.Value is { IsNull: false } source && Permits(identity, OpcUaOperation.Read, source) ? audit : null, @@ -5006,25 +4998,25 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 internal GeneralModelChangeEventState? FilterModelChangeFor( RoleCarryingUserIdentity? identity, GeneralModelChangeEventState e) { - var changes = e.Changes?.Value; - if (changes is not { Length: > 0 }) return e; + var changes = e.Changes?.Value ?? default; + if (changes.Count == 0) return e; - var visible = changes - .Where(c => c.Affected is null || c.Affected.IsNullNodeId || Permits(identity, OpcUaOperation.Browse, c.Affected)) + var visible = (changes.ToArray() ?? []) + .Where(c => c.Affected.IsNull || Permits(identity, OpcUaOperation.Browse, c.Affected)) .ToArray(); - if (visible.Length == changes.Length) return e; + if (visible.Length == changes.Count) return e; if (visible.Length == 0) return null; // The original Message names the affected node(s), so the copy carries a neutral one. var copy = new GeneralModelChangeEventState(null); copy.Initialize( - SystemContext, source: null, (EventSeverity)e.Severity.Value, new LocalizedText($"{visible.Length} node(s) changed")); - copy.EventId.Value = e.EventId.Value; - copy.Time.Value = e.Time.Value; - copy.ReceiveTime.Value = e.ReceiveTime.Value; + SystemContext, source: null, (EventSeverity)e.Severity!.Value, new LocalizedText($"{visible.Length} node(s) changed")); + copy.EventId!.Value = e.EventId!.Value; + copy.Time!.Value = e.Time!.Value; + copy.ReceiveTime!.Value = e.ReceiveTime!.Value; copy.SetChildValue(SystemContext, BrowseNames.SourceNode, e.SourceNode?.Value ?? ObjectIds.Server, false); copy.SetChildValue(SystemContext, BrowseNames.SourceName, e.SourceName?.Value ?? "Server", false); - copy.SetChildValue(SystemContext, BrowseNames.Changes, visible, false); + copy.SetChildValue(SystemContext, BrowseNames.Changes, (ArrayOf)visible, false); return copy; } @@ -5305,7 +5297,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Queue BEFORE detaching: the notification has to be in the item's queue while the item is // still live, or the client is cut off with no explanation. item.QueueValue( - new DataValue(StatusCodes.BadUserAccessDenied) { SourceTimestamp = now, ServerTimestamp = now }, + new DataValue(Variant.Null, StatusCodes.BadUserAccessDenied, now, now), new ServiceResult(StatusCodes.BadUserAccessDenied), ignoreFilters: true); @@ -5451,7 +5443,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (identity is null || (identity.ApiKeyId ?? identity.Principal) is not { Length: > 0 } sessionKey - || nodeId.Identifier is not string { Length: > 0 } identifier) + || !nodeId.TryGetValue(out string identifier) || identifier.Length == 0) { return _authorizer!.IsAllowedCached( identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm); @@ -5560,7 +5552,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestampsToReturn, - IList nodesToRead, + ArrayOf nodesToRead, IList results, IList errors, List nodesToProcess, @@ -5590,14 +5582,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // same HistoryRead authorization as a raw read (above); a backend with no trail ⇒ unsupported. work.Add(() => ServeModifiedPagedAsync( handle, session, nodesToRead, results, errors, - details.StartTime, details.EndTime, details.NumValuesPerNode, + details.StartTime.ToDateTime(), details.EndTime.ToDateTime(), details.NumValuesPerNode, ct)); continue; } work.Add(() => ServeRawPagedAsync( handle, session, nodesToRead, results, errors, - details.StartTime, details.EndTime, details.NumValuesPerNode, + details.StartTime.ToDateTime(), details.EndTime.ToDateTime(), details.NumValuesPerNode, ct, details.ReturnBounds && HonorReturnBounds)); } @@ -5611,7 +5603,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ServerSystemContext context, ReadProcessedDetails details, TimestampsToReturn timestampsToReturn, - IList nodesToRead, + ArrayOf nodesToRead, IList results, IList errors, List nodesToProcess, @@ -5657,8 +5649,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var agg = aggregate.Value; work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync( tagname, - details.StartTime, - details.EndTime, + details.StartTime.ToDateTime(), + details.EndTime.ToDateTime(), interval, agg, token), ct)); @@ -5673,14 +5665,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ServerSystemContext context, ReadAtTimeDetails details, TimestampsToReturn timestampsToReturn, - IList nodesToRead, + ArrayOf nodesToRead, IList results, IList errors, List nodesToProcess, IDictionary cache) { // Snapshot the requested timestamps once — the same list is read for every node. - var timestamps = details.ReqTimes?.ToList() ?? new List(); + var timestamps = (details.ReqTimes.ToArray() ?? []).Select(t => t.ToDateTime()).ToList(); nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors); WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () => { @@ -5716,7 +5708,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 HistoryReadDetails details, TimestampsToReturn timestampsToReturn, bool releaseContinuationPoints, - IList nodesToRead, + ArrayOf nodesToRead, IList results, IList errors, List nodesToProcess, @@ -5758,7 +5750,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ServerSystemContext context, ReadEventDetails details, TimestampsToReturn timestampsToReturn, - IList nodesToRead, + ArrayOf nodesToRead, IList results, IList errors, List nodesToProcess, @@ -5766,7 +5758,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { // Snapshot the select clauses once — the same filter projects every node's events — and the where-clause, which // is evaluated per event against the same projection (an OfType through the server's type tree). - var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection(); + var selectClauses = details.Filter?.SelectClauses ?? new List(); var where = details.Filter?.WhereClause is { Elements.Count: > 0 } w ? w : null; var filterContext = where is null ? null : new FilterContext(Server.NamespaceUris, Server.TypeTree, context.PreferredLocales, Server.Telemetry); var session = context.OperationContext?.Session; @@ -5820,10 +5812,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 NodeHandle handle, string? sourceName, ReadEventDetails details, - SimpleAttributeOperandCollection selectClauses, + ArrayOf selectClauses, ContentFilter? where, FilterContext? filterContext, - IList nodesToRead, + ArrayOf nodesToRead, ISession? session, IList results, IList errors, @@ -5835,8 +5827,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var source = _historianDataSource; try { - var startUtc = details.StartTime; - var endUtc = details.EndTime; + var startUtc = details.StartTime.ToDateTime(); + var endUtc = details.EndTime.ToDateTime(); var numValuesPerNode = details.NumValuesPerNode; var boundarySkip = 0; var walkHint = default(ReverseWalkHint); // a walked-back read's width + proven-empty stretch @@ -5846,7 +5838,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { // Single-use take. A miss (unknown / evicted / malformed / a RAW cursor) ⇒ BadContinuationPointInvalid // for THIS node only. - var state = _historyContinuationStore.TryTake(session, inboundCp!); + var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray()); // A point is bound to the node that issued it: another node's source name is a miss too. if (state is null || state.Kind != HistoryCursorKind.Events || state.Tagname != (sourceName ?? string.Empty)) { @@ -6030,7 +6022,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 StatusCode = historyEvent.Events.Count == 0 && outboundCp is null ? StatusCodes.GoodNoData : StatusCodes.Good, HistoryData = new ExtensionObject(historyEvent), // Null when the page was complete, or when no session is available to hold the cursor. - ContinuationPoint = outboundCp, + ContinuationPoint = outboundCp is null ? default : ByteString.From(outboundCp), }; errors[handle.Index] = ServiceResult.Good; } @@ -6135,7 +6127,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// protected override void HistoryUpdateData( ServerSystemContext context, - IList nodesToUpdate, + ArrayOf nodesToUpdate, IList results, IList errors, List nodesToProcess, @@ -6155,7 +6147,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 default: return null; // Remove (and anything unknown) ⇒ unsupported } - var items = (IList?)details.UpdateValues ?? Array.Empty(); + var items = details.UpdateValues.ToArray() ?? []; return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, latest, who, ct); }); } @@ -6163,7 +6155,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// protected override void HistoryDeleteRawModified( ServerSystemContext context, - IList nodesToUpdate, + ArrayOf nodesToUpdate, IList results, IList errors, List nodesToProcess, @@ -6175,8 +6167,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Deleting the modified-value trail itself (Part 11 IsDeleteModified) is not supported: the trail is the // audit record of every change and is aged out only by retention. if (details.IsDeleteModified) return null; - var start = details.StartTime; - var end = details.EndTime; + var start = details.StartTime.ToDateTime(); + var end = details.EndTime.ToDateTime(); // Part 11 requires both bounds of a raw delete; DateTime.MinValue is the wire's "unspecified". Refuse // it here rather than let a backend read MinValue as "from the beginning of time". if (start == DateTime.MinValue || end == DateTime.MinValue) @@ -6192,7 +6184,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// protected override void HistoryDeleteAtTime( ServerSystemContext context, - IList nodesToUpdate, + ArrayOf nodesToUpdate, IList results, IList errors, List nodesToProcess, @@ -6200,7 +6192,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { ServeHistoryUpdate(context, nodesToProcess, results, errors, handle => { - var times = nodesToUpdate[handle.Index].ReqTimes?.ToList() ?? new List(); + var times = (nodesToUpdate[handle.Index].ReqTimes.ToArray() ?? []).Select(t => t.ToDateTime()).ToList(); return async (updater, tagname, who, ct) => { var codes = times.Count == 0 @@ -6212,7 +6204,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return new HistoryUpdateResult { StatusCode = StatusCodes.Good, - OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))), + OperationResults = new List(codes.Select(c => new StatusCode(c))), }; }; }); @@ -6273,26 +6265,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 for (var i = 0; i < items.Count; i++) { var item = items[i]; - if (item is null) + if (item.SourceTimestamp == DateTime.MinValue) { - codes[i] = StatusCodes.BadInvalidArgument; + codes[i] = StatusCodes.BadInvalidTimestamp.Code; } - else if (item.SourceTimestamp == DateTime.MinValue) - { - codes[i] = StatusCodes.BadInvalidTimestamp; - } - else if (latest is { } bound && ToUtc(item.SourceTimestamp) > bound) + else if (latest is { } bound && ToUtc(item.SourceTimestamp.ToDateTime()) > bound) { // A far-future history edit is refused before any backend sees it. - codes[i] = StatusCodes.BadOutOfRange; + codes[i] = StatusCodes.BadOutOfRange.Code; } - else if (!HistorianValue.TryFromObject(item.Value, out var value)) + else if (!HistorianValue.TryFromObject(SdkValueBoundary.ToClr(item.WrappedValue), out var value)) { - codes[i] = StatusCodes.BadTypeMismatch; + codes[i] = StatusCodes.BadTypeMismatch.Code; } else { - toSend.Add(new HistorizationValue(ToUtc(item.SourceTimestamp), value, item.StatusCode.Code)); + toSend.Add(new HistorizationValue(ToUtc(item.SourceTimestamp.ToDateTime()), value, item.StatusCode.Code)); sentIndex.Add(i); } } @@ -6311,7 +6299,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return new HistoryUpdateResult { StatusCode = StatusCodes.Good, - OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))), + OperationResults = new List(codes.Select(c => new StatusCode(c))), }; } @@ -6362,8 +6350,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // and after planning. Off-Primary every servable node is refused BadInvalidState and nothing reaches the updater. if (HistoryUpdatePrimaryGate is { } primary && !primary()) { - var refusal = new ServiceResult(StatusCodes.BadInvalidState, - new LocalizedText("This server is not the redundancy Primary; update history on the Primary (the server with the higher ServiceLevel).")); + var refusal = new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText("This server is not the redundancy Primary; update history on the Primary (the server with the higher ServiceLevel).")); foreach (var (handle, _, _) in planned) { errors[handle.Index] = refusal; @@ -6485,16 +6472,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The context is evaluated in. /// The populated SDK . private HistoryEvent ProjectEvents( - IReadOnlyList events, SimpleAttributeOperandCollection selectClauses, + IReadOnlyList events, ArrayOf selectClauses, ContentFilter? where, FilterContext? filterContext) { - var fieldLists = new HistoryEventFieldListCollection(events.Count); + var fieldLists = new List(events.Count); foreach (var evt in events) { var target = new HistoricalEventFilterTarget(this, evt); if (where is not null && filterContext is not null && !where.Evaluate(filterContext, target)) continue; - var fields = new VariantCollection(selectClauses.Count); + var fields = new List(selectClauses.Count); foreach (var operand in selectClauses) { fields.Add(target.Project(operand)); @@ -6522,13 +6509,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 public bool IsTypeOf(IFilterContext context, NodeId typeDefinitionId) => EventType == typeDefinitionId || context.TypeTree.IsTypeOf(EventType, typeDefinitionId); - public object? GetAttributeValue(IFilterContext context, NodeId typeDefinitionId, IList relativePath, + public Variant GetAttributeValue(IFilterContext context, NodeId typeDefinitionId, ArrayOf relativePath, uint attributeId, NumericRange indexRange) { - if (attributeId != Attributes.Value || relativePath is null || relativePath.Count == 0) return null; - var operand = new SimpleAttributeOperand { TypeDefinitionId = typeDefinitionId, AttributeId = attributeId }; - foreach (var name in relativePath) operand.BrowsePath.Add(name); - return Project(operand).Value; + if (attributeId != Attributes.Value || relativePath.Count == 0) return Variant.Null; + var operand = new SimpleAttributeOperand { TypeDefinitionId = typeDefinitionId, AttributeId = attributeId, BrowsePath = relativePath }; + return Project(operand); } } @@ -6609,7 +6595,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private static string? LeafFieldName(SimpleAttributeOperand operand) { var path = operand.BrowsePath; - if (path is null || path.Count == 0) return null; + if (path.Count == 0) return null; return path[^1].Name; } @@ -6808,7 +6794,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Single-shot arms (Processed / AtTime) never page — the backend returns the complete // result in one read (no client count cap to detect a "full page" against), so no // continuation point. Raw pages via ServeRawPaged, not this helper. - ContinuationPoint = null, + ContinuationPoint = default, }; errors[handle.Index] = ServiceResult.Good; } @@ -6852,7 +6838,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// synthesised time-based: /// /// Fresh read (no inbound continuation point): read the window from - /// details.StartTime to capped at + /// details.StartTime.ToDateTime() to capped at /// + 1 — one lookahead row. If the lookahead row came /// back, return the first N, store a resume cursor and emit a continuation point; otherwise the page is /// the last and carries none (so a total that is an exact multiple of N ends on a full page, not on an @@ -6889,7 +6875,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private async Task ServeRawPagedAsync( NodeHandle handle, ISession? session, - IList nodesToRead, + ArrayOf nodesToRead, IList results, IList errors, DateTime startTimeUtc, @@ -6913,7 +6899,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { // Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒ // BadContinuationPointInvalid for THIS node. - var state = _historyContinuationStore.TryTake(session, inboundCp); + var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray()); if (state is null || state.Kind != HistoryCursorKind.Raw || !TryGetHistorizedTagname(handle.NodeId, out var boundTag) || boundTag != state.Tagname) { @@ -7024,7 +7010,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { StatusCode = StatusCodes.Good, HistoryData = new ExtensionObject(ToHistoryDataFromSamples(onlyBound)), - ContinuationPoint = afterCp, + ContinuationPoint = afterCp is null ? default : ByteString.From(afterCp), }; errors[handle.Index] = ServiceResult.Good; return; @@ -7055,7 +7041,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { StatusCode = StatusCodes.Good, HistoryData = new ExtensionObject(ToHistoryDataFromSamples([startBound])), - ContinuationPoint = boundOnlyCp, + ContinuationPoint = boundOnlyCp is null ? default : ByteString.From(boundOnlyCp), }; errors[handle.Index] = ServiceResult.Good; return; @@ -7068,7 +7054,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { StatusCode = StatusCodes.GoodNoData, HistoryData = new ExtensionObject(ToHistoryDataFromSamples([])), - ContinuationPoint = null, + ContinuationPoint = default, }; errors[handle.Index] = ServiceResult.Good; return; @@ -7250,7 +7236,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { StatusCode = slice.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good, HistoryData = new ExtensionObject(ToHistoryDataFromSamples(slice)), - ContinuationPoint = clusterCp, + ContinuationPoint = clusterCp is null ? default : ByteString.From(clusterCp), }; errors[handle.Index] = ServiceResult.Good; return; @@ -7319,7 +7305,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // genuinely drained — never silent data loss. StatusCode = samples.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good, HistoryData = new ExtensionObject(historyData), - ContinuationPoint = outboundCp, + ContinuationPoint = outboundCp is null ? default : ByteString.From(outboundCp), }; errors[handle.Index] = ServiceResult.Good; } @@ -7368,7 +7354,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private async Task ServeModifiedPagedAsync( NodeHandle handle, ISession? session, - IList nodesToRead, + ArrayOf nodesToRead, IList results, IList errors, DateTime startTimeUtc, @@ -7390,7 +7376,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 HistoryModifiedCursor? resumeAfter = null; if (inboundCp is { Length: > 0 }) { - var state = _historyContinuationStore.TryTake(session, inboundCp); + var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray()); if (state is null || state.Kind != HistoryCursorKind.Modified || state.Tagname != tagname) { errors[handle.Index] = StatusCodes.BadContinuationPointInvalid; @@ -7430,7 +7416,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { StatusCode = page.Values.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good, HistoryData = new ExtensionObject(data), - ContinuationPoint = outboundCp, + ContinuationPoint = outboundCp is null ? default : ByteString.From(outboundCp), }; errors[handle.Index] = ServiceResult.Good; } @@ -7462,8 +7448,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The populated . internal static HistoryModifiedData ToHistoryModifiedData(IReadOnlyList values) { - var dataValues = new DataValueCollection(values.Count); - var infos = new ModificationInfoCollection(values.Count); + var dataValues = new List(values.Count); + var infos = new List(values.Count); foreach (var m in values) { dataValues.Add(ToSdkDataValue(m.Value)); @@ -7481,7 +7467,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// protected override void HistoryReleaseContinuationPoints( ServerSystemContext context, - IList nodesToRead, + ArrayOf nodesToRead, IList errors, List nodesToProcess, IDictionary cache) @@ -7492,7 +7478,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var cp = nodesToRead[handle.Index].ContinuationPoint; if (cp is { Length: > 0 }) { - _historyContinuationStore.Release(session, cp); + _historyContinuationStore.Release(session, cp.ToArray()); } errors[handle.Index] = ServiceResult.Good; @@ -7500,7 +7486,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// Transport failures are retryable; unsupported operations retain their distinct status. - private static uint HistoryReadFailureStatus(Exception error) + private static StatusCode HistoryReadFailureStatus(Exception error) { for (Exception? ex = error; ex is not null; ex = ex.InnerException) if (ex is System.IO.IOException or System.Net.Sockets.SocketException or System.Net.Http.HttpRequestException or TimeoutException) @@ -7514,31 +7500,29 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 foreach (var handle in handles) { if (AtTimeValueType(handle.NodeId) is not { } type || results[handle.Index]?.HistoryData is not { } body) continue; - var values = ExtensionObject.ToEncodeable(body) switch + if (body.IsNull || ExtensionObject.ToEncodeable(body) is not HistoryData data) continue; + var values = data.DataValues.ToArray() ?? []; + for (var i = 0; i < values.Length; i++) { - HistoryData data => data.DataValues, - _ => null, - }; - if (values is null) continue; - foreach (var value in values) - { - if (value.Value is null) continue; + var value = values[i]; + var payload = SdkValueBoundary.ToClr(value.WrappedValue); + if (payload is null) continue; try { // A gateway number cannot reconstruct a DateTime or a String which was never stored. - if ((type == TypeCode.DateTime && value.Value is not DateTime) - || (type == TypeCode.String && value.Value is not string)) throw new InvalidCastException(); - var converted = Convert.ChangeType(value.Value, type, System.Globalization.CultureInfo.InvariantCulture); - if (converted is float f && float.IsInfinity(f) && value.Value is double d && double.IsFinite(d)) + if ((type == TypeCode.DateTime && payload is not DateTime) + || (type == TypeCode.String && payload is not string)) throw new InvalidCastException(); + var converted = Convert.ChangeType(payload, type, System.Globalization.CultureInfo.InvariantCulture); + if (converted is float f && float.IsInfinity(f) && payload is double d && double.IsFinite(d)) throw new OverflowException(); - value.WrappedValue = new Variant(converted); + values[i] = value.WithWrappedValue(SdkValueBoundary.FromClr(converted)); } catch (Exception ex) when (ex is InvalidCastException or OverflowException or FormatException) { - value.WrappedValue = Variant.Null; - value.StatusCode = StatusCodes.BadTypeMismatch; + values[i] = value.WithWrappedValue(Variant.Null).WithStatus(StatusCodes.BadTypeMismatch); } } + data.DataValues = values; } } @@ -7548,7 +7532,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The populated SDK . private static HistoryData ToHistoryDataFromSamples(IReadOnlyList samples) { - var values = new DataValueCollection(samples.Count); + var values = new List(samples.Count); foreach (var sample in samples) values.Add(ToSdkDataValue(sample)); return new HistoryData { DataValues = values }; } @@ -7563,7 +7547,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The populated SDK . private static HistoryData ToHistoryData(HistorianRead sourceResult) { - var values = new DataValueCollection(sourceResult.Samples.Count); + var values = new List(sourceResult.Samples.Count); foreach (var sample in sourceResult.Samples) { values.Add(ToSdkDataValue(sample)); @@ -7576,13 +7560,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// , mirroring value / status code / source + server timestamps. /// The source sample. /// The equivalent SDK data value. - private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new() - { - WrappedValue = new Variant(snapshot.Value), - StatusCode = new StatusCode(snapshot.StatusCode), - SourceTimestamp = snapshot.SourceTimestampUtc ?? DateTime.MinValue, - ServerTimestamp = snapshot.ServerTimestampUtc, - }; + private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new( + SdkValueBoundary.FromClr(snapshot.Value), + new StatusCode(snapshot.StatusCode), + snapshot.SourceTimestampUtc ?? DateTime.MinValue, + snapshot.ServerTimestampUtc); /// public override void CreateAddressSpace(IDictionary> externalReferences) @@ -7600,7 +7582,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { NodeId = new NodeId(V3NodeIds.SharedRootFolderId, NamespaceIndex), BrowseName = new QualifiedName(V3NodeIds.SharedRootFolderId, NamespaceIndex), - DisplayName = V3NodeIds.SharedRootFolderId, + DisplayName = new LocalizedText(V3NodeIds.SharedRootFolderId), EventNotifier = EventNotifiers.None, TypeDefinitionId = ObjectTypeIds.FolderType, }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs index 058cc702..c3ff4f08 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs @@ -22,17 +22,40 @@ public sealed class OtOpcUaSdkServer : StandardServer /// Initializes a new instance of the class. /// Logger handed to the created ; /// null leaves it on a no-op logger. - public OtOpcUaSdkServer(ILogger? nodeManagerLogger = null) => _nodeManagerLogger = nodeManagerLogger; + public OtOpcUaSdkServer(ILogger? nodeManagerLogger = null) + : base(DefaultTelemetry.Create(_ => { })) => _nodeManagerLogger = nodeManagerLogger; - /// - /// The client's remote IP endpoint for the secure channel , or null when it - /// cannot be resolved (lmxopcua#751). Best effort — see ; the SDK - /// exposes the listeners only to a subclass, which is why this lives here. - /// - /// The global secure channel id a request arrived on. - /// The remote endpoint, or null. - public System.Net.IPEndPoint? TryResolveRemoteEndpoint(string? secureChannelId) => - SecureChannelRemoteEndpoint.Resolve(TransportListeners, secureChannelId); + private readonly AsyncLocal _activationPeer = new(); + + /// The transport-observed peer of the current activation request, when available. + internal System.Net.IPAddress? ActivationPeerAddress => _activationPeer.Value; + + /// + public override async ValueTask ActivateSessionAsync( + SecureChannelContext secureChannelContext, + RequestHeader? requestHeader, + SignatureData? clientSignature, + ArrayOf clientSoftwareCertificates, + ArrayOf localeIds, + ExtensionObject userIdentityToken, + SignatureData? userTokenSignature, + RequestLifetime requestLifetime) + { + // AuthenticationContext does not carry the request peer in 2.0.1. Keep the public + // transport evidence in this activation's async flow, including reactivation on a new channel. + var previous = _activationPeer.Value; + _activationPeer.Value = secureChannelContext.PeerAddress; + try + { + return await base.ActivateSessionAsync(secureChannelContext, requestHeader, clientSignature, + clientSoftwareCertificates, localeIds, userIdentityToken, userTokenSignature, requestLifetime) + .ConfigureAwait(false); + } + finally + { + _activationPeer.Value = previous; + } + } /// The custom node manager once StartAsync has called /// . Null until the SDK has bootstrapped. @@ -230,15 +253,15 @@ public sealed class OtOpcUaSdkServer : StandardServer // this point that is the Null default (no history aggregates); the Host's SetHistorianDataSource // re-syncs to the configured backend's set right after start. /// - protected override AggregateManager CreateAggregateManager( - IServerInternal server, ApplicationConfiguration configuration) + protected override async ValueTask CreateAggregateManagerAsync( + IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default) { - var manager = base.CreateAggregateManager(server, configuration); + var manager = await base.CreateAggregateManagerAsync(server, configuration, cancellationToken).ConfigureAwait(false); // Pin the SDK manager's default AggregateConfiguration (what live AggregateFilter subscriptions compute under // and what the server advertises) to OUR defaults. Opc.Ua.Server 1.5.378.182 flipped the SDK default of // TreatUncertainAsBad to true; history-read aggregates (Sql.Common evaluators) are fixed at false, so the live // and historical paths must agree and stay behaviour-neutral across the SDK hop (lmxopcua#782). - var defaults = manager.GetDefaultConfiguration(null) ?? new AggregateConfiguration(); + var defaults = manager.GetDefaultConfiguration(NodeId.Null) ?? new AggregateConfiguration(); defaults.TreatUncertainAsBad = ServerAggregateConfiguration.TreatUncertainAsBad; defaults.PercentDataBad = ServerAggregateConfiguration.PercentDataBad; defaults.PercentDataGood = ServerAggregateConfiguration.PercentDataGood; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs index de984308..0ea04b88 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// Uses (a ) and /// its child variable, which the SDK populates /// automatically during initialization. Writes are -/// guarded by so concurrent diagnostics scans +/// guarded by so concurrent diagnostics scans /// from the SDK don't fight with our update. /// public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher @@ -44,13 +44,13 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher try { - lock (_serverInternal.DiagnosticsLock) + _serverInternal.UpdateServerDiagnostics(_ => { node.Value = serviceLevel; node.Timestamp = DateTime.UtcNow; node.StatusCode = StatusCodes.Good; node.ClearChangeMasks(_serverInternal.DefaultSystemContext, includeChildren: false); - } + }); } catch (Exception ex) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkValueBoundary.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkValueBoundary.cs new file mode 100644 index 00000000..9aeb8e26 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkValueBoundary.cs @@ -0,0 +1,34 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// Copies supported SDK payloads at the server's CLR value boundary. +internal static class SdkValueBoundary +{ + /// Build a detached Variant for an existing supported scalar or one-dimensional array. + internal static Variant FromClr(object? value, bool byteArray = false) + { + if (value is null) return Variant.Null; + if (value is Array array) + { + if (array.Rank != 1) throw new ArgumentException("Only one-dimensional values are supported.", nameof(value)); + value = array.Clone(); + } + if (byteArray && value is byte[] bytes) return Variant.From((ArrayOf)bytes); + if (VariantHelper.TryCastFrom(value, out var variant)) return variant; + throw new ArgumentException("Unsupported OPC UA value type.", nameof(value)); + } + + /// Restore existing CLR payload types and detach mutable arrays. + internal static object? ToClr(Variant value) => value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch + { + ByteString bytes => bytes.IsNull ? null : bytes.ToArray(), + DateTimeUtc time => time.ToDateTime(), + Uuid id => (Guid)id, + DateTimeUtc[] times => times.Select(x => x.ToDateTime()).ToArray(), + Uuid[] ids => ids.Select(x => (Guid)x).ToArray(), + ByteString[] strings => strings.Select(x => x.IsNull ? null : x.ToArray()).ToArray(), + Array array => array.Clone(), + var scalar => scalar, + }; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs index 2da221ad..20dbfb9d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs @@ -297,7 +297,7 @@ public sealed class DataPlaneAuthorizer // The s= identifier, read once: it is both the cache key and the raw material the scope is split // from, and pulling it up here absorbs what used to be TryBuildScope's own guard. A NodeId that // carries no string addresses no ACL scope at all, so it can never be answered from the cache. - if (nodeId?.Identifier is not string identifier || identifier.Length == 0) + if (!nodeId.TryGetValue(out string identifier) || identifier.Length == 0) { return Deny(operation, nodeId, identity.Principal, "the node id carries no string identifier, so it addresses no ACL scope", cached); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs deleted file mode 100644 index 384af2dc..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Collections; -using System.Net; -using System.Reflection; -using Opc.Ua; - -namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; - -/// -/// Resolves the client's remote IP endpoint for a secure channel, so session activation can account -/// pre-authentication API-key misses per client (lmxopcua#751). -/// -/// -/// -/// ⚠️ The OPC UA SDK (1.5.378) does not expose the client address on any public surface — not -/// on ISession, SecureChannelContext, ImpersonateEventArgs nor the transport -/// listener. The TCP listener holds it privately: TcpTransportListener.m_channels (channel id -/// → channel) → the channel's non-public Socket → the public -/// IMessageSocket.RemoteEndpoint. This class walks that path by reflection. -/// -/// -/// It is strictly best effort and fail-soft: any missing member (an SDK upgrade renamed it), an -/// unknown channel or a closed socket yields , and a caller treats null as -/// "address unknown" — the per-endpoint budget is skipped and the process-wide one still applies. It -/// never throws. An over-the-wire test (RemoteEndpointResolutionTests) pins that it resolves on -/// the SDK version in use, so an upgrade that breaks the path fails a test instead of silently -/// degrading to process-wide accounting. -/// -/// -public static class SecureChannelRemoteEndpoint -{ - private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - - /// - /// Finds the remote endpoint of the secure channel among - /// . - /// - /// The server's transport listeners. - /// The global secure channel id ({ListenerId}-{ChannelId}). - /// The client's IP endpoint (IPv4-mapped addresses normalised to IPv4), or null when unknown. - public static IPEndPoint? Resolve(IEnumerable? listeners, string? secureChannelId) - { - if (listeners is null || string.IsNullOrEmpty(secureChannelId)) return null; - try - { - foreach (var listener in listeners) - { - if (listener is null) continue; - var listenerId = listener.ListenerId; - if (string.IsNullOrEmpty(listenerId) - || !secureChannelId.StartsWith(listenerId + "-", StringComparison.Ordinal) - || !uint.TryParse(secureChannelId.AsSpan(listenerId.Length + 1), out var channelId)) - { - continue; - } - - if (FindChannel(listener, channelId) is { } channel && RemoteEndpointOf(channel) is { } endpoint) - return endpoint; - } - } - catch - { - // Fail-soft by contract: the caller falls back to process-wide accounting. - } - - return null; - } - - /// The listener's channel with id , read from its private channel map. - private static object? FindChannel(ITransportListener listener, uint channelId) - { - var field = FindField(listener.GetType(), "m_channels"); - if (field?.GetValue(listener) is not IDictionary channels) return null; - return channels.Contains(channelId) ? channels[channelId] : null; - } - - /// The channel's socket's remote endpoint, as an . - private static IPEndPoint? RemoteEndpointOf(object channel) - { - var socketProperty = FindProperty(channel.GetType(), "Socket"); - var socket = socketProperty?.GetValue(channel); - if (socket is null) return null; - var remote = FindProperty(socket.GetType(), "RemoteEndpoint")?.GetValue(socket) as IPEndPoint; - if (remote is null) return null; - return remote.Address.IsIPv4MappedToIPv6 ? new IPEndPoint(remote.Address.MapToIPv4(), remote.Port) : remote; - } - - private static FieldInfo? FindField(Type? type, string name) - { - for (; type is not null; type = type.BaseType) - { - if (type.GetField(name, Instance | BindingFlags.DeclaredOnly) is { } field) return field; - } - - return null; - } - - private static PropertyInfo? FindProperty(Type? type, string name) - { - for (; type is not null; type = type.BaseType) - { - if (type.GetProperty(name, Instance | BindingFlags.DeclaredOnly) is { } property) return property; - } - - return null; - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj index ccc65441..0e24ce27 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj @@ -5,13 +5,7 @@ - -