refactor: migrate OPC UA server SDK 2.0 compilation and boundaries

This commit is contained in:
Joseph Doherty
2026-11-10 10:04:14 -05:00
parent d2076e75c6
commit 9af1a824b3
14 changed files with 416 additions and 451 deletions
@@ -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 &lt;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>