refactor: migrate OPC UA server SDK 2.0 compilation and boundaries
This commit is contained in:
@@ -27,6 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// </remarks>
|
||||
internal static class AdvertisedAggregateFunctions
|
||||
{
|
||||
private static readonly object SyncGate = new();
|
||||
/// <summary>Every standard aggregate-function NodeId the SDK's nodeset knows (<c>ObjectIds.AggregateFunction_*</c>).</summary>
|
||||
private static readonly NodeId[] StandardAggregateIds = typeof(ObjectIds)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
@@ -56,10 +57,10 @@ internal static class AdvertisedAggregateFunctions
|
||||
/// <returns>The advertised NodeIds, or <c>null</c> when the diagnostics node manager or folder is not available.</returns>
|
||||
public static IReadOnlyList<NodeId>? Apply(IServerInternal server, IReadOnlySet<HistoryAggregateType> served)
|
||||
{
|
||||
if (server?.DiagnosticsNodeManager is not CustomNodeManager2 diagnostics) return null;
|
||||
if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return null;
|
||||
|
||||
var advertised = new List<NodeId>();
|
||||
lock (diagnostics.Lock)
|
||||
lock (SyncGate)
|
||||
{
|
||||
var folder = diagnostics.FindPredefinedNode<BaseObjectState>(Folder);
|
||||
if (folder is null) return null;
|
||||
|
||||
@@ -51,5 +51,5 @@ internal static class AggregateFunctionMap
|
||||
/// <param name="aggregateNodeId">The per-node aggregate-function NodeId from the request.</param>
|
||||
/// <returns>The mapped aggregate, or <c>null</c>.</returns>
|
||||
public static HistoryAggregateType? Map(NodeId? aggregateNodeId) =>
|
||||
aggregateNodeId is not null && ByNodeId.TryGetValue(aggregateNodeId, out var aggregate) ? aggregate : null;
|
||||
aggregateNodeId is { } nodeId && ByNodeId.TryGetValue(nodeId, out var aggregate) ? aggregate : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Preserves the application's established equipment-write conversions across SDK 2.0's
|
||||
/// new pre-handler type validation. The owning callback remains the authorization and shape gate.
|
||||
/// </summary>
|
||||
internal sealed class EquipmentVariableState(NodeState? parent, object ownerLock) : BaseDataVariableState(parent)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override ServiceResult WriteValueAttribute(ISystemContext context, NumericRange indexRange,
|
||||
Variant value, StatusCode statusCode, DateTimeUtc sourceTimestamp)
|
||||
{
|
||||
lock (ownerLock)
|
||||
{
|
||||
if (OnWriteValue is not { } write) return base.WriteValueAttribute(context, indexRange, value, statusCode, sourceTimestamp);
|
||||
|
||||
if ((AccessLevel & AccessLevels.CurrentWrite) == 0) return StatusCodes.BadNotWritable;
|
||||
var userAccess = UserAccessLevel;
|
||||
OnReadUserAccessLevel?.Invoke(context, this, ref userAccess);
|
||||
if ((userAccess & AccessLevels.CurrentWrite) == 0) return StatusCodes.BadUserAccessDenied;
|
||||
|
||||
// Match BaseVariableState.CheckStatusAndTimestampWritable in exact tagged SDK 2.0.1.
|
||||
if (!statusCode.Equals(StatusCodes.Good, StatusCodeComparison.CodeBitsOnly))
|
||||
{
|
||||
if ((AccessLevel & AccessLevels.StatusWrite) == 0) return StatusCodes.BadWriteNotSupported;
|
||||
if ((userAccess & AccessLevels.StatusWrite) == 0) return StatusCodes.BadUserAccessDenied;
|
||||
}
|
||||
if (sourceTimestamp != DateTimeUtc.MinValue)
|
||||
{
|
||||
if ((AccessLevel & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadWriteNotSupported;
|
||||
if ((userAccess & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadUserAccessDenied;
|
||||
}
|
||||
|
||||
var result = write(context, this, indexRange, default, ref value, ref statusCode, ref sourceTimestamp);
|
||||
if (ServiceResult.IsBad(result)) return result;
|
||||
|
||||
// Value's first-touch setter can reset status. Commit in this order, under the shared lock.
|
||||
Value = value;
|
||||
StatusCode = statusCode;
|
||||
Timestamp = sourceTimestamp == DateTimeUtc.MinValue ? DateTimeUtc.Now : sourceTimestamp;
|
||||
ChangeMasks |= NodeStateChangeMasks.Value;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ServiceResult ReadValueAttribute(ISystemContext context, NumericRange indexRange,
|
||||
QualifiedName dataEncoding, ref Variant value, ref DateTimeUtc sourceTimestamp)
|
||||
{
|
||||
// Subscription samplers read the node directly, bypassing the manager's service lock.
|
||||
lock (ownerLock) return base.ReadValueAttribute(context, indexRange, dataEncoding, ref value, ref sourceTimestamp);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ internal static class HistoryBounds
|
||||
/// <returns>The placeholder.</returns>
|
||||
public static DataValueSnapshot NotFound(DateTime atUtc)
|
||||
{
|
||||
var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound, atUtc, atUtc);
|
||||
var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound.Code, atUtc, atUtc);
|
||||
Placeholders.AddOrUpdate(placeholder, PlaceholderMark);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
@@ -43,8 +44,8 @@ internal interface IHistoryContinuationStore
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IHistoryContinuationStore"/> backed by the OPC UA SDK's per-session
|
||||
/// history-continuation store (<see cref="ISession.SaveHistoryContinuationPoint"/> /
|
||||
/// <see cref="ISession.RestoreHistoryContinuationPoint"/>). Using the SDK store gives us, for free:
|
||||
/// history-continuation store (<see cref="ISessionContinuationPoints.SaveHistory"/> /
|
||||
/// <see cref="ISessionContinuationPoints.RestoreHistory"/>). Using the SDK store gives us, for free:
|
||||
/// <list type="bullet">
|
||||
/// <item>per-session lifecycle — points are disposed when the session closes, so a client that
|
||||
/// disconnects mid-page can never leak resume state;</item>
|
||||
@@ -75,7 +76,7 @@ internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStor
|
||||
// A fresh Guid is the opaque point: 16 bytes, collision-free, and the SDK keys its session slot by
|
||||
// it. The SDK enforces ServerConfiguration.MaxHistoryContinuationPoints with oldest-eviction.
|
||||
var id = Guid.NewGuid();
|
||||
session.SaveHistoryContinuationPoint(id, state);
|
||||
session.ContinuationPoints.SaveHistory(new SessionHistoryPoint(id, state));
|
||||
return id.ToByteArray();
|
||||
}
|
||||
|
||||
@@ -85,13 +86,22 @@ internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStor
|
||||
if (session is null) return null;
|
||||
// RestoreHistoryContinuationPoint REMOVES the slot (single-use) and returns null for an unknown /
|
||||
// malformed (non-16-byte) point — exactly the "miss ⇒ BadContinuationPointInvalid" contract.
|
||||
return session.RestoreHistoryContinuationPoint(continuationPoint) as HistoryContinuationState;
|
||||
using var point = session.ContinuationPoints.RestoreHistory(ByteString.From(continuationPoint));
|
||||
return (point as SessionHistoryPoint)?.State;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Release(ISession? session, byte[] continuationPoint) =>
|
||||
// Restoring removes the slot; we discard the value. Null session / unknown point ⇒ no-op.
|
||||
session?.RestoreHistoryContinuationPoint(continuationPoint);
|
||||
session?.ContinuationPoints.ReleaseHistory(ByteString.From(continuationPoint));
|
||||
private sealed class SessionHistoryPoint(Guid id, HistoryContinuationState state) : IHistoryContinuationPoint
|
||||
{
|
||||
public Guid Id { get; } = id;
|
||||
public HistoryContinuationState State { get; } = state;
|
||||
// The immutable resume state contains managed value data only. No handles or owned resources.
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -143,9 +143,8 @@ internal static class HistoryPaging
|
||||
if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return false;
|
||||
try
|
||||
{
|
||||
lock (diagnostics.Lock)
|
||||
{
|
||||
var capabilities = diagnostics.GetDefaultHistoryCapabilities();
|
||||
var capabilities = diagnostics.GetDefaultHistoryCapabilitiesAsync().AsTask().GetAwaiter().GetResult();
|
||||
if (capabilities?.MaxReturnDataValues is not { } property) return false;
|
||||
property.Value = (uint)Math.Max(0, maxRawValuesPerRead);
|
||||
property.ClearChangeMasks(diagnostics.SystemContext, false);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Identity;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
@@ -172,7 +173,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
private StandardServer? _server;
|
||||
private ImpersonateEventHandler? _impersonateHandler;
|
||||
private readonly List<IUserTokenAuthenticator> _identityAuthenticators = [];
|
||||
|
||||
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
|
||||
/// <param name="options">The host configuration options.</param>
|
||||
@@ -222,30 +223,38 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_options.PublicHostname, _options.OpcUaPort);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to <see cref="SessionManager.ImpersonateUser"/> after the SDK has its
|
||||
/// <c>SessionManager</c> ready (only after <c>_application.Start</c>). Anonymous tokens
|
||||
/// pass through; UserName tokens hit <see cref="IOpcUaUserAuthenticator"/> and, on
|
||||
/// success, attach a <see cref="UserIdentity"/> with the mapped role-set to the session
|
||||
/// so downstream ACL checks can read it via <c>OperationContext.UserIdentity</c>.
|
||||
///
|
||||
/// The SDK calls <c>ImpersonateUser</c> synchronously off the session-activation
|
||||
/// thread, so the authenticator's async work is run via <c>GetAwaiter().GetResult()</c>.
|
||||
/// LDAP binds typically complete in <100 ms; if a backing store ever gets that slow
|
||||
/// it should not block the OPC UA stack — callers must enforce their own timeouts inside
|
||||
/// <see cref="IOpcUaUserAuthenticator.AuthenticateUserNameAsync"/>.
|
||||
/// </summary>
|
||||
private void AttachUserAuthenticator()
|
||||
{
|
||||
var sessionManager = _server?.CurrentInstance?.SessionManager;
|
||||
if (sessionManager is null)
|
||||
var registry = _server?.CurrentInstance?.IdentityRegistry
|
||||
?? throw new InvalidOperationException("OPC UA identity registry unavailable after start.");
|
||||
foreach (var type in new[] { UserTokenType.Anonymous, UserTokenType.UserName })
|
||||
{
|
||||
_logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
|
||||
return;
|
||||
var adapter = new ApplicationTokenAuthenticator(this, type);
|
||||
registry.Register(adapter);
|
||||
_identityAuthenticators.Add(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
_impersonateHandler = OnImpersonateUser;
|
||||
sessionManager.ImpersonateUser += _impersonateHandler;
|
||||
private sealed class ApplicationTokenAuthenticator(OpcUaApplicationHost host, UserTokenType tokenType)
|
||||
: IUserTokenAuthenticator
|
||||
{
|
||||
public UserTokenType TokenType => tokenType;
|
||||
public string? IssuedTokenProfileUri => null;
|
||||
|
||||
public async ValueTask<AuthenticationResult> AuthenticateAsync(AuthenticationContext context, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var args = new ImpersonateEventArgs(context.TokenHandler, context.UserTokenPolicy, context.EndpointDescription);
|
||||
var peer = (host._server as OtOpcUaSdkServer)?.ActivationPeerAddress;
|
||||
// Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port.
|
||||
await HandleImpersonationAsync(host._userAuthenticator, args, host._logger, host._options.AllowAnonymous,
|
||||
peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct).ConfigureAwait(false);
|
||||
return ServiceResult.IsBad(args.IdentityValidationError)
|
||||
? AuthenticationResult.Reject(args.IdentityValidationError)
|
||||
: args.Identity is { } identity
|
||||
? AuthenticationResult.Accept(identity)
|
||||
: AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -298,46 +307,18 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
|
||||
uris.Add(peer);
|
||||
}
|
||||
serverObject.ServerArray.Value = uris.ToArray();
|
||||
serverObject.ServerArray!.Value = (ArrayOf<string>)uris;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
|
||||
HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous,
|
||||
// Only a UserName activation consults the endpoint (the per-client API-key miss budget, #751), so
|
||||
// the reflection walk is skipped for anonymous / X509.
|
||||
args.NewIdentity is UserNameIdentityToken ? ResolveRemoteEndpoint(session) : null);
|
||||
|
||||
/// <summary>The remote endpoint of the client activating <paramref name="session"/>, or null when unknown.</summary>
|
||||
/// <param name="session">The session being activated.</param>
|
||||
/// <returns>The client's IP endpoint, or null.</returns>
|
||||
/// <remarks>
|
||||
/// Read from the session's secure channel — the SDK raises impersonation without the request's channel
|
||||
/// context (<c>SecureChannelContext.Current</c> is obsolete in 1.5.378). A client that re-activates over
|
||||
/// a NEW channel after its old one closed resolves to null and is accounted process-wide only.
|
||||
/// </remarks>
|
||||
private System.Net.IPEndPoint? ResolveRemoteEndpoint(ISession? session) =>
|
||||
_server is OtOpcUaSdkServer sdk ? sdk.TryResolveRemoteEndpoint(session?.SecureChannelId) : null;
|
||||
|
||||
/// <summary>
|
||||
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
|
||||
/// the full SDK. Side-effects are confined to mutating <see cref="ImpersonateEventArgs"/>
|
||||
/// and logging.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378).</b>
|
||||
/// The SDK raises this handler SYNCHRONOUSLY — it is a plain <c>void</c> delegate, and there is no
|
||||
/// async impersonation callback in this SDK version. Worse, <c>SessionManager.ActivateSessionAsync</c>
|
||||
/// invokes it while holding a single <c>SessionManager</c>-wide event lock (<c>m_eventLock</c>), so a
|
||||
/// handler that blocks does not merely stall one SDK request thread — it SERIALIZES every session
|
||||
/// activation server-wide (Anonymous and X509 included, since even those paths must take the lock to
|
||||
/// raise the handler). Consequently the <see cref="IOpcUaUserAuthenticator"/> block-bridge below
|
||||
/// (<c>GetAwaiter().GetResult()</c> under <see cref="CancellationToken.None"/>) is irreducible: the
|
||||
/// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded
|
||||
/// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live
|
||||
/// inside the authenticator — NOT here. (The LDAP authenticator was replaced with
|
||||
/// <c>ApiKeyOpcUaUserAuthenticator</c>, whose verification does no network I/O at all, so the
|
||||
/// bound/concurrency/circuit machinery that used to live there is gone rather than relocated.)
|
||||
/// The registry path awaits authentication outside the SDK event lock. This synchronous observation
|
||||
/// seam remains for existing handler tests; production invokes the asynchronous implementation directly.
|
||||
/// </remarks>
|
||||
/// <param name="authenticator">The user authenticator to validate credentials.</param>
|
||||
/// <param name="args">The impersonation event arguments to process.</param>
|
||||
@@ -351,14 +332,21 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
ILogger logger,
|
||||
bool allowAnonymous = true,
|
||||
System.Net.IPEndPoint? remoteEndpoint = null)
|
||||
=> HandleImpersonationAsync(authenticator, args, logger, allowAnonymous, remoteEndpoint)
|
||||
.AsTask().GetAwaiter().GetResult();
|
||||
|
||||
private static async ValueTask HandleImpersonationAsync(
|
||||
IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger,
|
||||
bool allowAnonymous = true, System.Net.IPEndPoint? remoteEndpoint = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
|
||||
if (args.UserIdentityTokenHandler.Token is AnonymousIdentityToken anonymousToken)
|
||||
{
|
||||
HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken);
|
||||
await HandleAnonymousImpersonationAsync(authenticator, args, logger, allowAnonymous, anonymousToken, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.NewIdentity is not UserNameIdentityToken token)
|
||||
if (args.UserIdentityTokenHandler.Token is not UserNameIdentityToken token)
|
||||
{
|
||||
// X509 tokens — let the SDK's default validation stand.
|
||||
return;
|
||||
@@ -369,29 +357,27 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
// 1.5.378 exposes DecryptedPassword as raw bytes (was string); UserName token passwords
|
||||
// are UTF-8 on the wire.
|
||||
var decryptedBytes = token.DecryptedPassword;
|
||||
var decryptedBytes = (args.UserIdentityTokenHandler as UserNameIdentityTokenHandler)?.DecryptedPassword;
|
||||
password = decryptedBytes is null ? string.Empty : Encoding.UTF8.GetString(decryptedBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"UserName token decryption failed");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "UserName token decryption failed");
|
||||
return;
|
||||
}
|
||||
|
||||
OpcUaUserAuthResult result;
|
||||
try
|
||||
{
|
||||
result = authenticator
|
||||
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
result = await authenticator
|
||||
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Authentication failed");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Authentication failed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,8 +385,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
|
||||
token.UserName, result.Error);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
result.Error ?? "Invalid credentials");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Invalid credentials");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -421,28 +406,25 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// <see cref="RoleCarryingUserIdentity"/> the UserName path uses, so every downstream gate reads
|
||||
/// it identically and there is no anonymous-shaped branch in any of them.
|
||||
/// </remarks>
|
||||
private static void HandleAnonymousImpersonation(
|
||||
private static async ValueTask HandleAnonymousImpersonationAsync(
|
||||
IOpcUaUserAuthenticator authenticator,
|
||||
ImpersonateEventArgs args,
|
||||
ILogger logger,
|
||||
bool allowAnonymous,
|
||||
AnonymousIdentityToken token)
|
||||
AnonymousIdentityToken token, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!allowAnonymous)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Anonymous access is disabled on this server.");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled on this server.");
|
||||
return;
|
||||
}
|
||||
|
||||
OpcUaUserAuthResult result;
|
||||
try
|
||||
{
|
||||
// Same synchronous bridge, and for the same irreducible reason, as the UserName path above.
|
||||
result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
result = await authenticator.AuthenticateAnonymousAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -450,16 +432,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
// to the SDK default identity, because that would silently ADMIT it — the previous
|
||||
// behaviour — at exactly the moment we could not determine what it is allowed to do.
|
||||
logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Anonymous access is unavailable.");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
result.Error ?? "Anonymous access is denied.");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Anonymous access is denied.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -500,18 +480,12 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
|
||||
var serverConfig = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
||||
BaseAddresses = [$"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa"],
|
||||
};
|
||||
ApplyRequestQuotas(serverConfig, _options.Quotas);
|
||||
|
||||
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
|
||||
{
|
||||
serverConfig.SecurityPolicies.Add(policy);
|
||||
}
|
||||
foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous))
|
||||
{
|
||||
serverConfig.UserTokenPolicies.Add(token);
|
||||
}
|
||||
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
|
||||
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
@@ -661,21 +635,20 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
// An explicit SDK stop may already have halted CurrentInstance; still continue disposal.
|
||||
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
|
||||
sessionManager.ImpersonateUser -= _impersonateHandler;
|
||||
if (_server?.CurrentInstance?.IdentityRegistry is { } registry)
|
||||
foreach (var authenticator in _identityAuthenticators) registry.Unregister(authenticator);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw");
|
||||
_logger.LogWarning(ex, "OpcUaApplicationHost: detaching identity authenticators threw");
|
||||
}
|
||||
_impersonateHandler = null;
|
||||
_identityAuthenticators.Clear();
|
||||
|
||||
if (_application is not null)
|
||||
if (_application is { } application)
|
||||
{
|
||||
// 1.5.378: ApplicationInstance.Stop() → StopAsync().
|
||||
try { await _application.StopAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
||||
_application = null;
|
||||
try { await application.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,17 +22,40 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
/// <summary>Initializes a new instance of the <see cref="OtOpcUaSdkServer"/> class.</summary>
|
||||
/// <param name="nodeManagerLogger">Logger handed to the created <see cref="OtOpcUaNodeManager"/>;
|
||||
/// <c>null</c> leaves it on a no-op logger.</param>
|
||||
public OtOpcUaSdkServer(ILogger<OtOpcUaNodeManager>? nodeManagerLogger = null) => _nodeManagerLogger = nodeManagerLogger;
|
||||
public OtOpcUaSdkServer(ILogger<OtOpcUaNodeManager>? nodeManagerLogger = null)
|
||||
: base(DefaultTelemetry.Create(_ => { })) => _nodeManagerLogger = nodeManagerLogger;
|
||||
|
||||
/// <summary>
|
||||
/// The client's remote IP endpoint for the secure channel <paramref name="secureChannelId"/>, or null when it
|
||||
/// cannot be resolved (lmxopcua#751). Best effort — see <see cref="SecureChannelRemoteEndpoint"/>; the SDK
|
||||
/// exposes the listeners only to a subclass, which is why this lives here.
|
||||
/// </summary>
|
||||
/// <param name="secureChannelId">The global secure channel id a request arrived on.</param>
|
||||
/// <returns>The remote endpoint, or null.</returns>
|
||||
public System.Net.IPEndPoint? TryResolveRemoteEndpoint(string? secureChannelId) =>
|
||||
SecureChannelRemoteEndpoint.Resolve(TransportListeners, secureChannelId);
|
||||
private readonly AsyncLocal<System.Net.IPAddress?> _activationPeer = new();
|
||||
|
||||
/// <summary>The transport-observed peer of the current activation request, when available.</summary>
|
||||
internal System.Net.IPAddress? ActivationPeerAddress => _activationPeer.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<ActivateSessionResponse> ActivateSessionAsync(
|
||||
SecureChannelContext secureChannelContext,
|
||||
RequestHeader? requestHeader,
|
||||
SignatureData? clientSignature,
|
||||
ArrayOf<SignedSoftwareCertificate> clientSoftwareCertificates,
|
||||
ArrayOf<string> localeIds,
|
||||
ExtensionObject userIdentityToken,
|
||||
SignatureData? userTokenSignature,
|
||||
RequestLifetime requestLifetime)
|
||||
{
|
||||
// AuthenticationContext does not carry the request peer in 2.0.1. Keep the public
|
||||
// transport evidence in this activation's async flow, including reactivation on a new channel.
|
||||
var previous = _activationPeer.Value;
|
||||
_activationPeer.Value = secureChannelContext.PeerAddress;
|
||||
try
|
||||
{
|
||||
return await base.ActivateSessionAsync(secureChannelContext, requestHeader, clientSignature,
|
||||
clientSoftwareCertificates, localeIds, userIdentityToken, userTokenSignature, requestLifetime)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_activationPeer.Value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The custom node manager once <c>StartAsync</c> has called
|
||||
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
|
||||
@@ -230,15 +253,15 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
// this point that is the Null default (no history aggregates); the Host's SetHistorianDataSource
|
||||
// re-syncs to the configured backend's set right after start.
|
||||
/// <inheritdoc />
|
||||
protected override AggregateManager CreateAggregateManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
protected override async ValueTask<AggregateManager> CreateAggregateManagerAsync(
|
||||
IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manager = base.CreateAggregateManager(server, configuration);
|
||||
var manager = await base.CreateAggregateManagerAsync(server, configuration, cancellationToken).ConfigureAwait(false);
|
||||
// Pin the SDK manager's default AggregateConfiguration (what live AggregateFilter subscriptions compute under
|
||||
// and what the server advertises) to OUR defaults. Opc.Ua.Server 1.5.378.182 flipped the SDK default of
|
||||
// TreatUncertainAsBad to true; history-read aggregates (Sql.Common evaluators) are fixed at false, so the live
|
||||
// and historical paths must agree and stay behaviour-neutral across the SDK hop (lmxopcua#782).
|
||||
var defaults = manager.GetDefaultConfiguration(null) ?? new AggregateConfiguration();
|
||||
var defaults = manager.GetDefaultConfiguration(NodeId.Null) ?? new AggregateConfiguration();
|
||||
defaults.TreatUncertainAsBad = ServerAggregateConfiguration.TreatUncertainAsBad;
|
||||
defaults.PercentDataBad = ServerAggregateConfiguration.PercentDataBad;
|
||||
defaults.PercentDataGood = ServerAggregateConfiguration.PercentDataGood;
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// Uses <see cref="IServerInternal.ServerObject"/> (a <see cref="ServerObjectState"/>) and
|
||||
/// its <see cref="ServerObjectState.ServiceLevel"/> child variable, which the SDK populates
|
||||
/// automatically during <see cref="DiagnosticsNodeManager"/> initialization. Writes are
|
||||
/// guarded by <see cref="IServerInternal.DiagnosticsLock"/> so concurrent diagnostics scans
|
||||
/// guarded by <see cref="IServerContext.UpdateServerDiagnostics"/> so concurrent diagnostics scans
|
||||
/// from the SDK don't fight with our update.
|
||||
/// </summary>
|
||||
public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
|
||||
@@ -44,13 +44,13 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
|
||||
|
||||
try
|
||||
{
|
||||
lock (_serverInternal.DiagnosticsLock)
|
||||
_serverInternal.UpdateServerDiagnostics(_ =>
|
||||
{
|
||||
node.Value = serviceLevel;
|
||||
node.Timestamp = DateTime.UtcNow;
|
||||
node.StatusCode = StatusCodes.Good;
|
||||
node.ClearChangeMasks(_serverInternal.DefaultSystemContext, includeChildren: false);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>Copies supported SDK payloads at the server's CLR value boundary.</summary>
|
||||
internal static class SdkValueBoundary
|
||||
{
|
||||
/// <summary>Build a detached Variant for an existing supported scalar or one-dimensional array.</summary>
|
||||
internal static Variant FromClr(object? value, bool byteArray = false)
|
||||
{
|
||||
if (value is null) return Variant.Null;
|
||||
if (value is Array array)
|
||||
{
|
||||
if (array.Rank != 1) throw new ArgumentException("Only one-dimensional values are supported.", nameof(value));
|
||||
value = array.Clone();
|
||||
}
|
||||
if (byteArray && value is byte[] bytes) return Variant.From((ArrayOf<byte>)bytes);
|
||||
if (VariantHelper.TryCastFrom(value, out var variant)) return variant;
|
||||
throw new ArgumentException("Unsupported OPC UA value type.", nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>Restore existing CLR payload types and detach mutable arrays.</summary>
|
||||
internal static object? ToClr(Variant value) => value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch
|
||||
{
|
||||
ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
|
||||
DateTimeUtc time => time.ToDateTime(),
|
||||
Uuid id => (Guid)id,
|
||||
DateTimeUtc[] times => times.Select(x => x.ToDateTime()).ToArray(),
|
||||
Uuid[] ids => ids.Select(x => (Guid)x).ToArray(),
|
||||
ByteString[] strings => strings.Select(x => x.IsNull ? null : x.ToArray()).ToArray(),
|
||||
Array array => array.Clone(),
|
||||
var scalar => scalar,
|
||||
};
|
||||
}
|
||||
@@ -297,7 +297,7 @@ public sealed class DataPlaneAuthorizer
|
||||
// The s= identifier, read once: it is both the cache key and the raw material the scope is split
|
||||
// from, and pulling it up here absorbs what used to be TryBuildScope's own guard. A NodeId that
|
||||
// carries no string addresses no ACL scope at all, so it can never be answered from the cache.
|
||||
if (nodeId?.Identifier is not string identifier || identifier.Length == 0)
|
||||
if (!nodeId.TryGetValue(out string identifier) || identifier.Length == 0)
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
"the node id carries no string identifier, so it addresses no ACL scope", cached);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the client's remote IP endpoint for a secure channel, so session activation can account
|
||||
/// pre-authentication API-key misses per client (lmxopcua#751).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ⚠️ <b>The OPC UA SDK (1.5.378) does not expose the client address on any public surface</b> — not
|
||||
/// on <c>ISession</c>, <c>SecureChannelContext</c>, <c>ImpersonateEventArgs</c> nor the transport
|
||||
/// listener. The TCP listener holds it privately: <c>TcpTransportListener.m_channels</c> (channel id
|
||||
/// → channel) → the channel's non-public <c>Socket</c> → the public
|
||||
/// <c>IMessageSocket.RemoteEndpoint</c>. This class walks that path by reflection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is strictly <b>best effort and fail-soft</b>: any missing member (an SDK upgrade renamed it), an
|
||||
/// unknown channel or a closed socket yields <see langword="null"/>, and a caller treats null as
|
||||
/// "address unknown" — the per-endpoint budget is skipped and the process-wide one still applies. It
|
||||
/// never throws. An over-the-wire test (<c>RemoteEndpointResolutionTests</c>) pins that it resolves on
|
||||
/// the SDK version in use, so an upgrade that breaks the path fails a test instead of silently
|
||||
/// degrading to process-wide accounting.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SecureChannelRemoteEndpoint
|
||||
{
|
||||
private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the remote endpoint of the secure channel <paramref name="secureChannelId"/> among
|
||||
/// <paramref name="listeners"/>.
|
||||
/// </summary>
|
||||
/// <param name="listeners">The server's transport listeners.</param>
|
||||
/// <param name="secureChannelId">The global secure channel id (<c>{ListenerId}-{ChannelId}</c>).</param>
|
||||
/// <returns>The client's IP endpoint (IPv4-mapped addresses normalised to IPv4), or null when unknown.</returns>
|
||||
public static IPEndPoint? Resolve(IEnumerable<ITransportListener>? listeners, string? secureChannelId)
|
||||
{
|
||||
if (listeners is null || string.IsNullOrEmpty(secureChannelId)) return null;
|
||||
try
|
||||
{
|
||||
foreach (var listener in listeners)
|
||||
{
|
||||
if (listener is null) continue;
|
||||
var listenerId = listener.ListenerId;
|
||||
if (string.IsNullOrEmpty(listenerId)
|
||||
|| !secureChannelId.StartsWith(listenerId + "-", StringComparison.Ordinal)
|
||||
|| !uint.TryParse(secureChannelId.AsSpan(listenerId.Length + 1), out var channelId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (FindChannel(listener, channelId) is { } channel && RemoteEndpointOf(channel) is { } endpoint)
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail-soft by contract: the caller falls back to process-wide accounting.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The listener's channel with id <paramref name="channelId"/>, read from its private channel map.</summary>
|
||||
private static object? FindChannel(ITransportListener listener, uint channelId)
|
||||
{
|
||||
var field = FindField(listener.GetType(), "m_channels");
|
||||
if (field?.GetValue(listener) is not IDictionary channels) return null;
|
||||
return channels.Contains(channelId) ? channels[channelId] : null;
|
||||
}
|
||||
|
||||
/// <summary>The channel's socket's remote endpoint, as an <see cref="IPEndPoint"/>.</summary>
|
||||
private static IPEndPoint? RemoteEndpointOf(object channel)
|
||||
{
|
||||
var socketProperty = FindProperty(channel.GetType(), "Socket");
|
||||
var socket = socketProperty?.GetValue(channel);
|
||||
if (socket is null) return null;
|
||||
var remote = FindProperty(socket.GetType(), "RemoteEndpoint")?.GetValue(socket) as IPEndPoint;
|
||||
if (remote is null) return null;
|
||||
return remote.Address.IsIPv4MappedToIPv6 ? new IPEndPoint(remote.Address.MapToIPv4(), remote.Port) : remote;
|
||||
}
|
||||
|
||||
private static FieldInfo? FindField(Type? type, string name)
|
||||
{
|
||||
for (; type is not null; type = type.BaseType)
|
||||
{
|
||||
if (type.GetField(name, Instance | BindingFlags.DeclaredOnly) is { } field) return field;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PropertyInfo? FindProperty(Type? type, string name)
|
||||
{
|
||||
for (; type is not null; type = type.BaseType)
|
||||
{
|
||||
if (type.GetProperty(name, Instance | BindingFlags.DeclaredOnly) is { } property) return property;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server"/>
|
||||
<!-- All OPC UA packages aligned at the central 1.5.378.182 (Opc.Ua.Server 1.5.378.182 is now
|
||||
published). The prior VersionOverride pinned Opc.Ua.Configuration/Core to 1.5.374.126 to match
|
||||
the older Server, but the published Host unified Core to 378 anyway (Driver.OpcUaClient needs
|
||||
it) → Server 1.5.374 loaded against Core 1.5.378 and threw TypeLoadException(INodeIdFactory).
|
||||
The 1.5.378 SDK is a sync→async + ITelemetryContext break, ported in OpcUaApplicationHost. -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user