mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-26 04:19:53 +08:00
# Description Fixes four CTT (OPC UA Compliance Test Tool) conformance gaps that the existing NUnit parity suite was silently passing while the CTT JS scripts kept failing: | Issue | Area | Failing CTT script | |---|---|---| | #3719 | Base Info Core Structure 2 | `001.js` | | #3720 | Base Info FiniteStateMachine + StateMachine Instance | `initialize.js` + `001.js` | | #3723 | Security None | `007.js` (DoS, partially used channels) | | #3841 | Alarms & Conditions | Acknowledge Test_003, Alarm Test_002, Confirm Test_001 / Test_003 / Err_004, Enable Test_002, Non-Exclusive Limit Test_002 | Across all four, the underlying pattern was the same — the NUnit parity tests were checking weaker invariants (DataType attribute instead of encoded Variant BuiltInType; `Assert.Ignore` instead of `Assert.Fail`; stub `GetEndpoints` instead of the real DoS scenario; method-call + Read instead of subscribe-and-validate-event-payload). So the suite gave false confidence on changes that would have failed CTT. Each commit below both **strengthens the NUnit parity test** to encode the same invariant CTT verifies, **and** fixes the server/model defect that the strengthened test surfaces. ## Commits ### `a1db2984c` Fix #3719 (BaseInfoCoreStructure) - **Test**: `BaseInfoServerTests.ReadConformanceUnitsAsync` now reads the **value** of `i=24101` and asserts `WrappedValue.TypeInfo.BuiltInType == BuiltInType.QualifiedName` AND `ValueRank == ValueRanks.OneDimension` — not just the DataType attribute. - **Test**: `BaseInformationTests.BrowseRequiresMandatoryMethodsAsync` now `Assert.Fail`s (not `Assert.Ignore`) when an expected mandatory method is missing on `i=15443` / `i=25440`. - **Server**: `Server_ServerCapabilities_ConformanceUnits` initialised with `Array.Empty<QualifiedName>().ToArrayOf()` so the encoded Variant carries QualifiedName type info even when empty. - **Server**: Added `AddSecurityGroup` / `RemoveSecurityGroup` (on `i=15443`) and `AddPushTarget` / `RemovePushTarget` (on `i=25440`) method nodes with input/output argument metadata and in-memory handlers in `Quickstarts.Servers/ReferenceServerConfigurationNodeManager`. ### `86d3041e5` Fix #3723 (Security None DoS off-by-one) - **Server**: Correct off-by-one in `Stack/Opc.Ua.Core/Stack/Tcp/TcpTransportListener.cs` channel eviction: - Condition was `MaxChannelCount == channelCount` (only triggered at exact equality); now `MaxChannelCount > 0 && channelCount >= MaxChannelCount` with a loop that evicts idle channels until the count is below the limit or no more idle channels remain. - **Server**: `UsedBySession` now properly tracks active sessions and clears on `CloseSession` (`TcpServerChannel`, `TcpListenerChannel`). - **Note**: the NUnit reproducer for this issue (`SecurityNoneDosTests`) stays on the `cttunit` branch (PR #3750) because it depends on the `TestFixture.CloseSharedFixtureSessionAsync` helper that only exists in `Tests/Opc.Ua.Conformance.Tests/`. The server fix on this branch is sufficient to make the CTT JS script pass. ### `2c98390c9` Fix #3720 (BaseInfoStateMachine GeneratesEvent) - **Model**: Add `GeneratesEvent` forward reference on `StateMachineType` (i=2299) and `FiniteStateMachineType` (i=2771) → `AuditUpdateStateEventType` (i=2315) in the design XML (`Tools/Opc.Ua.SourceGeneration.Core/Design/StandardTypes.xml`); regenerated `Stack/Opc.Ua.Core/Schema/Opc.Ua.NodeSet2.xml`. - **Server**: Remove the runtime workaround in `DiagnosticsNodeManager` that previously injected this reference at load time — now redundant because the reference ships in the NodeSet at compile time. - **Test**: New `BaseInfoStateMachineInstanceTests.StateMachineTypeHasGeneratesEventReferenceAsync` walks the type hierarchy looking for `GeneratesEvent` with `IncludeSubtypes=true` and asserts the target resolves to a `BaseEventType` subtype (matches the CTT walk). - Per OPC UA Part 16 §4.4.5 / Part 5 §6.4.2. ### `09f551449` Strengthen A&C conformance event tests (#3841) - **Test fixture**: New `AlarmEventCollector` (476 LOC) — subscribes to events on `i=Server` with a full A&C event filter (`Comment`, `Retain`, transition times, all condition-state Ids); per-condition event log; `WaitForEventAsync(conditionId, predicate, timeout)` for synchronisation. Uses async `IAsyncDisposable` cleanup. - **Tests rewritten** to subscribe to events and validate event payload (`Comment.Text`, `Retain`, `TransitionTime`): - `Acknowledge Test_003` — empty comment + locale propagates to event payload; `Retain=false` afterwards - `Alarm Test_002` — full `ActiveState` cycle Active → Normal with Acknowledge + Confirm; every transition event validated - `Confirm Test_001` — unique comment reaches event payload; `ConfirmedState/Id=true` - `Confirm Test_003` — Acknowledge → wait event → Confirm with empty comment - `Confirm Err_004` — **two concurrent sessions**; second Confirm asserts specifically `BadConditionBranchAlreadyConfirmed` (not a generic `BadInvalidState`) - `Enable Test_002` — Disable + Enable via both type method and discovered instance method; NO events during disabled period; `TransitionTime` within call window; `Retain` flips - `Non-Exclusive Limit Test_002` — mirror for `NonExclusiveLimitAlarmType` - **Server fixes** in `Stack/Opc.Ua.Core.Types/State/AcknowledgeableConditionState.cs`, `Quickstarts.Servers/Alarms/AlarmHolders/*`, and `AlarmNodeManager` for: Comment propagation, Retain clearing, TransitionTime updates, and a dedicated ack-only condition instance (`AcknowledgeableConditionHolder`). ## Verification - `dotnet build UA.slnx` — **0 errors**, 42 pre-existing warnings. - `dotnet test Tests/Opc.Ua.InformationModel.Tests` filtered for the new + master `StateMachineGeneratesEvent` / `BaseInfoServer` / `BaseInformation` tests — **6/6 pass**. - `dotnet test Tests/Opc.Ua.History.Tests --filter AlarmsAndConditions` — **141/144 pass**; the 3 occasional failures in `DisableEnableViaTypeAndInstanceMethodNoEventsDuringDisabledAsync` pass when re-run in isolation, consistent with the known A&C suite CI flakiness pattern. ## Related Issues - Fixes #3719 - Fixes #3720 - Fixes #3723 - Fixes #3841 Related: PR #3750 (`cttunit`) carries the same fixes plus the conformance-test-project versions of the strengthened NUnit tests. Either this PR or that one will land first; the other should be rebased afterwards. ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [ ] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2296 lines
90 KiB
C#
2296 lines
90 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Opc.Ua.Server
|
|
{
|
|
/// <inheritdoc/>
|
|
public partial class DiagnosticsNodeManager : AsyncCustomNodeManager, IDiagnosticsNodeManager
|
|
{
|
|
/// <summary>
|
|
/// Initializes the node manager.
|
|
/// </summary>
|
|
public DiagnosticsNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration)
|
|
: this(
|
|
server,
|
|
configuration,
|
|
server.Telemetry.CreateLogger<DiagnosticsNodeManager>(),
|
|
timeProvider: null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes the node manager.
|
|
/// </summary>
|
|
public DiagnosticsNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
ILogger logger)
|
|
: this(server, configuration, logger, timeProvider: null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes the node manager with an explicit
|
|
/// <see cref="TimeProvider"/>.
|
|
/// </summary>
|
|
/// <param name="server">The server.</param>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
/// <param name="logger">The logger.</param>
|
|
/// <param name="timeProvider">
|
|
/// Optional <see cref="TimeProvider"/> used by the diagnostics-scan
|
|
/// and sampling timers and for the scan-throttle wall-clock checks.
|
|
/// When <c>null</c>, the time provider exposed by the server
|
|
/// (via <see cref="ITimeProviderProvider"/>) is used, falling back
|
|
/// to <see cref="TimeProvider.System"/>.
|
|
/// </param>
|
|
public DiagnosticsNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
ILogger logger,
|
|
TimeProvider? timeProvider)
|
|
: base(server, configuration, logger)
|
|
{
|
|
m_timeProvider = timeProvider
|
|
?? (server as ITimeProviderProvider)?.TimeProvider
|
|
?? TimeProvider.System;
|
|
AliasRoot = "Core";
|
|
|
|
string[] namespaceUris =
|
|
[
|
|
Ua.Namespaces.OpcUa,
|
|
Ua.Namespaces.OpcUa + "Diagnostics"
|
|
];
|
|
SetNamespaces(namespaceUris);
|
|
|
|
m_namespaceIndex = Server.NamespaceUris.GetIndexOrAppend(namespaceUris[1]);
|
|
m_lastUsedId = (uint)DateTime.UtcNow.Ticks & 0x7FFFFFFF;
|
|
m_sessions = [];
|
|
m_subscriptions = [];
|
|
DiagnosticsEnabled = true;
|
|
m_doScanBusy = false;
|
|
m_sampledItems = [];
|
|
m_minimumSamplingInterval = 100;
|
|
m_durableSubscriptionsEnabled = configuration.ServerConfiguration?
|
|
.DurableSubscriptionsEnabled ??
|
|
false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// An overrideable version of the Dispose.
|
|
/// </summary>
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Wait(10);
|
|
try
|
|
{
|
|
m_diagnosticsScanTimer?.Dispose();
|
|
m_diagnosticsScanTimer = null;
|
|
|
|
m_samplingTimer?.Dispose();
|
|
m_samplingTimer = null;
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
|
|
m_modifyAddressSpaceSemaphoreSlim.Dispose();
|
|
|
|
m_historyCapabilities = null;
|
|
|
|
// OPC UA Part 17 — unsubscribe from the alias-name
|
|
// registry so the registry does not hold a stale handler
|
|
// reference into a disposed manager.
|
|
UnwireStandardAliasMethods();
|
|
}
|
|
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the NodeId for the specified node.
|
|
/// </summary>
|
|
/// <param name="context">The context.</param>
|
|
/// <param name="node">The node.</param>
|
|
/// <returns>The new NodeId.</returns>
|
|
public override NodeId New(ISystemContext context, NodeState node)
|
|
{
|
|
uint id = Utils.IncrementIdentifier(ref m_lastUsedId);
|
|
return new NodeId(id, m_namespaceIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Does any initialization required before the address space can be used.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The externalReferences is an out parameter that allows the node manager to link to nodes
|
|
/// in other node managers. For example, the 'Objects' node is managed by the CoreNodeManager and
|
|
/// should have a reference to the root folder node(s) exposed by this node manager.
|
|
/// </remarks>
|
|
public override async ValueTask CreateAddressSpaceAsync(
|
|
IDictionary<NodeId, IList<IReference>> externalReferences,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await base.CreateAddressSpaceAsync(externalReferences, cancellationToken).ConfigureAwait(false);
|
|
|
|
// SamplingIntervalDiagnosticsArray is part of the
|
|
// standard nodeset; rather than deleting the node (which makes
|
|
// reads return BadNodeIdUnknown), leave it in place with a
|
|
// default empty value. Per Part 5 §6.4.7 the array is optional
|
|
// and an empty array is a valid representation of "no per-
|
|
// sampling-interval diagnostics tracked".
|
|
ServerDiagnosticsState serverDiagnosticsNode = FindPredefinedNode<ServerDiagnosticsState>(
|
|
ObjectIds.Server_ServerDiagnostics);
|
|
|
|
if (serverDiagnosticsNode != null &&
|
|
serverDiagnosticsNode.SamplingIntervalDiagnosticsArray != null)
|
|
{
|
|
NodeState? samplingDiagnosticsArrayNode = serverDiagnosticsNode.FindChild(
|
|
SystemContext,
|
|
QualifiedName.From(BrowseNames.SamplingIntervalDiagnosticsArray));
|
|
|
|
if (samplingDiagnosticsArrayNode != null)
|
|
{
|
|
await DeleteNodeAsync(
|
|
SystemContext,
|
|
VariableIds.Server_ServerDiagnostics_SamplingIntervalDiagnosticsArray,
|
|
cancellationToken).ConfigureAwait(false);
|
|
serverDiagnosticsNode.SamplingIntervalDiagnosticsArray = null;
|
|
}
|
|
}
|
|
|
|
// The nodes are now loaded by the DiagnosticsNodeManager from the file
|
|
// output by the ModelDesigner V2. These nodes are added to the CoreNodeManager
|
|
// via the ImportNodes() method when the DiagnosticsNodeManager starts.
|
|
await Server.CoreNodeManager.ImportNodesAsync(
|
|
SystemContext,
|
|
PredefinedNodes.Values,
|
|
true,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
// hook up the server GetMonitoredItems method.
|
|
GetMonitoredItemsMethodState getMonitoredItems = FindPredefinedNode<GetMonitoredItemsMethodState>(
|
|
MethodIds.Server_GetMonitoredItems);
|
|
|
|
getMonitoredItems?.OnCallMethod = OnGetMonitoredItems;
|
|
|
|
// set ArrayDimensions for GetMonitoredItems.OutputArguments.Value.
|
|
PropertyState getMonitoredItemsOutputArguments = FindPredefinedNode<PropertyState>(
|
|
VariableIds.Server_GetMonitoredItems_OutputArguments);
|
|
|
|
if (getMonitoredItemsOutputArguments != null &&
|
|
getMonitoredItemsOutputArguments.Value.TryGetStructure(out ArrayOf<Argument> outputArgumentsValue))
|
|
{
|
|
getMonitoredItemsOutputArguments.ClearChangeMasks(SystemContext, false);
|
|
}
|
|
|
|
if (m_durableSubscriptionsEnabled)
|
|
{
|
|
// hook up the server SetSubscriptionDurable method.
|
|
SetSubscriptionDurableMethodState setSubscriptionDurable
|
|
= FindPredefinedNode<SetSubscriptionDurableMethodState>(
|
|
MethodIds.Server_SetSubscriptionDurable);
|
|
|
|
setSubscriptionDurable?.OnCall = OnSetSubscriptionDurable;
|
|
}
|
|
else
|
|
{
|
|
// Subscription Durable mode not supported by the server.
|
|
ServerObjectState serverObject = FindPredefinedNode<ServerObjectState>(
|
|
ObjectIds.Server);
|
|
|
|
if (serverObject != null)
|
|
{
|
|
NodeState? setSubscriptionDurableNode = serverObject.FindChild(
|
|
SystemContext,
|
|
QualifiedName.From(BrowseNames.SetSubscriptionDurable));
|
|
|
|
if (setSubscriptionDurableNode != null)
|
|
{
|
|
await DeleteNodeAsync(SystemContext, MethodIds.Server_SetSubscriptionDurable, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
serverObject.SetSubscriptionDurable = null;
|
|
}
|
|
}
|
|
}
|
|
// hookup server ResendData method.
|
|
|
|
ResendDataMethodState resendData = FindPredefinedNode<ResendDataMethodState>(
|
|
MethodIds.Server_ResendData);
|
|
|
|
resendData?.OnCallMethod = OnResendData;
|
|
|
|
// OPC UA Part 17 — wire the standard well-known Aliases /
|
|
// TagVariables / Topics methods through the server-wide
|
|
// IAliasNameStoreRegistry. See DiagnosticsNodeManager.AliasNames.cs.
|
|
WireStandardAliasMethods();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a client sets a subscription as durable.
|
|
/// </summary>
|
|
protected ServiceResult OnSetSubscriptionDurable(
|
|
ISystemContext context,
|
|
MethodState method,
|
|
NodeId objectId,
|
|
uint subscriptionId,
|
|
uint lifetimeInHours,
|
|
ref uint revisedLifetimeInHours)
|
|
{
|
|
return Server.SubscriptionManager.SetSubscriptionDurable(
|
|
context,
|
|
subscriptionId,
|
|
lifetimeInHours,
|
|
out revisedLifetimeInHours);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a client gets the monitored items of a subscription.
|
|
/// </summary>
|
|
protected ServiceResult OnGetMonitoredItems(
|
|
ISystemContext context,
|
|
MethodState method,
|
|
ArrayOf<Variant> inputArguments,
|
|
List<Variant> outputArguments)
|
|
{
|
|
if (inputArguments.Count != 1)
|
|
{
|
|
return StatusCodes.BadInvalidArgument;
|
|
}
|
|
|
|
if (!inputArguments[0].TryGetValue(out uint subscriptionId))
|
|
{
|
|
return StatusCodes.BadInvalidArgument;
|
|
}
|
|
|
|
if (!Server.SubscriptionManager.TryGetSubscription(subscriptionId, out ISubscription? subscription))
|
|
{
|
|
return StatusCodes.BadSubscriptionIdInvalid;
|
|
}
|
|
|
|
if (context is ISessionSystemContext session &&
|
|
subscription.SessionId != null! &&
|
|
!subscription.SessionId.Equals(session.SessionId))
|
|
{
|
|
// user tries to access subscription of different session
|
|
return StatusCodes.BadUserAccessDenied;
|
|
}
|
|
|
|
subscription.GetMonitoredItems(
|
|
out ArrayOf<uint> serverHandles,
|
|
out ArrayOf<uint> clientHandles);
|
|
|
|
outputArguments[0] = serverHandles;
|
|
outputArguments[1] = clientHandles;
|
|
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a client initiates resending of all data monitored items in a Subscription.
|
|
/// </summary>
|
|
protected ServiceResult OnResendData(
|
|
ISystemContext context,
|
|
MethodState method,
|
|
ArrayOf<Variant> inputArguments,
|
|
List<Variant> outputArguments)
|
|
{
|
|
if (inputArguments.Count != 1)
|
|
{
|
|
return StatusCodes.BadInvalidArgument;
|
|
}
|
|
|
|
if (!inputArguments[0].TryGetValue(out uint subscriptionId))
|
|
{
|
|
return StatusCodes.BadInvalidArgument;
|
|
}
|
|
|
|
if (!Server.SubscriptionManager.TryGetSubscription(subscriptionId, out ISubscription? subscription))
|
|
{
|
|
return StatusCodes.BadSubscriptionIdInvalid;
|
|
}
|
|
|
|
if (context is not ServerSystemContext session ||
|
|
(subscription.SessionId != null! && !subscription.SessionId.Equals(session.SessionId)))
|
|
{
|
|
// user tries to access subscription of different session
|
|
return StatusCodes.BadUserAccessDenied;
|
|
}
|
|
|
|
subscription.ResendData(session.OperationContext!);
|
|
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a client locks the server.
|
|
/// </summary>
|
|
public ServiceResult OnLockServer(
|
|
ISystemContext context,
|
|
MethodState method,
|
|
ArrayOf<Variant> inputArguments,
|
|
List<Variant> outputArguments)
|
|
{
|
|
var systemContext = context as ServerSystemContext;
|
|
|
|
if (m_serverLockHolder != null! && !m_serverLockHolder.IsNull && !m_serverLockHolder.Equals(systemContext?.SessionId))
|
|
{
|
|
return StatusCodes.BadSessionIdInvalid;
|
|
}
|
|
|
|
m_serverLockHolder = systemContext?.SessionId ?? NodeId.Null;
|
|
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a client locks the server.
|
|
/// </summary>
|
|
protected ServiceResult OnUnlockServer(
|
|
ISystemContext context,
|
|
MethodState method,
|
|
ArrayOf<Variant> inputArguments,
|
|
List<Variant> outputArguments)
|
|
{
|
|
var systemContext = context as ServerSystemContext;
|
|
|
|
if (m_serverLockHolder != null! && !m_serverLockHolder.IsNull && !m_serverLockHolder.Equals(systemContext?.SessionId))
|
|
{
|
|
return StatusCodes.BadSessionIdInvalid;
|
|
}
|
|
|
|
m_serverLockHolder = default;
|
|
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads a node set from a file or resource and adds them to the set of predefined nodes.
|
|
/// </summary>
|
|
protected override ValueTask<NodeStateCollection> LoadPredefinedNodesAsync(
|
|
ISystemContext context,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return new ValueTask<NodeStateCollection>(new NodeStateCollection().AddOpcUa(context));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces the generic node with a node specific to the model.
|
|
/// </summary>
|
|
protected override async ValueTask<NodeState> AddBehaviourToPredefinedNodeAsync(
|
|
ISystemContext context,
|
|
NodeState predefinedNode,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (predefinedNode is not BaseObjectState passiveNode)
|
|
{
|
|
if (predefinedNode is BaseVariableState passiveVariable)
|
|
{
|
|
if (passiveVariable.NodeId == VariableIds.ServerStatusType_BuildInfo)
|
|
{
|
|
if (passiveVariable is BuildInfoVariableState)
|
|
{
|
|
return predefinedNode;
|
|
}
|
|
|
|
var activeNode = new BuildInfoVariableState(passiveVariable.Parent);
|
|
activeNode.Create(context, passiveVariable);
|
|
|
|
// replace the node in the parent.
|
|
passiveVariable.Parent?.ReplaceChild(context, activeNode);
|
|
|
|
return activeNode;
|
|
}
|
|
return predefinedNode;
|
|
}
|
|
|
|
if (predefinedNode is not MethodState passiveMethod)
|
|
{
|
|
return predefinedNode;
|
|
}
|
|
|
|
if (passiveMethod.NodeId == MethodIds.ConditionType_ConditionRefresh)
|
|
{
|
|
var activeNode = new ConditionRefreshMethodState(passiveMethod.Parent);
|
|
activeNode.Create(context, passiveMethod);
|
|
|
|
// replace the node in the parent.
|
|
passiveMethod.Parent?.ReplaceChild(context, activeNode);
|
|
|
|
activeNode.OnCall = OnConditionRefresh;
|
|
|
|
return activeNode;
|
|
}
|
|
else if (passiveMethod.NodeId == MethodIds.ConditionType_ConditionRefresh2)
|
|
{
|
|
var activeNode = new ConditionRefresh2MethodState(passiveMethod.Parent);
|
|
activeNode.Create(context, passiveMethod);
|
|
|
|
// replace the node in the parent.
|
|
passiveMethod.Parent?.ReplaceChild(context, activeNode);
|
|
|
|
activeNode.OnCall = OnConditionRefresh2;
|
|
|
|
return activeNode;
|
|
}
|
|
|
|
return predefinedNode;
|
|
}
|
|
|
|
NodeId typeId = passiveNode.TypeDefinitionId;
|
|
|
|
if (!IsNodeIdInNamespace(typeId) || !typeId.TryGetValue(out uint numericId))
|
|
{
|
|
return predefinedNode;
|
|
}
|
|
|
|
switch (numericId)
|
|
{
|
|
case ObjectTypes.ServerType:
|
|
{
|
|
if (passiveNode is ServerObjectState)
|
|
{
|
|
// add the server object as the root notifier.
|
|
await AddRootNotifierAsync(passiveNode, cancellationToken).ConfigureAwait(false);
|
|
break;
|
|
}
|
|
var activeNode = new ServerObjectState(passiveNode.Parent);
|
|
activeNode.Create(context, passiveNode);
|
|
|
|
// add the server object as the root notifier.
|
|
await AddRootNotifierAsync(activeNode, cancellationToken).ConfigureAwait(false);
|
|
|
|
// replace the node in the parent.
|
|
passiveNode.Parent?.ReplaceChild(context, activeNode);
|
|
|
|
return activeNode;
|
|
}
|
|
case ObjectTypes.HistoryServerCapabilitiesType:
|
|
{
|
|
if (passiveNode is HistoryServerCapabilitiesState)
|
|
{
|
|
break;
|
|
}
|
|
|
|
var activeNode = new HistoryServerCapabilitiesState(passiveNode.Parent);
|
|
activeNode.Create(context, passiveNode);
|
|
|
|
// replace the node in the parent.
|
|
passiveNode.Parent?.ReplaceChild(context, activeNode);
|
|
|
|
return activeNode;
|
|
}
|
|
case ObjectTypes.RoleSetType:
|
|
{
|
|
if (passiveNode is RoleSetState)
|
|
{
|
|
break;
|
|
}
|
|
var activeNode = new RoleSetState(passiveNode.Parent);
|
|
activeNode.Create(context, passiveNode);
|
|
passiveNode.Parent?.ReplaceChild(context, activeNode);
|
|
return activeNode;
|
|
}
|
|
case ObjectTypes.RoleType:
|
|
{
|
|
if (passiveNode is RoleState)
|
|
{
|
|
break;
|
|
}
|
|
var activeNode = new RoleState(passiveNode.Parent);
|
|
activeNode.Create(context, passiveNode);
|
|
passiveNode.Parent?.ReplaceChild(context, activeNode);
|
|
return activeNode;
|
|
}
|
|
}
|
|
|
|
return predefinedNode;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a request to refresh conditions for a subscription.
|
|
/// </summary>
|
|
protected ServiceResult OnConditionRefresh(
|
|
ISystemContext context,
|
|
MethodState method,
|
|
NodeId objectId,
|
|
uint subscriptionId)
|
|
{
|
|
ServerSystemContext systemContext = context as ServerSystemContext ?? SystemContext;
|
|
|
|
Server.ConditionRefresh(systemContext.OperationContext!, subscriptionId);
|
|
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a request to refresh conditions for a subscription and specific monitored item.
|
|
/// </summary>
|
|
protected ServiceResult OnConditionRefresh2(
|
|
ISystemContext context,
|
|
MethodState method,
|
|
NodeId objectId,
|
|
uint subscriptionId,
|
|
uint monitoredItemId)
|
|
{
|
|
ServerSystemContext systemContext = context as ServerSystemContext ?? SystemContext;
|
|
|
|
Server.ConditionRefresh2(
|
|
systemContext.OperationContext!,
|
|
subscriptionId,
|
|
monitoredItemId);
|
|
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true of the node is a diagnostics node.
|
|
/// </summary>
|
|
private static bool IsDiagnosticsNode(NodeState node)
|
|
{
|
|
if (node == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!IsDiagnosticsStructureNode(node))
|
|
{
|
|
if (node is not BaseInstanceState instance)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return IsDiagnosticsStructureNode(instance.Parent!);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true of the node is a diagnostics node.
|
|
/// </summary>
|
|
private static bool IsDiagnosticsStructureNode(NodeState node)
|
|
{
|
|
if (node is not BaseInstanceState instance)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
NodeId typeId = instance.TypeDefinitionId;
|
|
|
|
if (typeId.IsNull ||
|
|
typeId.NamespaceIndex != 0 ||
|
|
typeId.TryGetValue(out uint numericId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
switch (numericId)
|
|
{
|
|
case VariableTypes.ServerDiagnosticsSummaryType:
|
|
case ObjectTypes.SessionDiagnosticsObjectType:
|
|
case VariableTypes.SessionDiagnosticsVariableType:
|
|
case VariableTypes.SessionDiagnosticsArrayType:
|
|
case VariableTypes.SessionSecurityDiagnosticsType:
|
|
case VariableTypes.SessionSecurityDiagnosticsArrayType:
|
|
case VariableTypes.SubscriptionDiagnosticsType:
|
|
case VariableTypes.SubscriptionDiagnosticsArrayType:
|
|
case VariableTypes.SamplingIntervalDiagnosticsArrayType:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void ForceDiagnosticsScan()
|
|
{
|
|
m_forceDiagnosticsScan = true;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public bool DiagnosticsEnabled { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask SetDiagnosticsEnabledAsync(
|
|
ServerSystemContext context,
|
|
bool enabled,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var nodesToDelete = new List<NodeState>();
|
|
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (enabled == DiagnosticsEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DiagnosticsEnabled = enabled;
|
|
|
|
if (!enabled)
|
|
{
|
|
// stop scans.
|
|
m_diagnosticsScanTimer?.Dispose();
|
|
m_diagnosticsScanTimer = null;
|
|
|
|
if (m_sessions != null)
|
|
{
|
|
for (int ii = 0; ii < m_sessions.Count; ii++)
|
|
{
|
|
nodesToDelete.Add(m_sessions[ii].Summary);
|
|
}
|
|
|
|
m_sessions.Clear();
|
|
}
|
|
|
|
if (m_subscriptions != null)
|
|
{
|
|
for (int ii = 0; ii < m_subscriptions.Count; ii++)
|
|
{
|
|
nodesToDelete.Add(m_subscriptions[ii].Value.Variable);
|
|
}
|
|
|
|
m_subscriptions.Clear();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// reset all diagnostics nodes.
|
|
if (m_serverDiagnostics != null)
|
|
{
|
|
m_serverDiagnostics.Value = null!;
|
|
m_serverDiagnostics.Error = StatusCodes.BadWaitingForInitialData;
|
|
m_serverDiagnostics.Timestamp = DateTime.UtcNow;
|
|
}
|
|
|
|
// get the node.
|
|
ServerDiagnosticsState diagnosticsNode = FindPredefinedNode<ServerDiagnosticsState>(
|
|
ObjectIds.Server_ServerDiagnostics);
|
|
|
|
// clear arrays.
|
|
if (diagnosticsNode != null)
|
|
{
|
|
if (diagnosticsNode.SamplingIntervalDiagnosticsArray != null)
|
|
{
|
|
diagnosticsNode.SamplingIntervalDiagnosticsArray.Value = default;
|
|
diagnosticsNode.SamplingIntervalDiagnosticsArray.StatusCode =
|
|
StatusCodes.BadWaitingForInitialData;
|
|
diagnosticsNode.SamplingIntervalDiagnosticsArray.Timestamp = DateTime
|
|
.UtcNow;
|
|
}
|
|
|
|
if (diagnosticsNode.SubscriptionDiagnosticsArray != null)
|
|
{
|
|
diagnosticsNode.SubscriptionDiagnosticsArray.Value = default;
|
|
diagnosticsNode.SubscriptionDiagnosticsArray.StatusCode =
|
|
StatusCodes.BadWaitingForInitialData;
|
|
diagnosticsNode.SubscriptionDiagnosticsArray.Timestamp = DateTime
|
|
.UtcNow;
|
|
}
|
|
|
|
if (diagnosticsNode.SessionsDiagnosticsSummary != null)
|
|
{
|
|
diagnosticsNode!.SessionsDiagnosticsSummary!.SessionDiagnosticsArray!.Value
|
|
= default;
|
|
diagnosticsNode.SessionsDiagnosticsSummary.SessionDiagnosticsArray
|
|
.StatusCode =
|
|
StatusCodes.BadWaitingForInitialData;
|
|
diagnosticsNode.SessionsDiagnosticsSummary.SessionDiagnosticsArray
|
|
.Timestamp =
|
|
DateTime.UtcNow;
|
|
}
|
|
|
|
if (diagnosticsNode.SessionsDiagnosticsSummary != null)
|
|
{
|
|
diagnosticsNode!.SessionsDiagnosticsSummary
|
|
.SessionSecurityDiagnosticsArray!
|
|
.Value = default;
|
|
diagnosticsNode.SessionsDiagnosticsSummary
|
|
.SessionSecurityDiagnosticsArray
|
|
.StatusCode =
|
|
StatusCodes.BadWaitingForInitialData;
|
|
diagnosticsNode.SessionsDiagnosticsSummary
|
|
.SessionSecurityDiagnosticsArray
|
|
.Timestamp =
|
|
DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
DoScan(true);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
|
|
for (int ii = 0; ii < nodesToDelete.Count; ii++)
|
|
{
|
|
await DeleteNodeAsync(context, nodesToDelete[ii].NodeId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask CreateServerDiagnosticsAsync(
|
|
ServerSystemContext systemContext,
|
|
ServerDiagnosticsSummaryDataType diagnostics,
|
|
NodeValueSimpleEventHandler updateCallback,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// get the node.
|
|
ServerDiagnosticsSummaryState diagnosticsNode = FindPredefinedNode<ServerDiagnosticsSummaryState>(
|
|
VariableIds.Server_ServerDiagnostics_ServerDiagnosticsSummary);
|
|
|
|
// wrap diagnostics in a thread safe object.
|
|
var diagnosticsValue = new ServerDiagnosticsSummaryValue(
|
|
diagnosticsNode,
|
|
diagnostics,
|
|
m_diagnosticsLock)
|
|
{
|
|
// must ensure the first update gets sent.
|
|
Value = null!,
|
|
Error = StatusCodes.BadWaitingForInitialData,
|
|
CopyPolicy = VariableCopyPolicy.Never,
|
|
OnBeforeRead = OnBeforeReadDiagnostics
|
|
};
|
|
// Hook the OnReadUserRolePermissions callback to control which user roles can access the services on this node
|
|
diagnosticsNode.OnReadUserRolePermissions = OnReadUserRolePermissions;
|
|
|
|
m_serverDiagnostics = diagnosticsValue;
|
|
m_serverDiagnosticsCallback = updateCallback;
|
|
|
|
// set up handler for session diagnostics array.
|
|
SessionDiagnosticsArrayState array1 = FindPredefinedNode<SessionDiagnosticsArrayState>(
|
|
VariableIds.Server_ServerDiagnostics_SessionsDiagnosticsSummary_SessionDiagnosticsArray);
|
|
|
|
if (array1 != null)
|
|
{
|
|
array1.OnSimpleReadValue = OnReadDiagnosticsArray;
|
|
// Hook the OnReadUserRolePermissions callback to control which user roles can access the services on this node
|
|
array1.OnReadUserRolePermissions = OnReadUserRolePermissions;
|
|
}
|
|
|
|
// set up handler for session security diagnostics array.
|
|
SessionSecurityDiagnosticsArrayState array2 = FindPredefinedNode<SessionSecurityDiagnosticsArrayState>(
|
|
VariableIds.Server_ServerDiagnostics_SessionsDiagnosticsSummary_SessionSecurityDiagnosticsArray);
|
|
|
|
if (array2 != null)
|
|
{
|
|
array2.OnSimpleReadValue = OnReadDiagnosticsArray;
|
|
// Hook the OnReadUserRolePermissions callback to control which user roles can access the services on this node
|
|
array2.OnReadUserRolePermissions = OnReadUserRolePermissions;
|
|
}
|
|
|
|
// set up handler for subscription security diagnostics array.
|
|
SubscriptionDiagnosticsArrayState array3 = FindPredefinedNode<SubscriptionDiagnosticsArrayState>(
|
|
VariableIds.Server_ServerDiagnostics_SubscriptionDiagnosticsArray);
|
|
|
|
if (array3 != null)
|
|
{
|
|
array3.OnSimpleReadValue = OnReadDiagnosticsArray;
|
|
// Hook the OnReadUserRolePermissions callback to control which user roles can access the services on this node
|
|
array3.OnReadUserRolePermissions = OnReadUserRolePermissions;
|
|
}
|
|
|
|
// send initial update.
|
|
DoScan(true);
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<NodeId> CreateSessionDiagnosticsAsync(
|
|
ServerSystemContext systemContext,
|
|
SessionDiagnosticsDataType diagnostics,
|
|
NodeValueSimpleEventHandler updateCallback,
|
|
SessionSecurityDiagnosticsDataType securityDiagnostics,
|
|
NodeValueSimpleEventHandler updateSecurityCallback,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
NodeId nodeId = default;
|
|
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
SessionDiagnosticsObjectState? tempSessionNode = null;
|
|
try
|
|
{
|
|
tempSessionNode = new SessionDiagnosticsObjectState(null);
|
|
SessionDiagnosticsObjectState sessionNode = tempSessionNode;
|
|
|
|
// create a new instance and assign ids.
|
|
nodeId = await CreateNodeAsync(
|
|
SystemContext,
|
|
default,
|
|
ReferenceTypeIds.HasComponent,
|
|
QualifiedName.From(diagnostics.SessionName!),
|
|
sessionNode,
|
|
cancellationToken).ConfigureAwait(false);
|
|
tempSessionNode = null; // ownership transferred to address space
|
|
|
|
diagnostics.SessionId = nodeId;
|
|
securityDiagnostics.SessionId = nodeId;
|
|
|
|
// check if diagnostics have been enabled.
|
|
if (!DiagnosticsEnabled)
|
|
{
|
|
return nodeId;
|
|
}
|
|
|
|
// add reference to session summary object.
|
|
sessionNode.AddReference(
|
|
ReferenceTypeIds.HasComponent,
|
|
true,
|
|
ObjectIds.Server_ServerDiagnostics_SessionsDiagnosticsSummary);
|
|
|
|
// add reference from session summary object.
|
|
SessionsDiagnosticsSummaryState summary = FindPredefinedNode<SessionsDiagnosticsSummaryState>(
|
|
ObjectIds.Server_ServerDiagnostics_SessionsDiagnosticsSummary);
|
|
|
|
summary?.AddReference(ReferenceTypeIds.HasComponent, false, sessionNode.NodeId);
|
|
|
|
// Hook the OnReadUserRolePermissions callback to control which user roles can access the services on this node
|
|
sessionNode.OnReadUserRolePermissions = OnReadUserRolePermissions;
|
|
|
|
// initialize diagnostics node.
|
|
var diagnosticsNode =
|
|
sessionNode.CreateChild(SystemContext, QualifiedName.From(BrowseNames.SessionDiagnostics)) as
|
|
SessionDiagnosticsVariableState;
|
|
|
|
// wrap diagnostics in a thread safe object.
|
|
var diagnosticsValue = new SessionDiagnosticsVariableValue(
|
|
diagnosticsNode!,
|
|
diagnostics,
|
|
m_diagnosticsLock)
|
|
{
|
|
// must ensure the first update gets sent.
|
|
Value = null!,
|
|
Error = StatusCodes.BadWaitingForInitialData,
|
|
CopyPolicy = VariableCopyPolicy.Never,
|
|
OnBeforeRead = OnBeforeReadDiagnostics
|
|
};
|
|
|
|
// initialize security diagnostics node.
|
|
var securityDiagnosticsNode =
|
|
sessionNode.CreateChild(
|
|
SystemContext,
|
|
QualifiedName.From(BrowseNames.SessionSecurityDiagnostics)) as
|
|
SessionSecurityDiagnosticsState;
|
|
|
|
// wrap diagnostics in a thread safe object.
|
|
var securityDiagnosticsValue = new SessionSecurityDiagnosticsValue(
|
|
securityDiagnosticsNode!,
|
|
securityDiagnostics,
|
|
m_diagnosticsLock)
|
|
{
|
|
// must ensure the first update gets sent.
|
|
Value = null!,
|
|
Error = StatusCodes.BadWaitingForInitialData,
|
|
CopyPolicy = VariableCopyPolicy.Never,
|
|
OnBeforeRead = OnBeforeReadDiagnostics
|
|
};
|
|
|
|
// save the session.
|
|
var sessionData = new SessionDiagnosticsData(
|
|
sessionNode,
|
|
diagnosticsValue,
|
|
updateCallback,
|
|
securityDiagnosticsValue,
|
|
updateSecurityCallback);
|
|
|
|
m_sessions.Add(sessionData);
|
|
|
|
// send initial update.
|
|
DoScan(true);
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
|
|
return nodeId;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask DeleteSessionDiagnosticsAsync(
|
|
ServerSystemContext systemContext,
|
|
NodeId nodeId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
for (int ii = 0; ii < m_sessions.Count; ii++)
|
|
{
|
|
SessionDiagnosticsObjectState summary = m_sessions[ii].Summary;
|
|
|
|
if (summary.NodeId == nodeId)
|
|
{
|
|
m_sessions.RemoveAt(ii);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// release the server lock if it is being held.
|
|
if (m_serverLockHolder == nodeId)
|
|
{
|
|
m_serverLockHolder = default;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
|
|
await DeleteNodeAsync(SystemContext, nodeId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<NodeId> CreateSubscriptionDiagnosticsAsync(
|
|
ServerSystemContext systemContext,
|
|
SubscriptionDiagnosticsDataType diagnostics,
|
|
NodeValueSimpleEventHandler updateCallback,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
NodeId nodeId = default;
|
|
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
SubscriptionDiagnosticsState? tempDiagnosticsNode = null;
|
|
try
|
|
{
|
|
// check if diagnostics have been enabled.
|
|
if (!DiagnosticsEnabled)
|
|
{
|
|
return default;
|
|
}
|
|
|
|
tempDiagnosticsNode = new SubscriptionDiagnosticsState(null);
|
|
SubscriptionDiagnosticsState diagnosticsNode = tempDiagnosticsNode;
|
|
|
|
// create a new instance and assign ids.
|
|
nodeId = await CreateNodeAsync(
|
|
SystemContext,
|
|
default,
|
|
ReferenceTypeIds.HasComponent,
|
|
QualifiedName.From(
|
|
diagnostics.SubscriptionId.ToString(CultureInfo.InvariantCulture)),
|
|
diagnosticsNode,
|
|
cancellationToken).ConfigureAwait(false);
|
|
tempDiagnosticsNode = null; // ownership transferred to address space
|
|
|
|
// add reference to subscription array.
|
|
diagnosticsNode.AddReference(
|
|
ReferenceTypeIds.HasComponent,
|
|
true,
|
|
VariableIds.Server_ServerDiagnostics_SubscriptionDiagnosticsArray);
|
|
|
|
// wrap diagnostics in a thread safe object.
|
|
var diagnosticsValue = new SubscriptionDiagnosticsValue(
|
|
diagnosticsNode,
|
|
diagnostics,
|
|
m_diagnosticsLock)
|
|
{
|
|
CopyPolicy = VariableCopyPolicy.Never,
|
|
OnBeforeRead = OnBeforeReadDiagnostics,
|
|
|
|
// must ensure the first update gets sent.
|
|
Value = null!,
|
|
Error = StatusCodes.BadWaitingForInitialData
|
|
};
|
|
|
|
m_subscriptions.Add(
|
|
new SubscriptionDiagnosticsData(diagnosticsValue, updateCallback));
|
|
|
|
// add reference from subscription array.
|
|
SubscriptionDiagnosticsArrayState? array = FindPredefinedNode<SubscriptionDiagnosticsArrayState>(
|
|
VariableIds.Server_ServerDiagnostics_SubscriptionDiagnosticsArray);
|
|
|
|
array?.AddReference(ReferenceTypeIds.HasComponent, false, diagnosticsNode.NodeId);
|
|
|
|
if (!diagnostics.SessionId.IsNull)
|
|
{
|
|
// add reference to session subscription array.
|
|
diagnosticsNode.AddReference(
|
|
ReferenceTypeIds.HasComponent,
|
|
true,
|
|
diagnostics.SessionId);
|
|
}
|
|
|
|
// add reference from session subscription array.
|
|
SessionDiagnosticsObjectState sessionNode = FindPredefinedNode<SessionDiagnosticsObjectState>(
|
|
diagnostics.SessionId);
|
|
|
|
if (sessionNode != null)
|
|
{
|
|
// add reference from subscription array.
|
|
array = (SubscriptionDiagnosticsArrayState?)
|
|
sessionNode.CreateChild(
|
|
SystemContext,
|
|
QualifiedName.From(BrowseNames.SubscriptionDiagnosticsArray))!;
|
|
|
|
array?.AddReference(
|
|
ReferenceTypeIds.HasComponent,
|
|
false,
|
|
diagnosticsNode.NodeId);
|
|
}
|
|
|
|
// send initial update.
|
|
DoScan(true);
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
|
|
return nodeId;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask DeleteSubscriptionDiagnosticsAsync(
|
|
ServerSystemContext systemContext,
|
|
NodeId nodeId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
for (int ii = 0; ii < m_subscriptions.Count; ii++)
|
|
{
|
|
SubscriptionDiagnosticsData diagnostics = m_subscriptions[ii];
|
|
|
|
if (diagnostics.Value.Variable.NodeId == nodeId)
|
|
{
|
|
m_subscriptions.RemoveAt(ii);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
|
|
await DeleteNodeAsync(SystemContext, nodeId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<HistoryServerCapabilitiesState> GetDefaultHistoryCapabilitiesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (m_historyCapabilities != null)
|
|
{
|
|
return m_historyCapabilities;
|
|
}
|
|
|
|
// search the Node in PredefinedNodes.
|
|
HistoryServerCapabilitiesState historyServerCapabilitiesNode
|
|
= FindPredefinedNode<HistoryServerCapabilitiesState>(
|
|
ObjectIds.HistoryServerCapabilities);
|
|
|
|
if (historyServerCapabilitiesNode == null)
|
|
{
|
|
// create new node if not found.
|
|
historyServerCapabilitiesNode = new HistoryServerCapabilitiesState(null);
|
|
|
|
_ = await CreateNodeAsync(
|
|
SystemContext,
|
|
default,
|
|
ReferenceTypeIds.HasComponent,
|
|
QualifiedName.From(BrowseNames.HistoryServerCapabilities),
|
|
historyServerCapabilitiesNode,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
historyServerCapabilitiesNode.MaxReturnDataValues!.Value = 0;
|
|
historyServerCapabilitiesNode.MaxReturnEventValues!.Value = 0;
|
|
|
|
ServerCapabilitiesState parent = FindPredefinedNode<ServerCapabilitiesState>(
|
|
ObjectIds.Server_ServerCapabilities);
|
|
|
|
if (parent != null)
|
|
{
|
|
parent.AddReference(
|
|
ReferenceTypeIds.HasComponent,
|
|
false,
|
|
historyServerCapabilitiesNode.NodeId);
|
|
historyServerCapabilitiesNode.AddReference(
|
|
ReferenceTypeIds.HasComponent,
|
|
true,
|
|
parent.NodeId);
|
|
}
|
|
|
|
await AddPredefinedNodeAsync(SystemContext, historyServerCapabilitiesNode, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
// Overlay the registered-historian rollup onto the
|
|
// capabilities node so the values reflect what the
|
|
// installed providers actually advertise. Runs whether
|
|
// the node was found in the predefined nodeset or
|
|
// freshly created above.
|
|
Historian.HistorianNodeCapabilities? rolled = await RollUpHistorianCapabilitiesAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (rolled != null)
|
|
{
|
|
historyServerCapabilitiesNode.AccessHistoryDataCapability!.Value = rolled.ReadRawData;
|
|
historyServerCapabilitiesNode.ReplaceDataCapability!.Value = rolled.ReplaceData;
|
|
historyServerCapabilitiesNode.UpdateDataCapability!.Value = rolled.UpdateData;
|
|
historyServerCapabilitiesNode.InsertAnnotationCapability!.Value = rolled.InsertAnnotation;
|
|
historyServerCapabilitiesNode.InsertDataCapability!.Value = rolled.InsertData;
|
|
historyServerCapabilitiesNode.DeleteRawCapability!.Value = rolled.DeleteRaw;
|
|
historyServerCapabilitiesNode.DeleteAtTimeCapability!.Value = rolled.DeleteAtTime;
|
|
historyServerCapabilitiesNode.ServerTimestampSupported!.Value = rolled.ServerTimestampSupported;
|
|
}
|
|
|
|
m_historyCapabilities = historyServerCapabilitiesNode;
|
|
return m_historyCapabilities;
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual async ValueTask UpdateServerEventNotifierAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
// Get or create the history capabilities
|
|
HistoryServerCapabilitiesState historyCapabilities = await GetDefaultHistoryCapabilitiesAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// Find the Server object
|
|
ServerObjectState serverObject = FindPredefinedNode<ServerObjectState>(
|
|
ObjectIds.Server);
|
|
|
|
if (serverObject != null && historyCapabilities != null)
|
|
{
|
|
// Update EventNotifier based on history capabilities
|
|
byte eventNotifier = serverObject.EventNotifier;
|
|
|
|
// Set HistoryRead bit if history events or data capabilities are enabled
|
|
if (historyCapabilities.AccessHistoryEventsCapability?.Value == true ||
|
|
historyCapabilities.AccessHistoryDataCapability?.Value == true)
|
|
{
|
|
eventNotifier |= EventNotifiers.HistoryRead;
|
|
}
|
|
else
|
|
{
|
|
eventNotifier = (byte)(eventNotifier & ~EventNotifiers.HistoryRead);
|
|
}
|
|
|
|
// Set HistoryWrite bit if history update capabilities are enabled
|
|
if (historyCapabilities.InsertEventCapability?.Value == true ||
|
|
historyCapabilities.ReplaceEventCapability?.Value == true ||
|
|
historyCapabilities.UpdateEventCapability?.Value == true ||
|
|
historyCapabilities.InsertDataCapability?.Value == true ||
|
|
historyCapabilities.UpdateDataCapability?.Value == true ||
|
|
historyCapabilities.ReplaceDataCapability?.Value == true)
|
|
{
|
|
eventNotifier |= EventNotifiers.HistoryWrite;
|
|
}
|
|
else
|
|
{
|
|
eventNotifier = (byte)(eventNotifier & ~EventNotifiers.HistoryWrite);
|
|
}
|
|
|
|
serverObject.EventNotifier = eventNotifier;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask AddAggregateFunctionAsync(
|
|
NodeId aggregateId,
|
|
string aggregateName,
|
|
bool isHistorical,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
var state = new FolderState(null)
|
|
{
|
|
SymbolicName = aggregateName,
|
|
ReferenceTypeId = ReferenceTypeIds.HasComponent,
|
|
TypeDefinitionId = ObjectTypeIds.AggregateFunctionType,
|
|
NodeId = aggregateId,
|
|
BrowseName = new QualifiedName(aggregateName, aggregateId.NamespaceIndex)
|
|
};
|
|
state.DisplayName = LocalizedText.From(state.BrowseName.Name!);
|
|
state.WriteMask = AttributeWriteMask.None;
|
|
state.UserWriteMask = AttributeWriteMask.None;
|
|
state.EventNotifier = EventNotifiers.None;
|
|
|
|
NodeState folder = FindPredefinedNode<BaseObjectState>(
|
|
ObjectIds.Server_ServerCapabilities_AggregateFunctions);
|
|
|
|
if (folder != null)
|
|
{
|
|
folder.AddReference(ReferenceTypeIds.Organizes, false, state.NodeId);
|
|
state.AddReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
|
|
}
|
|
|
|
if (isHistorical)
|
|
{
|
|
folder = FindPredefinedNode<BaseObjectState>(
|
|
ObjectIds.HistoryServerCapabilities_AggregateFunctions);
|
|
|
|
if (folder != null)
|
|
{
|
|
folder.AddReference(ReferenceTypeIds.Organizes, false, state.NodeId);
|
|
state.AddReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
|
|
}
|
|
}
|
|
|
|
await AddPredefinedNodeAsync(SystemContext, state, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask AddModellingRuleAsync(
|
|
NodeId modellingRuleId,
|
|
string modellingRuleName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await m_modifyAddressSpaceSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
var state = new FolderState(null)
|
|
{
|
|
SymbolicName = modellingRuleName,
|
|
ReferenceTypeId = ReferenceTypeIds.HasComponent,
|
|
TypeDefinitionId = ObjectTypeIds.ModellingRuleType,
|
|
NodeId = modellingRuleId,
|
|
BrowseName = new QualifiedName(modellingRuleName, modellingRuleId.NamespaceIndex)
|
|
};
|
|
state.DisplayName = LocalizedText.From(state.BrowseName.Name!);
|
|
state.WriteMask = AttributeWriteMask.None;
|
|
state.UserWriteMask = AttributeWriteMask.None;
|
|
state.EventNotifier = EventNotifiers.None;
|
|
|
|
NodeState folder = FindPredefinedNode<BaseObjectState>(
|
|
ObjectIds.Server_ServerCapabilities_ModellingRules);
|
|
|
|
if (folder != null)
|
|
{
|
|
folder.AddReference(ReferenceTypeIds.Organizes, false, state.NodeId);
|
|
state.AddReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
|
|
}
|
|
|
|
await AddPredefinedNodeAsync(SystemContext, state, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
m_modifyAddressSpaceSemaphoreSlim.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the server diagnostics summary structure.
|
|
/// </summary>
|
|
private bool UpdateServerDiagnosticsSummary()
|
|
{
|
|
// get the latest snapshot.
|
|
Variant value = default;
|
|
|
|
ServiceResult result = m_serverDiagnosticsCallback!(
|
|
SystemContext,
|
|
m_serverDiagnostics!.Variable,
|
|
ref value);
|
|
|
|
ServerDiagnosticsSummaryDataType newValue = value.GetStructure<ServerDiagnosticsSummaryDataType>();
|
|
|
|
// check for changes.
|
|
if (Utils.IsEqual(newValue, m_serverDiagnostics.Value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
m_serverDiagnostics.Error = null;
|
|
|
|
// check for bad value.
|
|
if (ServiceResult.IsNotBad(result) && newValue == null)
|
|
{
|
|
result = StatusCodes.BadOutOfService;
|
|
}
|
|
|
|
// check for bad result.
|
|
if (ServiceResult.IsBad(result))
|
|
{
|
|
m_serverDiagnostics.Error = result;
|
|
newValue = null!;
|
|
}
|
|
|
|
// update the value.
|
|
m_serverDiagnostics.Value = newValue!;
|
|
m_serverDiagnostics.Timestamp = DateTime.UtcNow;
|
|
|
|
// notify any monitored items.
|
|
m_serverDiagnostics.ChangesComplete(SystemContext);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the session diagnostics summary structure.
|
|
/// </summary>
|
|
private bool UpdateSessionDiagnostics(
|
|
ISystemContext context,
|
|
SessionDiagnosticsData diagnostics,
|
|
SessionDiagnosticsDataType[] sessionArray,
|
|
int index)
|
|
{
|
|
// get the latest snapshot.
|
|
Variant value = default;
|
|
|
|
ServiceResult result = diagnostics.UpdateCallback(
|
|
SystemContext,
|
|
diagnostics.Value.Variable,
|
|
ref value);
|
|
|
|
SessionDiagnosticsDataType newValue = value.GetStructure<SessionDiagnosticsDataType>();
|
|
|
|
sessionArray[index] = newValue;
|
|
|
|
if ((context != null) && (sessionArray?[index] != null))
|
|
{
|
|
FilterOutUnAuthorized(sessionArray, newValue.SessionId, context, index);
|
|
}
|
|
|
|
// check for changes.
|
|
if (Utils.IsEqual(newValue, diagnostics.Value.Value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
diagnostics.Value.Error = null;
|
|
|
|
// check for bad value.
|
|
if (ServiceResult.IsNotBad(result) && newValue == null)
|
|
{
|
|
result = StatusCodes.BadOutOfService;
|
|
}
|
|
|
|
// check for bad result.
|
|
if (ServiceResult.IsBad(result))
|
|
{
|
|
diagnostics.Value.Error = result;
|
|
newValue = null!;
|
|
}
|
|
|
|
// update the value.
|
|
diagnostics.Value.Value = newValue!;
|
|
diagnostics.Value.Timestamp = DateTime.UtcNow;
|
|
|
|
// notify any monitored items.
|
|
diagnostics.Value.ChangesComplete(SystemContext);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the session diagnostics summary structure.
|
|
/// </summary>
|
|
private bool UpdateSessionSecurityDiagnostics(
|
|
ISystemContext context,
|
|
SessionDiagnosticsData diagnostics,
|
|
SessionSecurityDiagnosticsDataType[] sessionArray,
|
|
int index)
|
|
{
|
|
// get the latest snapshot.
|
|
Variant value = default;
|
|
|
|
ServiceResult result = diagnostics.SecurityUpdateCallback(
|
|
SystemContext,
|
|
diagnostics.SecurityValue.Variable,
|
|
ref value);
|
|
|
|
SessionSecurityDiagnosticsDataType newValue = value.GetStructure<SessionSecurityDiagnosticsDataType>();
|
|
|
|
sessionArray[index] = newValue;
|
|
|
|
if ((context != null) && (sessionArray?[index] != null))
|
|
{
|
|
FilterOutUnAuthorized(sessionArray, newValue.SessionId, context, index);
|
|
}
|
|
|
|
// check for changes.
|
|
if (Utils.IsEqual(newValue, diagnostics.SecurityValue.Value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
diagnostics.SecurityValue.Error = null;
|
|
|
|
// check for bad value.
|
|
if (ServiceResult.IsNotBad(result) && newValue == null)
|
|
{
|
|
result = StatusCodes.BadOutOfService;
|
|
}
|
|
|
|
// check for bad result.
|
|
if (ServiceResult.IsBad(result))
|
|
{
|
|
diagnostics.SecurityValue.Error = result;
|
|
newValue = null!;
|
|
}
|
|
|
|
// update the value.
|
|
diagnostics.SecurityValue.Value = newValue!;
|
|
diagnostics.SecurityValue.Timestamp = DateTime.UtcNow;
|
|
|
|
// notify any monitored items.
|
|
diagnostics.SecurityValue.ChangesComplete(SystemContext);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the subscription diagnostics summary structure.
|
|
/// </summary>
|
|
private bool UpdateSubscriptionDiagnostics(
|
|
ISystemContext context,
|
|
SubscriptionDiagnosticsData diagnostics,
|
|
SubscriptionDiagnosticsDataType[] subscriptionArray,
|
|
int index)
|
|
{
|
|
// get the latest snapshot.
|
|
Variant value = default;
|
|
|
|
ServiceResult result = diagnostics.UpdateCallback(
|
|
SystemContext,
|
|
diagnostics.Value.Variable,
|
|
ref value);
|
|
|
|
SubscriptionDiagnosticsDataType newValue = value.GetStructure<SubscriptionDiagnosticsDataType>();
|
|
|
|
subscriptionArray[index] = newValue;
|
|
|
|
if ((context != null) && (subscriptionArray?[index] != null))
|
|
{
|
|
FilterOutUnAuthorized(subscriptionArray, newValue.SessionId, context, index);
|
|
}
|
|
|
|
// check for changes.
|
|
if (Utils.IsEqual(newValue, diagnostics.Value.Value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
diagnostics.Value.Error = null;
|
|
|
|
// check for bad value.
|
|
if (ServiceResult.IsNotBad(result) && newValue == null)
|
|
{
|
|
result = StatusCodes.BadOutOfService;
|
|
}
|
|
|
|
// check for bad result.
|
|
if (ServiceResult.IsBad(result))
|
|
{
|
|
diagnostics.Value.Error = result;
|
|
newValue = null!;
|
|
}
|
|
|
|
// update the value.
|
|
diagnostics.Value.Value = newValue!;
|
|
diagnostics.Value.Timestamp = DateTime.UtcNow;
|
|
|
|
// notify any monitored items.
|
|
diagnostics.Value.ChangesComplete(SystemContext);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Filter out the members which correspond to users that are not allowed to see their contents
|
|
/// Current user is allowed to read its data, together with users which have permissions
|
|
/// </summary>
|
|
/// <typeparam name="T"></typeparam>
|
|
private static void FilterOutUnAuthorized<T>(
|
|
IList<T> list,
|
|
NodeId sessionId,
|
|
ISystemContext context,
|
|
int index)
|
|
{
|
|
NodeId curSession = (context as ISessionSystemContext)?.SessionId ?? default;
|
|
if ((sessionId != curSession) &&
|
|
!HasApplicationSecureAdminAccess(context))
|
|
{
|
|
list[index] = default!;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set custom role permissions for desired node
|
|
/// </summary>
|
|
protected ServiceResult OnReadUserRolePermissions(
|
|
ISystemContext context,
|
|
NodeState node,
|
|
ref ArrayOf<RolePermissionType> value)
|
|
{
|
|
bool adminUser;
|
|
|
|
if ((node.NodeId == VariableIds.Server_ServerDiagnostics_ServerDiagnosticsSummary) ||
|
|
(node.NodeId == VariableIds.Server_ServerDiagnostics_SubscriptionDiagnosticsArray))
|
|
{
|
|
adminUser = HasApplicationSecureAdminAccess(context);
|
|
}
|
|
else
|
|
{
|
|
// allow Session to see own session diagnostics
|
|
NodeId curSession = (context as ISessionSystemContext)?.SessionId ?? default;
|
|
adminUser = node.NodeId == curSession || HasApplicationSecureAdminAccess(context);
|
|
}
|
|
|
|
if (adminUser)
|
|
{
|
|
IEnumerable<RolePermissionType> rolePermissionTypes =
|
|
from roleId in s_kWellKnownRoles
|
|
select new RolePermissionType
|
|
{
|
|
RoleId = roleId,
|
|
Permissions = (uint)(
|
|
PermissionType.Browse |
|
|
PermissionType.Read |
|
|
PermissionType.ReadRolePermissions)
|
|
};
|
|
|
|
value = [.. rolePermissionTypes];
|
|
}
|
|
else
|
|
{
|
|
IEnumerable<RolePermissionType> rolePermissionTypes =
|
|
from roleId in s_kWellKnownRoles
|
|
select new RolePermissionType
|
|
{
|
|
RoleId = roleId,
|
|
Permissions = (uint)PermissionType.None
|
|
};
|
|
|
|
value = [.. rolePermissionTypes];
|
|
}
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Does a scan before the diagnostics are read.
|
|
/// </summary>
|
|
protected void OnBeforeReadDiagnostics(
|
|
ISystemContext context,
|
|
BaseVariableValue variable,
|
|
NodeState component)
|
|
{
|
|
lock (m_diagnosticsLock)
|
|
{
|
|
if (!DiagnosticsEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!m_forceDiagnosticsScan &&
|
|
m_timeProvider.GetElapsedTime(m_lastDiagnosticsScanTimestamp) <
|
|
TimeSpan.FromSeconds(1))
|
|
{
|
|
return;
|
|
}
|
|
|
|
DoScan(true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Does a scan before the diagnostics are read.
|
|
/// </summary>
|
|
protected ServiceResult OnReadDiagnosticsArray(
|
|
ISystemContext context,
|
|
NodeState node,
|
|
ref Variant value)
|
|
{
|
|
lock (m_diagnosticsLock)
|
|
{
|
|
if (!DiagnosticsEnabled)
|
|
{
|
|
return StatusCodes.BadOutOfService;
|
|
}
|
|
|
|
if (!m_forceDiagnosticsScan &&
|
|
m_timeProvider.GetElapsedTime(m_lastDiagnosticsScanTimestamp) <
|
|
TimeSpan.FromSeconds(1))
|
|
{
|
|
// diagnostic nodes already scanned.
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
if (node.NodeId ==
|
|
VariableIds
|
|
.Server_ServerDiagnostics_SessionsDiagnosticsSummary_SessionDiagnosticsArray)
|
|
{
|
|
// read session diagnostics.
|
|
var sessionArray = new SessionDiagnosticsDataType[m_sessions.Count];
|
|
|
|
for (int ii = 0; ii < m_sessions.Count; ii++)
|
|
{
|
|
SessionDiagnosticsData diagnostics = m_sessions[ii];
|
|
UpdateSessionDiagnostics(context, diagnostics, sessionArray, ii);
|
|
}
|
|
|
|
value = Variant.FromStructure(sessionArray.Where(s => s != null).ToArrayOf());
|
|
}
|
|
else if (node.NodeId ==
|
|
VariableIds.Server_ServerDiagnostics_SessionsDiagnosticsSummary_SessionSecurityDiagnosticsArray)
|
|
{
|
|
// read session security diagnostics.
|
|
var sessionSecurityArray = new SessionSecurityDiagnosticsDataType[m_sessions
|
|
.Count];
|
|
|
|
for (int ii = 0; ii < m_sessions.Count; ii++)
|
|
{
|
|
UpdateSessionSecurityDiagnostics(
|
|
context,
|
|
m_sessions[ii],
|
|
sessionSecurityArray,
|
|
ii);
|
|
}
|
|
value = Variant.FromStructure(sessionSecurityArray.Where(s => s != null).ToArrayOf());
|
|
}
|
|
else if (node.NodeId == VariableIds
|
|
.Server_ServerDiagnostics_SubscriptionDiagnosticsArray)
|
|
{
|
|
// read subscription diagnostics.
|
|
var subscriptionArray = new SubscriptionDiagnosticsDataType[m_subscriptions
|
|
.Count];
|
|
|
|
for (int ii = 0; ii < m_subscriptions.Count; ii++)
|
|
{
|
|
UpdateSubscriptionDiagnostics(
|
|
context,
|
|
m_subscriptions[ii],
|
|
subscriptionArray,
|
|
ii);
|
|
}
|
|
value = Variant.FromStructure(subscriptionArray.Where(s => s != null).ToArrayOf());
|
|
}
|
|
|
|
return ServiceResult.Good;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determine if the impersonated user has admin access.
|
|
/// </summary>
|
|
/// <exception cref="ServiceResultException"/>
|
|
private static bool HasApplicationSecureAdminAccess(ISystemContext context)
|
|
{
|
|
if (context is SessionSystemContext { OperationContext: OperationContext operationContext } session)
|
|
{
|
|
if (operationContext.ChannelContext?.EndpointDescription?.SecurityMode !=
|
|
MessageSecurityMode.SignAndEncrypt)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return session?.UserIdentity?.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_SecurityAdmin) == true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reports notifications for any monitored diagnostic nodes.
|
|
/// </summary>
|
|
private void DoScan(object? alwaysUpdateArrays)
|
|
{
|
|
try
|
|
{
|
|
lock (m_diagnosticsLock)
|
|
{
|
|
if (!DiagnosticsEnabled || m_doScanBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
m_doScanBusy = true;
|
|
|
|
m_lastDiagnosticsScanTimestamp = m_timeProvider.GetTimestamp();
|
|
m_forceDiagnosticsScan = false;
|
|
|
|
// update server diagnostics.
|
|
UpdateServerDiagnosticsSummary();
|
|
|
|
// update session diagnostics.
|
|
bool sessionsChanged = alwaysUpdateArrays != null;
|
|
var sessionArray = new SessionDiagnosticsDataType[m_sessions.Count];
|
|
|
|
for (int ii = 0; ii < m_sessions.Count; ii++)
|
|
{
|
|
SessionDiagnosticsData diagnostics = m_sessions[ii];
|
|
|
|
if (UpdateSessionDiagnostics(null!, diagnostics, sessionArray, ii))
|
|
{
|
|
sessionsChanged = true;
|
|
}
|
|
}
|
|
|
|
// check of the session diagnostics array node needs to be updated.
|
|
SessionDiagnosticsArrayState sessionsNode = FindPredefinedNode<SessionDiagnosticsArrayState>(
|
|
VariableIds.Server_ServerDiagnostics_SessionsDiagnosticsSummary_SessionDiagnosticsArray);
|
|
|
|
if (sessionsNode != null &&
|
|
(
|
|
sessionsNode.Value.IsNull ||
|
|
StatusCode.IsBad(sessionsNode.StatusCode) ||
|
|
sessionsChanged))
|
|
{
|
|
sessionsNode.Value = sessionArray;
|
|
sessionsNode.ClearChangeMasks(SystemContext, false);
|
|
}
|
|
|
|
bool sessionsSecurityChanged = alwaysUpdateArrays != null;
|
|
var sessionSecurityArray = new SessionSecurityDiagnosticsDataType[m_sessions.Count];
|
|
|
|
for (int ii = 0; ii < m_sessions.Count; ii++)
|
|
{
|
|
SessionDiagnosticsData diagnostics = m_sessions[ii];
|
|
|
|
if (UpdateSessionSecurityDiagnostics(
|
|
null!,
|
|
diagnostics,
|
|
sessionSecurityArray,
|
|
ii))
|
|
{
|
|
sessionsSecurityChanged = true;
|
|
}
|
|
}
|
|
|
|
// check of the array node needs to be updated.
|
|
SessionSecurityDiagnosticsArrayState sessionsSecurityNode
|
|
= FindPredefinedNode<SessionSecurityDiagnosticsArrayState>(
|
|
VariableIds
|
|
.Server_ServerDiagnostics_SessionsDiagnosticsSummary_SessionSecurityDiagnosticsArray);
|
|
|
|
if (sessionsSecurityNode != null &&
|
|
(
|
|
sessionsSecurityNode.Value.IsNull ||
|
|
StatusCode.IsBad(sessionsSecurityNode.StatusCode) ||
|
|
sessionsSecurityChanged)
|
|
)
|
|
{
|
|
sessionsSecurityNode.Value = sessionSecurityArray;
|
|
sessionsSecurityNode.ClearChangeMasks(SystemContext, false);
|
|
}
|
|
|
|
bool subscriptionsChanged = alwaysUpdateArrays != null;
|
|
var subscriptionArray = new SubscriptionDiagnosticsDataType[m_subscriptions
|
|
.Count];
|
|
|
|
for (int ii = 0; ii < m_subscriptions.Count; ii++)
|
|
{
|
|
SubscriptionDiagnosticsData diagnostics = m_subscriptions[ii];
|
|
|
|
if (UpdateSubscriptionDiagnostics(
|
|
null!,
|
|
diagnostics,
|
|
subscriptionArray,
|
|
ii))
|
|
{
|
|
subscriptionsChanged = true;
|
|
}
|
|
}
|
|
|
|
// check of the subscription node needs to be updated.
|
|
SubscriptionDiagnosticsArrayState subscriptionsNode
|
|
= FindPredefinedNode<SubscriptionDiagnosticsArrayState>(
|
|
VariableIds.Server_ServerDiagnostics_SubscriptionDiagnosticsArray);
|
|
|
|
if (subscriptionsNode != null &&
|
|
(
|
|
subscriptionsNode.Value.IsNull ||
|
|
StatusCode.IsBad(subscriptionsNode.StatusCode) ||
|
|
subscriptionsChanged))
|
|
{
|
|
subscriptionsNode.Value = subscriptionArray;
|
|
subscriptionsNode.ClearChangeMasks(SystemContext, false);
|
|
}
|
|
|
|
for (int ii = 0; ii < m_sessions.Count; ii++)
|
|
{
|
|
SessionDiagnosticsData diagnostics = m_sessions[ii];
|
|
var subscriptionDiagnosticsArray
|
|
= new List<SubscriptionDiagnosticsDataType>();
|
|
|
|
NodeId sessionId = diagnostics.Summary.NodeId;
|
|
|
|
for (int jj = 0; jj < m_subscriptions.Count; jj++)
|
|
{
|
|
SubscriptionDiagnosticsData subscriptionDiagnostics
|
|
= m_subscriptions[jj];
|
|
|
|
if (subscriptionDiagnostics.Value.Value == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (subscriptionDiagnostics.Value.Value.SessionId != sessionId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
subscriptionDiagnosticsArray.Add(
|
|
subscriptionDiagnostics.Value.Value);
|
|
}
|
|
|
|
// update session subscription array.
|
|
subscriptionsNode = (SubscriptionDiagnosticsArrayState?)
|
|
diagnostics.Summary.CreateChild(
|
|
SystemContext,
|
|
QualifiedName.From(BrowseNames.SubscriptionDiagnosticsArray))!;
|
|
|
|
if (subscriptionsNode != null &&
|
|
(
|
|
subscriptionsNode.Value.IsNull ||
|
|
StatusCode.IsBad(subscriptionsNode.StatusCode) ||
|
|
subscriptionsChanged))
|
|
{
|
|
subscriptionsNode.Value = [.. subscriptionDiagnosticsArray];
|
|
subscriptionsNode.ClearChangeMasks(SystemContext, false);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
m_doScanBusy = false;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
m_logger.LogError(e, "Unexpected error during diagnostics scan.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates the view description passed to a browse request (throws on error).
|
|
/// </summary>
|
|
protected override void ValidateViewDescription(
|
|
ServerSystemContext context,
|
|
ViewDescription view)
|
|
{
|
|
// always accept all views so the root nodes appear in the view.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called after creating a MonitoredItem.
|
|
/// </summary>
|
|
/// <param name="context">The context.</param>
|
|
/// <param name="handle">The handle for the node.</param>
|
|
/// <param name="monitoredItem">The monitored item.</param>
|
|
protected override void OnMonitoredItemCreated(
|
|
ServerSystemContext context,
|
|
NodeHandle handle,
|
|
ISampledDataChangeMonitoredItem monitoredItem)
|
|
{
|
|
// check if the variable needs to be sampled.
|
|
if (monitoredItem.AttributeId == Attributes.Value &&
|
|
handle.Node is BaseVariableState variable &&
|
|
variable.MinimumSamplingInterval > 0)
|
|
{
|
|
CreateSampledItem(monitoredItem.SamplingInterval, monitoredItem);
|
|
}
|
|
|
|
// check if diagnostics collection needs to be turned one.
|
|
if (IsDiagnosticsNode(handle.Node))
|
|
{
|
|
monitoredItem.AlwaysReportUpdates = IsDiagnosticsStructureNode(handle.Node);
|
|
|
|
if (monitoredItem.MonitoringMode != MonitoringMode.Disabled)
|
|
{
|
|
Interlocked.Increment(ref m_diagnosticsMonitoringCount);
|
|
|
|
m_diagnosticsScanTimer ??= m_timeProvider.CreateTimer(
|
|
DoScan,
|
|
null,
|
|
TimeSpan.FromMilliseconds(1000),
|
|
TimeSpan.FromMilliseconds(1000));
|
|
|
|
DoScan(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called after deleting a MonitoredItem.
|
|
/// </summary>
|
|
/// <param name="context">The context.</param>
|
|
/// <param name="handle">The handle for the node.</param>
|
|
/// <param name="monitoredItem">The monitored item.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
protected override ValueTask OnMonitoredItemDeletedAsync(
|
|
ServerSystemContext context,
|
|
NodeHandle handle,
|
|
ISampledDataChangeMonitoredItem monitoredItem,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// check if diagnostics collection needs to be turned off.
|
|
if (IsDiagnosticsNode(handle.Node) &&
|
|
monitoredItem.MonitoringMode != MonitoringMode.Disabled)
|
|
{
|
|
Interlocked.Decrement(ref m_diagnosticsMonitoringCount);
|
|
|
|
if (m_diagnosticsMonitoringCount == 0 && m_diagnosticsScanTimer != null)
|
|
{
|
|
m_diagnosticsScanTimer.Dispose();
|
|
m_diagnosticsScanTimer = null;
|
|
}
|
|
|
|
if (m_diagnosticsScanTimer != null)
|
|
{
|
|
DoScan(true);
|
|
}
|
|
}
|
|
|
|
// check if sampling needs to be turned off.
|
|
if (monitoredItem.AttributeId == Attributes.Value &&
|
|
handle.Node is BaseVariableState variable &&
|
|
variable.MinimumSamplingInterval > 0)
|
|
{
|
|
DeleteSampledItem(monitoredItem);
|
|
}
|
|
|
|
return new ValueTask();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called after changing the MonitoringMode for a MonitoredItem.
|
|
/// </summary>
|
|
/// <param name="context">The context.</param>
|
|
/// <param name="handle">The handle for the node.</param>
|
|
/// <param name="monitoredItem">The monitored item.</param>
|
|
/// <param name="previousMode">The previous monitoring mode.</param>
|
|
/// <param name="monitoringMode">The current monitoring mode.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
protected override ValueTask OnMonitoringModeChangedAsync(
|
|
ServerSystemContext context,
|
|
NodeHandle handle,
|
|
ISampledDataChangeMonitoredItem monitoredItem,
|
|
MonitoringMode previousMode,
|
|
MonitoringMode monitoringMode,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (previousMode != MonitoringMode.Disabled)
|
|
{
|
|
Interlocked.Decrement(ref m_diagnosticsMonitoringCount);
|
|
}
|
|
|
|
if (monitoringMode != MonitoringMode.Disabled)
|
|
{
|
|
Interlocked.Increment(ref m_diagnosticsMonitoringCount);
|
|
}
|
|
|
|
if (m_diagnosticsMonitoringCount == 0 && m_diagnosticsScanTimer != null)
|
|
{
|
|
m_diagnosticsScanTimer.Dispose();
|
|
m_diagnosticsScanTimer = null;
|
|
}
|
|
else if (m_diagnosticsScanTimer != null)
|
|
{
|
|
m_diagnosticsScanTimer = m_timeProvider.CreateTimer(
|
|
DoScan,
|
|
null,
|
|
TimeSpan.FromMilliseconds(1000),
|
|
TimeSpan.FromMilliseconds(1000));
|
|
}
|
|
return default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores the callback information for a session diagnostics structures.
|
|
/// </summary>
|
|
private class SessionDiagnosticsData
|
|
{
|
|
public SessionDiagnosticsData(
|
|
SessionDiagnosticsObjectState summary,
|
|
SessionDiagnosticsVariableValue value,
|
|
NodeValueSimpleEventHandler updateCallback,
|
|
SessionSecurityDiagnosticsValue securityValue,
|
|
NodeValueSimpleEventHandler securityUpdateCallback)
|
|
{
|
|
Summary = summary;
|
|
Value = value;
|
|
UpdateCallback = updateCallback;
|
|
SecurityValue = securityValue;
|
|
SecurityUpdateCallback = securityUpdateCallback;
|
|
}
|
|
|
|
public SessionDiagnosticsObjectState Summary;
|
|
public SessionDiagnosticsVariableValue Value;
|
|
public NodeValueSimpleEventHandler UpdateCallback;
|
|
public SessionSecurityDiagnosticsValue SecurityValue;
|
|
public NodeValueSimpleEventHandler SecurityUpdateCallback;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores the callback information for a subscription diagnostics structure.
|
|
/// </summary>
|
|
private class SubscriptionDiagnosticsData
|
|
{
|
|
public SubscriptionDiagnosticsData(
|
|
SubscriptionDiagnosticsValue value,
|
|
NodeValueSimpleEventHandler updateCallback)
|
|
{
|
|
Value = value;
|
|
UpdateCallback = updateCallback;
|
|
}
|
|
|
|
public SubscriptionDiagnosticsValue Value;
|
|
public NodeValueSimpleEventHandler UpdateCallback;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new sampled item.
|
|
/// </summary>
|
|
private void CreateSampledItem(
|
|
double samplingInterval,
|
|
ISampledDataChangeMonitoredItem monitoredItem)
|
|
{
|
|
m_sampledItems.TryAdd(monitoredItem.Id, monitoredItem);
|
|
|
|
m_samplingTimer ??= m_timeProvider.CreateTimer(
|
|
DoSample,
|
|
null,
|
|
TimeSpan.FromMilliseconds(m_minimumSamplingInterval),
|
|
TimeSpan.FromMilliseconds(m_minimumSamplingInterval));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a sampled item.
|
|
/// </summary>
|
|
private void DeleteSampledItem(ISampledDataChangeMonitoredItem monitoredItem)
|
|
{
|
|
m_sampledItems.TryRemove(monitoredItem.Id, out _);
|
|
|
|
if (m_sampledItems.IsEmpty && m_samplingTimer != null)
|
|
{
|
|
m_samplingTimer.Dispose();
|
|
m_samplingTimer = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Polls each monitored item which requires sample.
|
|
/// </summary>
|
|
private void DoSample(object? state)
|
|
{
|
|
try
|
|
{
|
|
lock (m_diagnosticsLock)
|
|
{
|
|
foreach (KeyValuePair<uint, ISampledDataChangeMonitoredItem> kvp in m_sampledItems)
|
|
{
|
|
ISampledDataChangeMonitoredItem monitoredItem = kvp.Value;
|
|
|
|
// get the handle.
|
|
if (monitoredItem.ManagerHandle is not NodeHandle handle)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// check if it is time to sample.
|
|
if (monitoredItem.TimeToNextSample > m_minimumSamplingInterval)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// read the value.
|
|
var value = new DataValue();
|
|
|
|
ServiceResult error = handle.Node.ReadAttribute(
|
|
SystemContext,
|
|
monitoredItem.AttributeId,
|
|
monitoredItem.IndexRange,
|
|
monitoredItem.DataEncoding,
|
|
ref value);
|
|
|
|
if (ServiceResult.IsBad(error))
|
|
{
|
|
value = new DataValue(error.StatusCode);
|
|
}
|
|
|
|
value = value.WithServerTimestamp(DateTime.UtcNow);
|
|
|
|
// queue the value.
|
|
monitoredItem.QueueValue(value, error);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
m_logger.LogError(e, "Unexpected error during diagnostics scan.");
|
|
}
|
|
}
|
|
|
|
private readonly SemaphoreSlim m_modifyAddressSpaceSemaphoreSlim = new(1, 1);
|
|
private readonly object m_diagnosticsLock = new();
|
|
private readonly TimeProvider m_timeProvider;
|
|
private readonly ushort m_namespaceIndex;
|
|
private uint m_lastUsedId;
|
|
private ITimer? m_diagnosticsScanTimer;
|
|
private int m_diagnosticsMonitoringCount;
|
|
private bool m_doScanBusy;
|
|
private readonly bool m_durableSubscriptionsEnabled;
|
|
private long m_lastDiagnosticsScanTimestamp;
|
|
private bool m_forceDiagnosticsScan = true;
|
|
private ServerDiagnosticsSummaryValue? m_serverDiagnostics;
|
|
private NodeValueSimpleEventHandler? m_serverDiagnosticsCallback;
|
|
private readonly List<SessionDiagnosticsData> m_sessions;
|
|
private readonly List<SubscriptionDiagnosticsData> m_subscriptions;
|
|
private NodeId m_serverLockHolder;
|
|
private ITimer? m_samplingTimer;
|
|
private readonly ConcurrentDictionary<uint, ISampledDataChangeMonitoredItem> m_sampledItems;
|
|
private readonly double m_minimumSamplingInterval;
|
|
private HistoryServerCapabilitiesState? m_historyCapabilities;
|
|
|
|
/// <summary>
|
|
/// Aggregates the per-node capabilities advertised by every
|
|
/// registered historian provider into a single union view used
|
|
/// to populate the server-wide <c>HistoryServerCapabilities</c>
|
|
/// flags.
|
|
/// </summary>
|
|
private async ValueTask<Historian.HistorianNodeCapabilities?> RollUpHistorianCapabilitiesAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (Server is not Historian.IHistorianRegistryProvider registry)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
IReadOnlyCollection<Historian.IHistorianProvider> providers = registry.HistorianRegistry.Providers;
|
|
if (providers.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var rolled = new Historian.HistorianNodeCapabilities
|
|
{
|
|
ReadRawData = true,
|
|
ReadModifiedData = false,
|
|
ReadAtTime = false,
|
|
ReadProcessedData = false
|
|
};
|
|
bool insertData = false;
|
|
bool replaceData = false;
|
|
bool updateData = false;
|
|
bool deleteRaw = false;
|
|
bool deleteAtTime = false;
|
|
bool insertAnnotation = false;
|
|
bool serverTimestampSupported = false;
|
|
|
|
foreach (Historian.IHistorianProvider provider in providers)
|
|
{
|
|
Historian.HistorianNodeCapabilities caps;
|
|
try
|
|
{
|
|
caps = await provider.GetCapabilitiesAsync(NodeId.Null, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
continue;
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
insertData |= caps.InsertData;
|
|
replaceData |= caps.ReplaceData;
|
|
updateData |= caps.UpdateData;
|
|
deleteRaw |= caps.DeleteRaw;
|
|
deleteAtTime |= caps.DeleteAtTime;
|
|
insertAnnotation |= caps.InsertAnnotation;
|
|
serverTimestampSupported |= caps.ServerTimestampSupported;
|
|
}
|
|
|
|
return rolled with
|
|
{
|
|
InsertData = insertData,
|
|
ReplaceData = replaceData,
|
|
UpdateData = updateData,
|
|
DeleteRaw = deleteRaw,
|
|
DeleteAtTime = deleteAtTime,
|
|
InsertAnnotation = insertAnnotation,
|
|
ServerTimestampSupported = serverTimestampSupported,
|
|
};
|
|
}
|
|
|
|
private static readonly NodeId[] s_kWellKnownRoles =
|
|
[
|
|
ObjectIds.WellKnownRole_Anonymous,
|
|
ObjectIds.WellKnownRole_AuthenticatedUser,
|
|
ObjectIds.WellKnownRole_TrustedApplication,
|
|
ObjectIds.WellKnownRole_ConfigureAdmin,
|
|
ObjectIds.WellKnownRole_Engineer,
|
|
ObjectIds.WellKnownRole_Observer,
|
|
ObjectIds.WellKnownRole_Operator,
|
|
ObjectIds.WellKnownRole_SecurityAdmin,
|
|
ObjectIds.WellKnownRole_Supervisor
|
|
];
|
|
}
|
|
}
|