diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs index 062e3593..bf93602a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs @@ -87,7 +87,9 @@ internal sealed record HistoryContinuationState( long EmptyFromTicks = 0, long EmptyToTicks = 0, HistoryEndBound? PendingEndBound = null, - bool WithinTieCluster = false) + bool WithinTieCluster = false, + string? BoundNodeKey = null, + long AddressSpaceEpoch = -1) { /// The walk-back hint this cursor carries. public ReverseWalkHint WalkHint => new(SegmentWidthTicks, EmptyFromTicks, EmptyToTicks); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 53d44384..6dd731c1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -202,6 +202,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// A task that represents the asynchronous operation. public async Task StartAsync(StandardServer server, CancellationToken cancellationToken) { + if (server is not OtOpcUaSdkServer sdkServer) + throw new ArgumentException("The server must support application authentication before its listeners open.", nameof(server)); _server = server; // 1.5.378 requires an ITelemetryContext on the ApplicationInstance ctor (the parameterless ctor // is obsolete). DefaultTelemetry.Create wires the SDK's internal logging; an empty builder keeps @@ -213,27 +215,32 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable ConfigSectionName = "OtOpcUa", }; - _ = await BuildConfigurationAsync(cancellationToken); - await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false); - await _application.StartAsync(server).ConfigureAwait(false); - - AttachUserAuthenticator(); - PopulateServerArray(); + try + { + _ = await BuildConfigurationAsync(cancellationToken); + await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false); + AttachUserAuthenticator(sdkServer); + await _application.StartAsync(server, cancellationToken).ConfigureAwait(false); + PopulateServerArray(); + } + catch + { + await DisposeAsync().ConfigureAwait(false); + throw; + } _logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}", _options.PublicHostname, _options.OpcUaPort); } - private void AttachUserAuthenticator() + private void AttachUserAuthenticator(OtOpcUaSdkServer server) { - var registry = _server?.CurrentInstance?.IdentityRegistry - ?? throw new InvalidOperationException("OPC UA identity registry unavailable after start."); foreach (var type in new[] { UserTokenType.Anonymous, UserTokenType.UserName }) { var adapter = new ApplicationTokenAuthenticator(this, type); - registry.Register(adapter); _identityAuthenticators.Add(adapter); } + server.ConfigureIdentityAuthenticators(_identityAuthenticators); } private sealed class ApplicationTokenAuthenticator(OpcUaApplicationHost host, UserTokenType tokenType) @@ -634,22 +641,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { - try - { - if (_server?.CurrentInstance?.IdentityRegistry is { } registry) - foreach (var authenticator in _identityAuthenticators) registry.Unregister(authenticator); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "OpcUaApplicationHost: detaching identity authenticators threw"); - } - _identityAuthenticators.Clear(); - + // Authenticators remain installed until the SDK closes every listener and disposes its registry. if (_application is { } application) { _application = null; try { await application.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); } } + _identityAuthenticators.Clear(); + _server = null; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 1dfad616..c2fcf660 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -698,6 +698,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private volatile SemaphoreSlim? _historyReadBatchLimiter; private readonly object _historyReadBatchLimiterLock = new(); + private long _historyContinuationEpoch; private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore(); /// @@ -2315,6 +2316,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId)); var range = EvaluateEquipmentWriteRange(indexRange); if (range is not null) return range; + if (value.TypeInfo.ValueRank > 1) return StatusCodes.BadTypeMismatch; var structure = EvaluateEquipmentWriteStructure(SdkValueBoundary.ToClr(value), node, refuseArrayOnScalar, out var coerced); if (structure is not null) return structure; value = SdkValueBoundary.FromClr(coerced, node is BaseDataVariableState { DataType: var dt, ValueRank: ValueRanks.OneDimension } && dt == DataTypeIds.Byte); @@ -3688,6 +3690,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { lock (Lock) { + Interlocked.Increment(ref _historyContinuationEpoch); // Review 01 L2 — every monitored item on a node this rebuild discards is told and forgotten BEFORE // the nodes go, so a later create on the same NodeId binds to the re-created node (see // TombstoneMonitoredNode). The shared root survives the rebuild and keeps its items. @@ -3812,6 +3815,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. if (!_variables.TryRemove(key, out var variable)) return false; + // Conservatively invalidate every outstanding history cursor on topology removal. + Interlocked.Increment(ref _historyContinuationEpoch); UnindexChild(key); TombstoneMonitoredNode(variable.NodeId, UtcNow); // 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds @@ -3880,6 +3885,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole // equipment goes). if (!_alarmConditions.TryRemove(key, out var condition)) return false; + // Conservatively invalidate every outstanding history cursor on topology removal. + Interlocked.Increment(ref _historyContinuationEpoch); UnindexChild(key); TombstoneMonitoredSubtree(condition, UtcNow); // unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE @@ -3908,6 +3915,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { // Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds). if (!_folders.ContainsKey(equipKey)) return false; + // Conservatively invalidate every outstanding history cursor on topology removal. + Interlocked.Increment(ref _historyContinuationEpoch); // Review 01 P2 — walk the parent→children index the materialise calls maintain instead of // prefix-scanning every map (variables, conditions, folders, notifier folders, event sources and @@ -4868,9 +4877,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue; if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue; if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue; - if (reportingSession is not null + if (reportingSession is { IsNull: false } reportingId && item.Session?.Id is { } own - && !own.Equals(reportingSession)) + && !own.Equals(reportingId)) { continue; } @@ -5825,6 +5834,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 IList errors, CancellationToken ct) { + var historyEpoch = Volatile.Read(ref _historyContinuationEpoch); var inboundCp = nodesToRead[handle.Index].ContinuationPoint; var resumed = inboundCp is { Length: > 0 }; // One snapshot of the (hot-swappable) source for the whole read: the capability check and every call must agree. @@ -5844,7 +5854,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // for THIS node only. 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)) + if (state is null || !HistoryContinuationMatches(state, handle.NodeId, historyEpoch) || state.Kind != HistoryCursorKind.Events || state.Tagname != (sourceName ?? string.Empty)) { errors[handle.Index] = StatusCodes.BadContinuationPointInvalid; results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid }; @@ -6019,7 +6029,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // boundary skip) is exactly as without a filter: a page may come back with fewer events — even none — and a // continuation point, never skipping or repeating one. var historyEvent = ProjectEvents(events, selectClauses, where, filterContext); - var outboundCp = pendingCursor is null ? null : _historyContinuationStore.Save(session, pendingCursor); + var outboundCp = pendingCursor is null ? null : SaveHistoryContinuation(session, handle.NodeId, historyEpoch, pendingCursor); results[handle.Index] = new SdkHistoryReadResult { // No events ⇒ GoodNoData (the notifier is historized, the window just held no events — or none matched). @@ -6888,6 +6898,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 CancellationToken ct, bool returnBounds = false) { + var historyEpoch = Volatile.Read(ref _historyContinuationEpoch); var inboundCp = nodesToRead[handle.Index].ContinuationPoint; try @@ -6904,7 +6915,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.ToArray()); - if (state is null || state.Kind != HistoryCursorKind.Raw + if (state is null || !HistoryContinuationMatches(state, handle.NodeId, historyEpoch) || state.Kind != HistoryCursorKind.Raw || !TryGetHistorizedTagname(handle.NodeId, out var boundTag) || boundTag != state.Tagname) { errors[handle.Index] = StatusCodes.BadContinuationPointInvalid; @@ -6971,7 +6982,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return null; } - return _historyContinuationStore.Save(session, new HistoryContinuationState( + return SaveHistoryContinuation(session, handle.NodeId, historyEpoch, new HistoryContinuationState( tagname, previous, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks, PendingEndBound: HistoryBounds.Carry(endBound!, page)! with { DataExhausted = true })); @@ -7037,7 +7048,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (startBound is not null && numValuesPerNode == 1 && anything) { // The page is the start bound alone; the data starts at StartTime on the next page. - var boundOnlyCp = _historyContinuationStore.Save(session, new HistoryContinuationState( + var boundOnlyCp = SaveHistoryContinuation(session, handle.NodeId, historyEpoch, new HistoryContinuationState( tagname, startUtc, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks, PendingEndBound: HistoryBounds.Carry(pending, [startBound]))); @@ -7224,7 +7235,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks, PendingEndBound: HistoryBounds.Carry(endBound, slice), WithinTieCluster: resumeAt == startUtc); - clusterCp = _historyContinuationStore.Save(session, clusterState); + clusterCp = SaveHistoryContinuation(session, handle.NodeId, historyEpoch, clusterState); } else if (endBound is not null) { @@ -7279,7 +7290,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { // Save may return null (no session on this request) ⇒ degrade to single-shot for this node. // Built AFTER historyData so a failure projecting samples can never orphan a stored cursor. - outboundCp = _historyContinuationStore.Save(session, nextState); + outboundCp = SaveHistoryContinuation(session, handle.NodeId, historyEpoch, nextState); } else if (endBound is not null) { @@ -7366,6 +7377,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 uint numValuesPerNode, CancellationToken ct) { + var historyEpoch = Volatile.Read(ref _historyContinuationEpoch); var inboundCp = nodesToRead[handle.Index].ContinuationPoint; try { @@ -7381,7 +7393,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (inboundCp is { Length: > 0 }) { var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray()); - if (state is null || state.Kind != HistoryCursorKind.Modified || state.Tagname != tagname) + if (state is null || !HistoryContinuationMatches(state, handle.NodeId, historyEpoch) || state.Kind != HistoryCursorKind.Modified || state.Tagname != tagname) { errors[handle.Index] = StatusCodes.BadContinuationPointInvalid; results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid }; @@ -7411,7 +7423,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (page.HasMore && numValuesPerNode > 0 && page.Values.Count > 0) { var last = page.Values[^1]; - outboundCp = _historyContinuationStore.Save(session, new HistoryContinuationState( + outboundCp = SaveHistoryContinuation(session, handle.NodeId, historyEpoch, new HistoryContinuationState( tagname, last.SourceTimeUtc, endUtc, 0, numValuesPerNode, HistoryCursorKind.Modified, last.ModifiedId, startUtc)); } @@ -7489,6 +7501,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + private byte[]? SaveHistoryContinuation(ISession? session, NodeId nodeId, long epoch, HistoryContinuationState state) + { + if (epoch != Volatile.Read(ref _historyContinuationEpoch)) return null; + return _historyContinuationStore.Save(session, state with + { + BoundNodeKey = nodeId.ToString(), AddressSpaceEpoch = epoch, + }); + } + + private static bool HistoryContinuationMatches(HistoryContinuationState state, NodeId nodeId, long epoch) => + state.BoundNodeKey == nodeId.ToString() && state.AddressSpaceEpoch == epoch; + /// Transport failures are retryable; unsupported operations retain their distinct status. private static StatusCode HistoryReadFailureStatus(Exception error) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs index c3ff4f08..507e9ec3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Server; +using Opc.Ua.Identity; +using Opc.Ua.Server.Hosting; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; @@ -25,6 +27,21 @@ public sealed class OtOpcUaSdkServer : StandardServer public OtOpcUaSdkServer(ILogger? nodeManagerLogger = null) : base(DefaultTelemetry.Create(_ => { })) => _nodeManagerLogger = nodeManagerLogger; + internal void ConfigureIdentityAuthenticators(IEnumerable authenticators) => + AddPreStartupTask(new IdentityStartupTask(authenticators.ToArray())); + + private sealed class IdentityStartupTask(IReadOnlyList authenticators) : IServerPreStartupTask + { + public ValueTask OnServerStartingAsync(IServerContext server, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (server is not IServerInternal internalServer) + throw new InvalidOperationException("Application identity registry unavailable during server startup."); + foreach (var authenticator in authenticators) internalServer.IdentityRegistry.Register(authenticator); + return ValueTask.CompletedTask; + } + } + private readonly AsyncLocal _activationPeer = new(); /// The transport-observed peer of the current activation request, when available. @@ -40,16 +57,23 @@ public sealed class OtOpcUaSdkServer : StandardServer ExtensionObject userIdentityToken, SignatureData? userTokenSignature, RequestLifetime requestLifetime) + { + return await WithActivationPeerAddressAsync(secureChannelContext.PeerAddress, + () => base.ActivateSessionAsync(secureChannelContext, requestHeader, clientSignature, + clientSoftwareCertificates, localeIds, userIdentityToken, userTokenSignature, requestLifetime)) + .ConfigureAwait(false); + } + + internal async ValueTask WithActivationPeerAddressAsync( + System.Net.IPAddress? peer, Func> activation) { // 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; + _activationPeer.Value = peer; try { - return await base.ActivateSessionAsync(secureChannelContext, requestHeader, clientSignature, - clientSoftwareCertificates, localeIds, userIdentityToken, userTokenSignature, requestLifetime) - .ConfigureAwait(false); + return await activation().ConfigureAwait(false); } finally { @@ -267,6 +291,7 @@ public sealed class OtOpcUaSdkServer : StandardServer defaults.PercentDataGood = ServerAggregateConfiguration.PercentDataGood; defaults.UseSlopedExtrapolation = ServerAggregateConfiguration.UseSlopedExtrapolation; manager.SetDefaultConfiguration(defaults); + await Compatibility.Aggregates.CompatibilityAggregateFactory.RegisterAsync(manager, cancellationToken).ConfigureAwait(false); _otOpcUaNodeManager?.SyncAdvertisedAggregates(); return manager; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs index f6542ec7..96de455e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs @@ -1,18 +1,18 @@ using Opc.Ua; +using Opc.Ua.Server; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; /// /// A that additionally carries the LDAP-resolved roles -/// () for the authenticated session. The SDK stores the -/// instance we assign to ImpersonateEventArgs.Identity by reference onto -/// Session.Identity/Session.EffectiveIdentity (no wrapping or copying), so a method +/// () for the authenticated session. The SDK may augment identities with mandatory and dynamically mapped roles. This subtype +/// preserves the application identity when those roles are added, so a method /// handler can later recover the roles via /// (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity — the /// seam the inbound alarm ack/shelve AlarmAck gate (T18) reads. This type only carries the /// roles; it makes no auth decision. /// -public sealed class RoleCarryingUserIdentity : UserIdentity +public sealed class RoleCarryingUserIdentity : RoleBasedIdentity { /// /// Initializes a new instance, building the base identity from the same UserName token the @@ -35,7 +35,14 @@ public sealed class RoleCarryingUserIdentity : UserIdentity /// The public id of the key that authenticated, or null for anonymous. public RoleCarryingUserIdentity( UserIdentityToken token, IReadOnlyList roles, string principal, string? apiKeyId) - : base(token) + : this(new UserIdentity(token), roles, principal, apiKeyId, [], new NamespaceTable()) + { + } + + private RoleCarryingUserIdentity( + IUserIdentity identity, IReadOnlyList roles, string principal, string? apiKeyId, + IEnumerable sdkRoles, NamespaceTable namespaces) + : base(identity, sdkRoles, namespaces) { ArgumentNullException.ThrowIfNull(roles); // Freeze to a defensive copy so a caller mutating the source list can never retroactively @@ -46,8 +53,13 @@ public sealed class RoleCarryingUserIdentity : UserIdentity ApiKeyId = apiKeyId; } + /// + public override RoleBasedIdentity WithAdditionalRoles(IEnumerable additionalRoles, NamespaceTable namespaces) => + new RoleCarryingUserIdentity(InnerIdentity, Roles, Principal, ApiKeyId, + base.Roles.Concat(additionalRoles), namespaces); + /// The roles the authenticator granted this session, used by downstream permission gates. - public IReadOnlyList Roles { get; } + public new IReadOnlyList Roles { get; } /// /// The ACL principal this session authenticates as — the name NodeAcl rows key on, and what