mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-24 19:13:02 +08:00
Drive the solution to zero warnings / zero errors with analyzers on, promote the consistently-fixed rules to error, and lock in coverage on the only line of new logic.
443 lines
18 KiB
C#
443 lines
18 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.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Opc.Ua.Server.AliasNames
|
|
{
|
|
/// <summary>
|
|
/// Standalone OPC UA Part 17 (Alias Names) node manager. Builds an
|
|
/// address-space tree of <c>AliasNameCategoryType</c> instances from
|
|
/// an <see cref="IAliasNameStore"/>'s
|
|
/// <see cref="IAliasNameStore.RootCategories"/>, wires the typed
|
|
/// <c>OnCallAsync</c> handlers of the generated
|
|
/// <c>FindAliasMethodState</c>/<c>FindAliasVerboseMethodState</c>/<c>AddAliasesToCategoryMethodState</c>/<c>DeleteAliasesFromCategoryMethodState</c>
|
|
/// children, and (when configured) registers the store with the
|
|
/// server-wide <see cref="IAliasNameStoreRegistry"/> so that the
|
|
/// standard well-known <c>Aliases (i=23470)</c> /
|
|
/// <c>TagVariables (i=23479)</c> / <c>Topics (i=23488)</c> nodes also
|
|
/// dispatch through it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Apps opt in by adding this manager to their server's node-manager
|
|
/// list. The manager owns a single namespace
|
|
/// (<see cref="AliasNameNodeManagerOptions.NamespaceUri"/>) under
|
|
/// which it creates its category and alias instances; standard
|
|
/// well-known categories owned by <c>DiagnosticsNodeManager</c> are
|
|
/// not duplicated — only their methods are wired through the
|
|
/// registry.
|
|
/// </para>
|
|
/// <para>
|
|
/// All four Part 17 methods are exposed only when the corresponding
|
|
/// <see cref="AliasNameCapabilities"/> flag is set on the
|
|
/// <see cref="AliasNameCategoryDescriptor"/>. Mutating methods
|
|
/// (<c>AddAliasesToCategory</c>/<c>DeleteAliasesFromCategory</c>)
|
|
/// default to requiring the <c>SecurityAdmin</c> role on a
|
|
/// <c>SignAndEncrypt</c> channel; override
|
|
/// <see cref="AliasNameNodeManagerOptions.RequireSecurityAdminForMutations"/>
|
|
/// to opt out.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class AliasNameNodeManager : AsyncCustomNodeManager
|
|
{
|
|
/// <summary>
|
|
/// Initializes a new instance.
|
|
/// </summary>
|
|
/// <param name="server">The server internal interface.</param>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
/// <param name="store">The pluggable alias-name backend; the
|
|
/// manager builds its address space from
|
|
/// <see cref="IAliasNameStore.RootCategories"/> and dispatches
|
|
/// every Part 17 method through it.</param>
|
|
/// <param name="options">Optional tunables; defaults applied
|
|
/// when <c>null</c>.</param>
|
|
public AliasNameNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
IAliasNameStore store,
|
|
AliasNameNodeManagerOptions? options = null)
|
|
: base(server, configuration, ResolveNamespaceUri(options))
|
|
{
|
|
Store = store ?? throw new ArgumentNullException(nameof(store));
|
|
Options = options ?? new AliasNameNodeManagerOptions();
|
|
m_aliasLogger = server.Telemetry.CreateLogger<AliasNameNodeManager>();
|
|
m_registry = ResolveServerRegistry(server);
|
|
m_localCategoryDispatcher = new AliasNameStoreRegistry();
|
|
m_localCategoryDispatcher.Register(Store);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The backing <see cref="IAliasNameStore"/>.
|
|
/// </summary>
|
|
public IAliasNameStore Store { get; }
|
|
|
|
/// <summary>
|
|
/// The tunables in use.
|
|
/// </summary>
|
|
public AliasNameNodeManagerOptions Options { get; }
|
|
|
|
/// <inheritdoc/>
|
|
public override NodeId New(ISystemContext context, NodeState node)
|
|
{
|
|
// Preserve any caller-assigned NodeId that already lives in our
|
|
// namespace; otherwise mint a sequential numeric id.
|
|
if (!node.NodeId.IsNull &&
|
|
node.NodeId.NamespaceIndex == NamespaceIndex)
|
|
{
|
|
return node.NodeId;
|
|
}
|
|
uint id = Utils.IncrementIdentifier(ref m_nextNodeId);
|
|
return new NodeId(id, NamespaceIndex);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override async ValueTask CreateAddressSpaceAsync(
|
|
IDictionary<NodeId, IList<IReference>> externalReferences,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await base.CreateAddressSpaceAsync(externalReferences, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
foreach (AliasNameCategoryDescriptor root in Store.RootCategories)
|
|
{
|
|
AliasNameCategoryState rootState = BuildCategoryTree(root);
|
|
m_rootCategoryStates[root.NodeId] = rootState;
|
|
|
|
if (Options.LinkToStandardAliasesObject)
|
|
{
|
|
AddExternalReference(
|
|
ObjectIds.Aliases,
|
|
ReferenceTypeIds.Organizes,
|
|
isInverse: false,
|
|
rootState.NodeId,
|
|
externalReferences);
|
|
rootState.AddReference(
|
|
ReferenceTypeIds.Organizes,
|
|
isInverse: true,
|
|
ObjectIds.Aliases);
|
|
}
|
|
|
|
await AddPredefinedNodeAsync(
|
|
SystemContext, rootState, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
if (Options.RegisterWithServerRegistry && m_registry != null)
|
|
{
|
|
try
|
|
{
|
|
m_registry.Register(Store);
|
|
m_registeredWithServer = true;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
m_aliasLogger.LogWarning(ex,
|
|
"AliasNameStore could not be registered with " +
|
|
"the server-wide registry; standard well-known " +
|
|
"Aliases methods will not dispatch through it.");
|
|
}
|
|
}
|
|
|
|
Store.Changed += OnStoreChanged;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
Store.Changed -= OnStoreChanged;
|
|
if (m_registeredWithServer && m_registry != null)
|
|
{
|
|
m_registry.Unregister(Store);
|
|
m_registeredWithServer = false;
|
|
}
|
|
m_localCategoryDispatcher.Dispose();
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively builds an <see cref="AliasNameCategoryState"/>
|
|
/// instance for the supplied descriptor — including any optional
|
|
/// children declared by its capabilities and any nested
|
|
/// sub-categories — and wires every method's typed
|
|
/// <c>OnCallAsync</c> handler to the dispatcher.
|
|
/// </summary>
|
|
protected virtual AliasNameCategoryState BuildCategoryTree(
|
|
AliasNameCategoryDescriptor descriptor)
|
|
{
|
|
// CreateInstanceOf... returns a typed instance with the
|
|
// mandatory FindAlias child already created. We then override
|
|
// the identity attributes and add the optional Part 17
|
|
// children we have been asked to expose.
|
|
AliasNameCategoryState category =
|
|
SystemContext.CreateInstanceOfAliasNameCategoryType();
|
|
category.NodeId = descriptor.NodeId;
|
|
category.BrowseName = descriptor.BrowseName;
|
|
category.DisplayName = new LocalizedText(descriptor.BrowseName.Name);
|
|
category.SymbolicName = descriptor.BrowseName.Name!;
|
|
category.TypeDefinitionId = ObjectTypeIds.AliasNameCategoryType;
|
|
category.ReferenceTypeId = ReferenceTypeIds.Organizes;
|
|
|
|
AliasNameCapabilities cap = descriptor.Capabilities;
|
|
if ((cap & AliasNameCapabilities.FindAliasVerbose) != 0)
|
|
{
|
|
category.AddFindAliasVerbose(SystemContext);
|
|
}
|
|
if ((cap & AliasNameCapabilities.LastChange) != 0)
|
|
{
|
|
category.AddLastChange(SystemContext);
|
|
}
|
|
if ((cap & AliasNameCapabilities.AddAliasesToCategory) != 0)
|
|
{
|
|
category.AddAddAliasesToCategory(SystemContext);
|
|
}
|
|
if ((cap & AliasNameCapabilities.DeleteAliasesFromCategory) != 0)
|
|
{
|
|
category.AddDeleteAliasesFromCategory(SystemContext);
|
|
}
|
|
|
|
foreach (AliasNameCategoryDescriptor child in descriptor.SubCategories)
|
|
{
|
|
AliasNameCategoryState childState = BuildCategoryTree(child);
|
|
childState.ReferenceTypeId = ReferenceTypeIds.Organizes;
|
|
category.AddChild(childState);
|
|
}
|
|
|
|
// Auto-assign NodeIds to every child not pre-pinned in our
|
|
// namespace.
|
|
category.AssignNodeIds(SystemContext, []);
|
|
|
|
// Wire method handlers (after node ids are stable).
|
|
WireCategoryHandlers(descriptor.NodeId, category);
|
|
|
|
// Seed LastChange.
|
|
category.LastChange?.Value
|
|
= Store.GetLastChange(descriptor.NodeId) ?? 0u;
|
|
|
|
return category;
|
|
}
|
|
|
|
private void WireCategoryHandlers(NodeId categoryId, AliasNameCategoryState category)
|
|
{
|
|
category.FindAlias?.OnCallAsync = (ctx, method, objId, pattern, refType, ct) =>
|
|
AliasNameMethodDispatcher.FindAliasAsync(
|
|
m_localCategoryDispatcher,
|
|
Server.TypeTree,
|
|
objId.IsNull ? categoryId : objId,
|
|
pattern,
|
|
refType,
|
|
ct);
|
|
category.FindAliasVerbose?.OnCallAsync = (ctx, method, objId, pattern, refType, ct) =>
|
|
AliasNameMethodDispatcher.FindAliasVerboseAsync(
|
|
m_localCategoryDispatcher,
|
|
Server.TypeTree,
|
|
objId.IsNull ? categoryId : objId,
|
|
pattern,
|
|
refType,
|
|
ct);
|
|
category.AddAliasesToCategory?.OnCallAsync =
|
|
(ctx, method, objId, names, targets, servers, refType, ct) =>
|
|
DispatchAddAsync(ctx, categoryId, objId, names, targets, servers, refType, ct);
|
|
category.DeleteAliasesFromCategory?.OnCallAsync =
|
|
(ctx, method, objId, names, targets, ct) =>
|
|
DispatchDeleteAsync(ctx, categoryId, objId, names, targets, ct);
|
|
|
|
// Recurse into sub-category children (already wired through
|
|
// BuildCategoryTree, but their state objects sit on this
|
|
// node's children list — wire those too).
|
|
var children = new List<BaseInstanceState>();
|
|
category.GetChildren(SystemContext, children);
|
|
foreach (BaseInstanceState child in children)
|
|
{
|
|
if (child is AliasNameCategoryState childCategory &&
|
|
!ReferenceEquals(childCategory, category))
|
|
{
|
|
WireCategoryHandlers(childCategory.NodeId, childCategory);
|
|
}
|
|
}
|
|
}
|
|
|
|
private ValueTask<AddAliasesToCategoryMethodStateResult> DispatchAddAsync(
|
|
ISystemContext context,
|
|
NodeId fallbackCategoryId,
|
|
NodeId objectId,
|
|
ArrayOf<string> aliasNames,
|
|
ArrayOf<ExpandedNodeId> targetNodes,
|
|
ArrayOf<string> targetServers,
|
|
NodeId targetReferenceType,
|
|
CancellationToken ct)
|
|
{
|
|
if (Options.RequireSecurityAdminForMutations &&
|
|
!HasSecureAdminAccess(context))
|
|
{
|
|
return new ValueTask<AddAliasesToCategoryMethodStateResult>(
|
|
new AddAliasesToCategoryMethodStateResult
|
|
{
|
|
ServiceResult = new ServiceResult(StatusCodes.BadUserAccessDenied),
|
|
ErrorCodes = default
|
|
});
|
|
}
|
|
return AliasNameMethodDispatcher.AddAliasesAsync(
|
|
m_localCategoryDispatcher,
|
|
objectId.IsNull ? fallbackCategoryId : objectId,
|
|
aliasNames,
|
|
targetNodes,
|
|
targetServers,
|
|
targetReferenceType,
|
|
ct);
|
|
}
|
|
|
|
private ValueTask<DeleteAliasesFromCategoryMethodStateResult> DispatchDeleteAsync(
|
|
ISystemContext context,
|
|
NodeId fallbackCategoryId,
|
|
NodeId objectId,
|
|
ArrayOf<string> aliasNames,
|
|
ArrayOf<ExpandedNodeId> targetNodes,
|
|
CancellationToken ct)
|
|
{
|
|
if (Options.RequireSecurityAdminForMutations &&
|
|
!HasSecureAdminAccess(context))
|
|
{
|
|
return new ValueTask<DeleteAliasesFromCategoryMethodStateResult>(
|
|
new DeleteAliasesFromCategoryMethodStateResult
|
|
{
|
|
ServiceResult = new ServiceResult(StatusCodes.BadUserAccessDenied),
|
|
ErrorCodes = default
|
|
});
|
|
}
|
|
return AliasNameMethodDispatcher.DeleteAliasesAsync(
|
|
m_localCategoryDispatcher,
|
|
objectId.IsNull ? fallbackCategoryId : objectId,
|
|
aliasNames,
|
|
targetNodes,
|
|
ct);
|
|
}
|
|
|
|
private void OnStoreChanged(object? sender, AliasStoreChangedEventArgs e)
|
|
{
|
|
lock (m_lock)
|
|
{
|
|
if (!m_rootCategoryStates.TryGetValue(e.CategoryId, out AliasNameCategoryState? root))
|
|
{
|
|
root = FindCategoryNode(e.CategoryId);
|
|
}
|
|
if (root?.LastChange != null)
|
|
{
|
|
root.LastChange.Value = e.LastChange;
|
|
root.LastChange.ClearChangeMasks(SystemContext, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private AliasNameCategoryState? FindCategoryNode(NodeId categoryId)
|
|
{
|
|
foreach (AliasNameCategoryState root in m_rootCategoryStates.Values)
|
|
{
|
|
AliasNameCategoryState? found = FindRecursive(root, categoryId);
|
|
if (found != null)
|
|
{
|
|
return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private AliasNameCategoryState? FindRecursive(
|
|
AliasNameCategoryState node,
|
|
NodeId target)
|
|
{
|
|
if (node.NodeId == target)
|
|
{
|
|
return node;
|
|
}
|
|
var children = new List<BaseInstanceState>();
|
|
node.GetChildren(SystemContext, children);
|
|
foreach (BaseInstanceState child in children)
|
|
{
|
|
if (child is AliasNameCategoryState sub)
|
|
{
|
|
AliasNameCategoryState? hit = FindRecursive(sub, target);
|
|
if (hit != null)
|
|
{
|
|
return hit;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static bool HasSecureAdminAccess(ISystemContext context)
|
|
{
|
|
if (context is SessionSystemContext { OperationContext: OperationContext op } session)
|
|
{
|
|
if (op.ChannelContext?.EndpointDescription?.SecurityMode !=
|
|
MessageSecurityMode.SignAndEncrypt)
|
|
{
|
|
return false;
|
|
}
|
|
return session.UserIdentity?.GrantedRoleIds
|
|
.Contains(ObjectIds.WellKnownRole_SecurityAdmin) == true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static string ResolveNamespaceUri(AliasNameNodeManagerOptions? options)
|
|
{
|
|
return options?.NamespaceUri
|
|
?? new AliasNameNodeManagerOptions().NamespaceUri;
|
|
}
|
|
|
|
private static IAliasNameStoreRegistry? ResolveServerRegistry(IServerInternal server)
|
|
{
|
|
return (server as IAliasNameStoreRegistryProvider)?.AliasNameStoreRegistry;
|
|
}
|
|
|
|
private readonly ILogger m_aliasLogger;
|
|
private readonly IAliasNameStoreRegistry? m_registry;
|
|
|
|
/// <summary>
|
|
/// Always-available dispatcher that wraps just this manager's
|
|
/// store so the standalone manager works even when the host
|
|
/// server does not implement IAliasNameStoreRegistryProvider.
|
|
/// </summary>
|
|
private readonly AliasNameStoreRegistry m_localCategoryDispatcher;
|
|
private readonly Dictionary<NodeId, AliasNameCategoryState> m_rootCategoryStates = [];
|
|
private bool m_registeredWithServer;
|
|
private uint m_nextNodeId;
|
|
private readonly Lock m_lock = new();
|
|
}
|
|
}
|