a83bf1fa27
v2-ci / unit-tests (push) Has been cancelled
ConfigurationBinder appends configured collection items to an existing list, so the pre-filled [None, Sign, SignAndEncrypt] default made "drop None" a no-op: every deployment that listed only the secure profiles still advertised an unsigned/unencrypted None endpoint. EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles() applies the unchanged three-profile default only when nothing is configured. A set-but-empty list fails startup validation and BuildSecurityPolicies no longer falls back to a None endpoint on empty input (it throws). Sweep of all options classes found no other non-empty pre-filled collection default. Tests: options binding (indexed, JSON, env forms, default, empty, bad name), a booted-server wire test (no None endpoint, None connection refused with BadSecurityPolicyRejected, secure connection works; default case is the control). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
919 lines
48 KiB
C#
919 lines
48 KiB
C#
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;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
/// <summary>
|
|
/// Transport-security profile served by the OPC UA endpoint. F13b ships the three baseline
|
|
/// profiles defined by docs/security.md; the remaining Aes128/Aes256 variants can be added
|
|
/// later by extending <see cref="OpcUaSecurityProfile.PolicyUri"/>+<see cref="OpcUaSecurityProfile.Mode"/>
|
|
/// — the wiring in <c>BuildConfigurationAsync</c> is profile-agnostic.
|
|
/// </summary>
|
|
public enum OpcUaSecurityProfile
|
|
{
|
|
/// <summary>No signing or encryption. Dev / isolated networks only.</summary>
|
|
None,
|
|
/// <summary>Basic256Sha256 + Sign. Messages signed, payload visible on the wire.</summary>
|
|
Basic256Sha256Sign,
|
|
/// <summary>Basic256Sha256 + SignAndEncrypt. Full transport protection.</summary>
|
|
Basic256Sha256SignAndEncrypt,
|
|
}
|
|
|
|
/// <summary>Configuration options for OPC UA application hosting.</summary>
|
|
public sealed class OpcUaApplicationHostOptions
|
|
{
|
|
/// <summary>Gets or sets the application name (default "OtOpcUa").</summary>
|
|
public string ApplicationName { get; set; } = "OtOpcUa";
|
|
|
|
/// <summary>Gets or sets the application URI (default "urn:OtOpcUa").</summary>
|
|
public string ApplicationUri { get; set; } = "urn:OtOpcUa";
|
|
|
|
/// <summary>Gets or sets the product URI (default "https://zb.com/otopcua").</summary>
|
|
public string ProductUri { get; set; } = "https://zb.com/otopcua";
|
|
|
|
/// <summary>Listening port for the binary endpoint (default 4840).</summary>
|
|
public int OpcUaPort { get; set; } = 4840;
|
|
|
|
/// <summary>Hostname or IP advertised in endpoint descriptions.</summary>
|
|
public string PublicHostname { get; set; } = "0.0.0.0";
|
|
|
|
/// <summary>Application config XML path; when set, loaded instead of building from defaults.</summary>
|
|
public string? ApplicationConfigPath { get; set; }
|
|
|
|
/// <summary>
|
|
/// Root of the application's PKI hierarchy. Sub-stores (<c>own</c>, <c>issuer</c>,
|
|
/// <c>trusted</c>, <c>rejected</c>) are created under this path on first start. Defaults
|
|
/// to "pki" (relative to the host's working directory) to keep dev flows identical to v1.
|
|
/// </summary>
|
|
public string PkiStoreRoot { get; set; } = "pki";
|
|
|
|
/// <summary>
|
|
/// Whether anonymous sessions may connect at all. When <see langword="false"/> the
|
|
/// Anonymous token policy is not advertised AND anonymous activation is rejected, so a client
|
|
/// must present an API key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Defaults to <see langword="true"/> deliberately, which preserves today's behaviour.</b>
|
|
/// Flipping the default would silently deny every client that has no key the moment this
|
|
/// code deploys — including ScadaBridge's Data Connection Layer, which consumes this address
|
|
/// space from another repo. Turning it off is the deployment-time decision that makes keys
|
|
/// mandatory, and it belongs in the rollout once keys have been issued.
|
|
/// </para>
|
|
/// <para>
|
|
/// Note this controls <i>admission</i>, not authority. An admitted anonymous session is a
|
|
/// normal principal (<c>anonymous</c>) whose grants come from <c>NodeAcl</c> rows naming it,
|
|
/// exactly like a key's principal — there is no anonymous bypass branch in the evaluator.
|
|
/// When a wired data-plane authorizer is enabled, per-node ACL checks apply to anonymous
|
|
/// sessions, including reads. Reads denied by the node manager return <c>BadUserAccessDenied</c>.
|
|
/// With the authorizer disabled or not wired, reads retain the legacy behaviour without
|
|
/// per-node ACL enforcement.
|
|
/// </para>
|
|
/// </remarks>
|
|
public bool AllowAnonymous { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Transport-security profiles exposed by the server (<c>OpcUa:EnabledSecurityProfiles</c>); the SDK
|
|
/// publishes one endpoint descriptor per profile and clients choose at session open.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Replace semantics (lmxopcua#879).</b> This is <see langword="null"/> by default and the
|
|
/// three-profile default (<see cref="DefaultSecurityProfiles"/>: <c>None</c> +
|
|
/// <c>Basic256Sha256Sign</c> + <c>Basic256Sha256SignAndEncrypt</c>) is applied by
|
|
/// <see cref="ResolveEnabledSecurityProfiles"/> only when nothing was configured. It must NOT be
|
|
/// pre-filled: <c>ConfigurationBinder</c> appends configured collection items to an existing list
|
|
/// instead of replacing it, so a pre-filled default made it impossible to drop <c>None</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Empty list.</b> A key that is absent, or an array with no items (<c>[]</c> binds to nothing),
|
|
/// means "not configured" and yields the default. A list that is non-null but empty (only
|
|
/// reachable from code, or an env value the binder materialises as an empty list) is a
|
|
/// configuration error: <c>OpcUaApplicationHostOptionsValidator</c> fails startup and
|
|
/// <see cref="ResolveEnabledSecurityProfiles"/> throws. It never falls back to <c>None</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public IList<OpcUaSecurityProfile>? EnabledSecurityProfiles { get; set; }
|
|
|
|
/// <summary>
|
|
/// The profiles applied when <see cref="EnabledSecurityProfiles"/> is not configured — all three
|
|
/// baseline profiles, unchanged from before lmxopcua#879. Production deployments configure the list
|
|
/// explicitly (without <c>None</c>) to drop the unsecured endpoint.
|
|
/// </summary>
|
|
public static IReadOnlyList<OpcUaSecurityProfile> DefaultSecurityProfiles { get; } = new[]
|
|
{
|
|
OpcUaSecurityProfile.None,
|
|
OpcUaSecurityProfile.Basic256Sha256Sign,
|
|
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
|
|
};
|
|
|
|
/// <summary>
|
|
/// The profiles the server actually publishes: the configured list when one was given, otherwise
|
|
/// <see cref="DefaultSecurityProfiles"/>.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">The list was set but is empty.</exception>
|
|
public IReadOnlyList<OpcUaSecurityProfile> ResolveEnabledSecurityProfiles()
|
|
{
|
|
if (EnabledSecurityProfiles is null) return DefaultSecurityProfiles;
|
|
if (EnabledSecurityProfiles.Count == 0)
|
|
throw new InvalidOperationException(
|
|
"OpcUa:EnabledSecurityProfiles is set but empty; list at least one profile or omit the key to use the default.");
|
|
return EnabledSecurityProfiles.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// When true, unknown client certificates are auto-added to the trusted store on first
|
|
/// connection. Convenient for dev; should be false in production (operators promote via
|
|
/// the Admin UI). Has no effect on <c>None</c> endpoints, which don't exchange certs.
|
|
/// </summary>
|
|
public bool AutoAcceptUntrustedClientCertificates { get; set; }
|
|
|
|
/// <summary>
|
|
/// Peer server URIs published in <c>Server.ServerArray</c> after start, in addition to
|
|
/// the local <see cref="ApplicationUri"/>. Empty by default — set this on warm-redundancy
|
|
/// deployments so OPC UA clients can discover the partner endpoint via the standard
|
|
/// Server.ServerArray property (NodeId i=2254). Order does not matter; the local URI
|
|
/// is always element 0.
|
|
/// </summary>
|
|
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
|
|
|
|
/// <summary>
|
|
/// URL of an OPC UA discovery server (LDS / GDS, e.g. <c>opc.tcp://lds-host:4840</c>) this server
|
|
/// registers with via <c>RegisterServer2</c> (<c>OpcUa:DiscoveryServerUrl</c>, lmxopcua#876).
|
|
/// <see langword="null"/> or blank (the default) disables discovery registration entirely.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The SDK's own default is to register every 30 s with an LDS at <c>opc.tcp://localhost:4840</c>, which on
|
|
/// an OtOpcUa node is this server itself (no LDS) — it answered <c>BadServiceUnsupported</c> and the SDK
|
|
/// retried forever. Registration is therefore opt-in.
|
|
/// </remarks>
|
|
public string? DiscoveryServerUrl { get; set; }
|
|
|
|
/// <summary>
|
|
/// Maximum interval in milliseconds between <c>RegisterServer2</c> calls while
|
|
/// <see cref="DiscoveryServerUrl"/> is set (<c>OpcUa:DiscoveryRegistrationIntervalMs</c>; default 30000, the
|
|
/// SDK's own default). Ignored when no discovery server is configured.
|
|
/// </summary>
|
|
public int DiscoveryRegistrationIntervalMs { get; set; } = 30_000;
|
|
|
|
/// <summary>
|
|
/// SDK request-processing and transport quotas (<c>OpcUa:Quotas:*</c>, review 01 P6). The defaults are
|
|
/// exactly the values that used to be hard-coded (5 / 100 / 200 request threads and queue) and the SDK's
|
|
/// own transport defaults, so an absent section changes nothing.
|
|
/// </summary>
|
|
public OpcUaQuotaOptions Quotas { get; set; } = new();
|
|
|
|
/// <summary>
|
|
/// Failed-credential lockout for UserName activations (<c>OpcUa:CredentialLockout:*</c>, lmxopcua#833).
|
|
/// Replaces the SDK's own lockout, which also refused Anonymous; see <see cref="FailedCredentialLockout"/>.
|
|
/// </summary>
|
|
public OpcUaCredentialLockoutOptions CredentialLockout { get; set; } = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <c>OpcUa:Quotas</c> section: the SDK's request-thread / queue ceilings and its transport quotas.
|
|
/// Request quotas carry the former hard-coded defaults; a transport quota left <c>null</c> keeps the SDK
|
|
/// default (<see cref="TransportQuotas"/>'s constructor values).
|
|
/// </summary>
|
|
public sealed class OpcUaQuotaOptions
|
|
{
|
|
/// <summary>Minimum SDK request-processing threads (default 5).</summary>
|
|
public int MinRequestThreadCount { get; set; } = 5;
|
|
|
|
/// <summary>Maximum SDK request-processing threads (default 100).</summary>
|
|
public int MaxRequestThreadCount { get; set; } = 100;
|
|
|
|
/// <summary>Requests the SDK queues before refusing with <c>BadTooManyOperations</c> (default 200) — the
|
|
/// burst ceiling review 01 P6 flagged.</summary>
|
|
public int MaxQueuedRequestCount { get; set; } = 200;
|
|
|
|
/// <summary>Per-operation timeout in milliseconds; <c>null</c> keeps the SDK default.</summary>
|
|
public int? OperationTimeoutMs { get; set; }
|
|
|
|
/// <summary>Largest string the transport decodes; <c>null</c> keeps the SDK default.</summary>
|
|
public int? MaxStringLength { get; set; }
|
|
|
|
/// <summary>Largest byte string the transport decodes; <c>null</c> keeps the SDK default.</summary>
|
|
public int? MaxByteStringLength { get; set; }
|
|
|
|
/// <summary>Largest array the transport decodes; <c>null</c> keeps the SDK default.</summary>
|
|
public int? MaxArrayLength { get; set; }
|
|
|
|
/// <summary>Largest message the transport accepts, in bytes; <c>null</c> keeps the SDK default.</summary>
|
|
public int? MaxMessageSize { get; set; }
|
|
|
|
/// <summary>Transport buffer size in bytes; <c>null</c> keeps the SDK default.</summary>
|
|
public int? MaxBufferSize { get; set; }
|
|
|
|
/// <summary>Secure-channel lifetime in milliseconds; <c>null</c> keeps the SDK default.</summary>
|
|
public int? ChannelLifetimeMs { get; set; }
|
|
|
|
/// <summary>Security-token lifetime in milliseconds; <c>null</c> keeps the SDK default.</summary>
|
|
public int? SecurityTokenLifetimeMs { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Thin facade over the OPC Foundation .NET Standard SDK's application bootstrap.
|
|
/// Owns the <see cref="ApplicationInstance"/> + <see cref="ApplicationConfiguration"/> lifetime,
|
|
/// builds the <see cref="ApplicationConfiguration"/> programmatically (security profiles + user
|
|
/// token policies + PKI stores), installs an SDK identity-registry authenticator before
|
|
/// the <see cref="OtOpcUaSdkServer"/> opens its listeners via
|
|
/// <see cref="IOpcUaUserAuthenticator"/>, and publishes the redundancy peer URIs through
|
|
/// <c>Server.ServerArray</c>. See <c>docs/OpcUaServer.md</c> and <c>docs/security.md</c>.
|
|
/// </summary>
|
|
public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|
{
|
|
private readonly OpcUaApplicationHostOptions _options;
|
|
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
|
private readonly ILogger<OpcUaApplicationHost> _logger;
|
|
private ApplicationInstance? _application;
|
|
private StandardServer? _server;
|
|
private readonly ITelemetryContext _telemetry;
|
|
private readonly List<IUserTokenAuthenticator> _identityAuthenticators = [];
|
|
private readonly FailedCredentialLockout _credentialLockout;
|
|
private readonly object _lifetimeLock = new();
|
|
private readonly TaskCompletionSource _disposeCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private int _disposeStarted;
|
|
|
|
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
|
|
/// <param name="options">The host configuration options.</param>
|
|
/// <param name="logger">The logger for diagnostic output.</param>
|
|
/// <param name="userAuthenticator">An optional user authenticator for UserName tokens; uses null implementation if not provided.</param>
|
|
/// <param name="telemetry">The SDK telemetry context (<see cref="ServerSdkTelemetry.Create"/>, #874); <c>null</c>
|
|
/// discards the SDK's own log output. The caller owns and releases it.</param>
|
|
public OpcUaApplicationHost(
|
|
OpcUaApplicationHostOptions options,
|
|
ILogger<OpcUaApplicationHost> logger,
|
|
IOpcUaUserAuthenticator? userAuthenticator = null,
|
|
ITelemetryContext? telemetry = null)
|
|
{
|
|
_telemetry = telemetry ?? DefaultTelemetry.Create(_ => { });
|
|
_options = options;
|
|
_logger = logger;
|
|
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
|
|
_credentialLockout = new FailedCredentialLockout(options.CredentialLockout ?? new OpcUaCredentialLockoutOptions());
|
|
}
|
|
|
|
/// <summary>Gets the OPC Foundation application instance, or null if not yet started.</summary>
|
|
public ApplicationInstance? ApplicationInstance => _application;
|
|
|
|
/// <summary>Gets the OPC UA server instance, or null if not yet started.</summary>
|
|
public StandardServer? Server => _server;
|
|
|
|
/// <summary>Starts the OPC UA application and server.</summary>
|
|
/// <param name="server">The standard server instance to start.</param>
|
|
/// <param name="cancellationToken">A cancellation token for the operation.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
|
|
{
|
|
OtOpcUaSdkServer sdkServer;
|
|
lock (_lifetimeLock)
|
|
{
|
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeStarted) != 0, this);
|
|
sdkServer = server as OtOpcUaSdkServer
|
|
?? throw new ArgumentException("The server must support application authentication before its listeners open.", nameof(server));
|
|
_server = server;
|
|
_application = new ApplicationInstance(_telemetry)
|
|
{
|
|
ApplicationName = _options.ApplicationName,
|
|
ApplicationType = ApplicationType.Server,
|
|
ConfigSectionName = "OtOpcUa",
|
|
};
|
|
}
|
|
|
|
try
|
|
{
|
|
_ = await BuildConfigurationAsync(cancellationToken);
|
|
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
|
AttachUserAuthenticator(sdkServer);
|
|
await _application.StartAsync(server, cancellationToken).ConfigureAwait(false);
|
|
PopulateServerArray();
|
|
}
|
|
catch
|
|
{
|
|
await DisposeAsync().ConfigureAwait(false);
|
|
throw;
|
|
}
|
|
|
|
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
|
|
_options.PublicHostname, _options.OpcUaPort);
|
|
}
|
|
|
|
private void AttachUserAuthenticator(OtOpcUaSdkServer server)
|
|
{
|
|
foreach (var type in new[] { UserTokenType.Anonymous, UserTokenType.UserName })
|
|
{
|
|
var adapter = new ApplicationTokenAuthenticator(this, type);
|
|
_identityAuthenticators.Add(adapter);
|
|
}
|
|
server.ConfigureIdentityAuthenticators(_identityAuthenticators);
|
|
}
|
|
|
|
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;
|
|
|
|
// lmxopcua#833 — only credential-bearing (UserName) activations are counted and refused. Anonymous
|
|
// never consults the lockout, so failed sign-ins cannot lock a client (or its NAT neighbours) out of
|
|
// anonymous access the way the SDK's own lockout did.
|
|
// The ApplicationUri is deliberately NOT part of the key (lmxopcua#845): on a None channel it is a free,
|
|
// unauthenticated string, so an attacker could reset its count by varying it.
|
|
var lockoutKey = tokenType == UserTokenType.UserName
|
|
? FailedCredentialLockout.KeyFor(context.ChannelCertificate?.Thumbprint, peer)
|
|
: null;
|
|
// lmxopcua#844 — failures are counted per (client, credential identity) as well as per client, so a
|
|
// success clears only its own identity's count.
|
|
var credentialIdentity = lockoutKey is null
|
|
? FailedCredentialLockout.NoIdentity
|
|
: DescribeCredential(host._userAuthenticator, args.UserIdentityTokenHandler);
|
|
if (lockoutKey is not null)
|
|
{
|
|
// Waits (bounded) for one of this client's verification slots — parallel activations queue, they are
|
|
// never refused for parallelism. Refused only when locked out, or (uncounted) when no slot freed in time.
|
|
var decision = await host._credentialLockout.BeginAsync(lockoutKey, credentialIdentity, ct).ConfigureAwait(false);
|
|
if (decision.Verdict == CredentialLockoutVerdict.LockedOut)
|
|
{
|
|
// Logged once per lockout episode at Information; every further refusal at Debug, so a client
|
|
// hammering a lockout cannot flood the log (#833 review).
|
|
var seconds = (long)Math.Ceiling(decision.Remaining.TotalSeconds);
|
|
host._logger.Log(decision.FirstRefusal ? LogLevel.Information : LogLevel.Debug,
|
|
"OpcUaApplicationHost: UserName activation refused — client {Client} is locked out ({Scope}: {Identity}) for {Seconds}s more",
|
|
lockoutKey, decision.Scope,
|
|
decision.Scope == CredentialLockoutScope.Client ? "every credential" : credentialIdentity, seconds);
|
|
return AuthenticationResult.Reject(ServiceResult.Create(StatusCodes.BadUserAccessDenied,
|
|
$"Too many failed authentication attempts. Try again in {seconds} seconds."));
|
|
}
|
|
|
|
if (decision.Verdict == CredentialLockoutVerdict.SlotWaitTimedOut)
|
|
{
|
|
host._logger.LogWarning(
|
|
"OpcUaApplicationHost: UserName activation for client {Client} timed out waiting for a "
|
|
+ "verification slot (OpcUa:CredentialLockout:SlotWaitSeconds); not counted as a failure",
|
|
lockoutKey);
|
|
return AuthenticationResult.Reject(ServiceResult.Create(StatusCodes.BadTimeout,
|
|
"Timed out waiting to verify the credential. Try again."));
|
|
}
|
|
}
|
|
|
|
// The slot BeginAsync granted is released on every path — completed with the outcome, or abandoned
|
|
// (uncounted) when the activation was cancelled or threw.
|
|
var completed = false;
|
|
try
|
|
{
|
|
// Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port.
|
|
var transient = await HandleImpersonationAsync(host._userAuthenticator, args, host._logger,
|
|
host._options.AllowAnonymous, peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct)
|
|
.ConfigureAwait(false);
|
|
var result = ServiceResult.IsBad(args.IdentityValidationError)
|
|
? AuthenticationResult.Reject(args.IdentityValidationError)
|
|
: args.Identity is { } identity
|
|
? AuthenticationResult.Accept(identity)
|
|
: AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected));
|
|
|
|
if (lockoutKey is not null)
|
|
{
|
|
var admitted = args.Identity is not null && !ServiceResult.IsBad(args.IdentityValidationError);
|
|
completed = true;
|
|
if (!admitted && transient)
|
|
{
|
|
// lmxopcua#845 — a store timeout / miss-budget refusal / authenticator fault is not a bad
|
|
// credential: release the slot uncounted.
|
|
host._credentialLockout.Abandon(lockoutKey, credentialIdentity);
|
|
}
|
|
else if (host._credentialLockout.Complete(lockoutKey, credentialIdentity, admitted) is var scope
|
|
&& scope != CredentialLockoutScope.None)
|
|
{
|
|
host._logger.LogWarning(
|
|
"OpcUaApplicationHost: client {Client} locked out of UserName activation ({Scope}: {Identity}) "
|
|
+ "for {Seconds}s after repeated failed attempts (OpcUa:CredentialLockout: {PerCredential} per "
|
|
+ "credential, {PerClient} per client); anonymous activation is unaffected",
|
|
lockoutKey, scope, scope == CredentialLockoutScope.Client ? "every credential" : credentialIdentity,
|
|
host._options.CredentialLockout.LockoutSeconds, host._options.CredentialLockout.MaxFailedAttempts,
|
|
host._credentialLockout.ClientThreshold);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
finally
|
|
{
|
|
if (lockoutKey is not null && !completed) host._credentialLockout.Abandon(lockoutKey, credentialIdentity);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/> via the OPC UA
|
|
/// standard <c>Server.ServerArray</c> property (NodeId i=2254) so warm-redundancy clients
|
|
/// can discover the partner endpoint.
|
|
///
|
|
/// The wire-served value of <c>Server.ServerArray</c> comes from
|
|
/// <see cref="IServerInternal.ServerUris"/> (an <see cref="Opc.Ua.StringTable"/>) via the
|
|
/// SDK's <c>OnReadServerArray</c> callback — writes to
|
|
/// <c>ServerObject.ServerArray.Value</c> are NOT what clients read. The SDK auto-populates
|
|
/// slot 0 with the local <c>ApplicationUri</c> on <c>ApplicationInstance.Start</c>; we
|
|
/// append the configured peers at slots 1, 2, … here.
|
|
///
|
|
/// The address-space property is also mirrored for in-process readers (the unit-test
|
|
/// observation seam) and as a defensive belt-and-braces measure.
|
|
/// </summary>
|
|
private void PopulateServerArray()
|
|
{
|
|
var internalData = _server?.CurrentInstance;
|
|
if (internalData is null) return;
|
|
|
|
// Wire path: append peers to IServerInternal.ServerUris — this is what
|
|
// OnReadServerArray serves to remote clients reading VariableIds.Server_ServerArray.
|
|
var serverUris = internalData.ServerUris;
|
|
var existing = new HashSet<string>(StringComparer.Ordinal);
|
|
for (uint i = 0; i < (uint)serverUris.Count; i++)
|
|
{
|
|
var existingUri = serverUris.GetString(i);
|
|
if (existingUri is not null) existing.Add(existingUri);
|
|
}
|
|
|
|
foreach (var peer in _options.PeerApplicationUris)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(peer)) continue;
|
|
if (existing.Contains(peer)) continue;
|
|
serverUris.Append(peer);
|
|
existing.Add(peer);
|
|
}
|
|
|
|
// In-process mirror: ServerObject.ServerArray.Value is consulted by some tests and
|
|
// tooling that read the SDK's address-space model directly rather than going through
|
|
// a session. Harmless on the wire (the SDK ignores it) but useful in-VM.
|
|
var serverObject = internalData.ServerObject;
|
|
if (serverObject is not null)
|
|
{
|
|
var uris = new List<string> { _options.ApplicationUri };
|
|
foreach (var peer in _options.PeerApplicationUris)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
|
|
uris.Add(peer);
|
|
}
|
|
serverObject.ServerArray!.Value = (ArrayOf<string>)uris;
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
/// 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>
|
|
/// <param name="logger">The logger for diagnostic output.</param>
|
|
/// <param name="allowAnonymous">Whether anonymous sessions may connect.</param>
|
|
/// <param name="remoteEndpoint">The activating client's IP endpoint when known (lmxopcua#751), handed to
|
|
/// the authenticator for per-client accounting; null when it could not be resolved.</param>
|
|
/// <returns><see langword="true"/> when a UserName denial was TRANSIENT — not a verdict on the credential
|
|
/// (the authenticator threw or reported <see cref="OpcUaUserAuthResult.Transient"/>), so the failed-credential
|
|
/// lockout must not count it (lmxopcua#845).</returns>
|
|
internal static bool HandleImpersonation(
|
|
IOpcUaUserAuthenticator authenticator,
|
|
ImpersonateEventArgs args,
|
|
ILogger logger,
|
|
bool allowAnonymous = true,
|
|
System.Net.IPEndPoint? remoteEndpoint = null)
|
|
=> HandleImpersonationAsync(authenticator, args, logger, allowAnonymous, remoteEndpoint)
|
|
.AsTask().GetAwaiter().GetResult();
|
|
|
|
private static async ValueTask<bool> HandleImpersonationAsync(
|
|
IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger,
|
|
bool allowAnonymous = true, System.Net.IPEndPoint? remoteEndpoint = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (args.UserIdentityTokenHandler.Token is AnonymousIdentityToken anonymousToken)
|
|
{
|
|
await HandleAnonymousImpersonationAsync(authenticator, args, logger, allowAnonymous, anonymousToken, cancellationToken).ConfigureAwait(false);
|
|
return false;
|
|
}
|
|
|
|
if (args.UserIdentityTokenHandler.Token is not UserNameIdentityToken token)
|
|
{
|
|
// X509 tokens — let the SDK's default validation stand.
|
|
return false;
|
|
}
|
|
|
|
string password;
|
|
try
|
|
{
|
|
// The SDK UserName token handler exposes decrypted password bytes; decode the wire UTF-8.
|
|
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 = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "UserName token decryption failed");
|
|
return false;
|
|
}
|
|
|
|
OpcUaUserAuthResult result;
|
|
try
|
|
{
|
|
result = await authenticator
|
|
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Includes cancellation: neither says anything about the credential (lmxopcua#845).
|
|
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
|
|
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Authentication failed");
|
|
return true;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}{Transient}: {Error}",
|
|
token.UserName, result.Transient ? " (transient, not counted toward the lockout)" : string.Empty, result.Error);
|
|
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Invalid credentials");
|
|
return result.Transient;
|
|
}
|
|
|
|
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, result.ApiKeyId);
|
|
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} as {Principal} ({Roles})",
|
|
token.UserName, result.Principal, string.Join(",", result.Roles));
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The credential identity a UserName activation presents, for the lockout's per-identity count
|
|
/// (lmxopcua#844): <see cref="IOpcUaUserAuthenticator.DescribeCredential"/> over the decrypted token. Never
|
|
/// throws: an unreadable token or a faulting implementation falls back to the username.
|
|
/// </summary>
|
|
internal static string DescribeCredential(IOpcUaUserAuthenticator authenticator, IUserIdentityTokenHandler handler)
|
|
{
|
|
if (handler.Token is not UserNameIdentityToken token) return FailedCredentialLockout.NoIdentity;
|
|
var username = token.UserName ?? string.Empty;
|
|
try
|
|
{
|
|
var decrypted = (handler as UserNameIdentityTokenHandler)?.DecryptedPassword;
|
|
var password = decrypted is null ? string.Empty : Encoding.UTF8.GetString(decrypted);
|
|
return authenticator.DescribeCredential(username, password) ?? "user:" + username;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return "user:" + username;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Admits or rejects an anonymous session, and — when admitted — attaches the roles the
|
|
/// <c>anonymous</c> principal has been granted.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Previously this path simply returned, leaving the SDK's default identity in place: anonymous
|
|
/// always connected and carried no roles. Two things change. Anonymous can now be refused
|
|
/// outright, and when admitted it is a normal principal whose grants come from ACL rows, so it
|
|
/// can be given read or write like any key. The identity attached is the same
|
|
/// <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 async ValueTask HandleAnonymousImpersonationAsync(
|
|
IOpcUaUserAuthenticator authenticator,
|
|
ImpersonateEventArgs args,
|
|
ILogger logger,
|
|
bool allowAnonymous,
|
|
AnonymousIdentityToken token, CancellationToken cancellationToken)
|
|
{
|
|
if (!allowAnonymous)
|
|
{
|
|
logger.LogInformation(
|
|
"OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)");
|
|
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled on this server.");
|
|
return;
|
|
}
|
|
|
|
OpcUaUserAuthResult result;
|
|
try
|
|
{
|
|
result = await authenticator.AuthenticateAnonymousAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Fail closed. An anonymous session whose grants could not be resolved must not fall through
|
|
// 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 = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is unavailable.");
|
|
return;
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error);
|
|
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Anonymous access is denied.");
|
|
return;
|
|
}
|
|
|
|
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, apiKeyId: null);
|
|
logger.LogInformation("OpcUaApplicationHost: anonymous session admitted as {Principal} ({Roles})",
|
|
OpcUaPrincipals.Anonymous,
|
|
result.Roles.Count == 0 ? "no roles" : string.Join(",", result.Roles));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
|
|
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
|
|
/// PKI tree; subsequent boots reuse the existing cert. Replaces v1's manual "you must
|
|
/// pre-create the PKI directory tree" friction. Partial slice of follow-up F13 — the
|
|
/// remaining endpoint-security, user-token validator, and observability wiring stays in
|
|
/// the follow-up queue.
|
|
/// </summary>
|
|
private async Task EnsureApplicationCertificateAsync(CancellationToken cancellationToken)
|
|
{
|
|
// silent: false → SDK logs cert creation events through its own trace plumbing.
|
|
// A null lifetime keeps the SDK default (12 months). Key-size policy comes from the configuration.
|
|
var ok = await _application!.CheckApplicationInstanceCertificatesAsync(
|
|
false, null, cancellationToken).ConfigureAwait(false);
|
|
if (!ok)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"OPC UA application certificate validation failed for {_options.ApplicationName}. " +
|
|
$"Cert store root: {Path.GetFullPath(_options.PkiStoreRoot)}");
|
|
}
|
|
}
|
|
|
|
private async Task<ApplicationConfiguration> BuildConfigurationAsync(CancellationToken ct)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath))
|
|
{
|
|
var loaded = await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct);
|
|
if (loaded.ServerConfiguration is { } loadedServer) DisableSdkAuthenticationLockout(loadedServer);
|
|
return loaded;
|
|
}
|
|
|
|
var serverConfig = new ServerConfiguration
|
|
{
|
|
BaseAddresses = [$"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa"],
|
|
};
|
|
ApplyRequestQuotas(serverConfig, _options.Quotas);
|
|
DisableSdkAuthenticationLockout(serverConfig);
|
|
ApplyDiscoveryRegistration(serverConfig, _options);
|
|
|
|
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.ResolveEnabledSecurityProfiles()).ToArray();
|
|
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
|
|
|
|
var config = new ApplicationConfiguration(_telemetry)
|
|
{
|
|
ApplicationName = _options.ApplicationName,
|
|
ApplicationUri = _options.ApplicationUri,
|
|
ProductUri = _options.ProductUri,
|
|
ApplicationType = ApplicationType.Server,
|
|
ServerConfiguration = serverConfig,
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = "Directory",
|
|
StorePath = Path.Combine(_options.PkiStoreRoot, "own"),
|
|
SubjectName = $"CN={_options.ApplicationName}",
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") },
|
|
TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") },
|
|
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
|
|
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
|
|
},
|
|
TransportQuotas = BuildTransportQuotas(_options.Quotas),
|
|
ClientConfiguration = new ClientConfiguration(),
|
|
};
|
|
|
|
// Validation creates the certificate manager before awaiting certificate loads. Give the
|
|
// application ownership first so failed/canceled startup disposes that manager too.
|
|
_application!.ApplicationConfiguration = config;
|
|
await config.ValidateAsync(ApplicationType.Server, ct).ConfigureAwait(false);
|
|
return config;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Switches off the SDK's per-client failed-authentication lockout (lmxopcua#833). It refuses EVERY
|
|
/// activation from a locked client — Anonymous included — for 5 minutes; <see cref="FailedCredentialLockout"/>
|
|
/// enforces the UserName-only replacement.
|
|
/// </summary>
|
|
/// <param name="serverConfig">The configuration to set.</param>
|
|
internal static void DisableSdkAuthenticationLockout(ServerConfiguration serverConfig) =>
|
|
serverConfig.MaxFailedAuthenticationAttempts = 0;
|
|
|
|
/// <summary>
|
|
/// Configures discovery-server registration (lmxopcua#876). Without <see cref="OpcUaApplicationHostOptions.DiscoveryServerUrl"/>
|
|
/// registration is OFF: <c>MaxRegistrationInterval = 0</c> is what makes the SDK (2.0.1
|
|
/// <c>StandardServer.StartApplicationAsync</c>) skip its registration timer, and the endpoint stays null.
|
|
/// With a URL, the server registers against it every
|
|
/// <see cref="OpcUaApplicationHostOptions.DiscoveryRegistrationIntervalMs"/>; the endpoint mirrors the SDK's own
|
|
/// default (SignAndEncrypt / Basic256Sha256, refreshed from the discovery server before connecting).
|
|
/// </summary>
|
|
/// <param name="serverConfig">The configuration to set.</param>
|
|
/// <param name="options">The host options carrying the discovery settings.</param>
|
|
internal static void ApplyDiscoveryRegistration(ServerConfiguration serverConfig, OpcUaApplicationHostOptions options)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(options.DiscoveryServerUrl))
|
|
{
|
|
serverConfig.MaxRegistrationInterval = 0;
|
|
serverConfig.RegistrationEndpoint = null;
|
|
return;
|
|
}
|
|
|
|
serverConfig.MaxRegistrationInterval = options.DiscoveryRegistrationIntervalMs;
|
|
var endpoint = new EndpointDescription
|
|
{
|
|
EndpointUrl = options.DiscoveryServerUrl.Trim(),
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
|
};
|
|
endpoint.Server.ApplicationType = ApplicationType.DiscoveryServer;
|
|
serverConfig.RegistrationEndpoint = endpoint;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies the request-processing quotas (<c>OpcUa:Quotas:*</c>, review 01 P6 — they were hard-coded at
|
|
/// 5 / 100 / 200) to the SDK server configuration.
|
|
/// </summary>
|
|
/// <param name="serverConfig">The configuration to set.</param>
|
|
/// <param name="quotas">The configured quotas.</param>
|
|
internal static void ApplyRequestQuotas(ServerConfiguration serverConfig, OpcUaQuotaOptions quotas)
|
|
{
|
|
serverConfig.MinRequestThreadCount = quotas.MinRequestThreadCount;
|
|
serverConfig.MaxRequestThreadCount = quotas.MaxRequestThreadCount;
|
|
serverConfig.MaxQueuedRequestCount = quotas.MaxQueuedRequestCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the SDK transport quotas from <c>OpcUa:Quotas:*</c>: every member starts at the SDK default and
|
|
/// only a configured (non-null) value replaces it, so an absent section is byte-identical to the former
|
|
/// <c>new TransportQuotas()</c>.
|
|
/// </summary>
|
|
/// <param name="quotas">The configured quotas.</param>
|
|
/// <returns>The transport quotas to install.</returns>
|
|
internal static TransportQuotas BuildTransportQuotas(OpcUaQuotaOptions quotas)
|
|
{
|
|
var t = new TransportQuotas();
|
|
if (quotas.OperationTimeoutMs is { } operationTimeout) t.OperationTimeout = operationTimeout;
|
|
if (quotas.MaxStringLength is { } maxString) t.MaxStringLength = maxString;
|
|
if (quotas.MaxByteStringLength is { } maxByteString) t.MaxByteStringLength = maxByteString;
|
|
if (quotas.MaxArrayLength is { } maxArray) t.MaxArrayLength = maxArray;
|
|
if (quotas.MaxMessageSize is { } maxMessage) t.MaxMessageSize = maxMessage;
|
|
if (quotas.MaxBufferSize is { } maxBuffer) t.MaxBufferSize = maxBuffer;
|
|
if (quotas.ChannelLifetimeMs is { } channelLifetime) t.ChannelLifetime = channelLifetime;
|
|
if (quotas.SecurityTokenLifetimeMs is { } tokenLifetime) t.SecurityTokenLifetime = tokenLifetime;
|
|
return t;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
|
|
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
|
|
/// the SDK rejects duplicate (policy,mode) pairs at <c>Validate</c> time. Empty input
|
|
/// falls back to a single None entry so the server doesn't refuse to start with no
|
|
/// listening endpoints — the misconfiguration is logged and very visible.
|
|
/// </summary>
|
|
/// <param name="profiles">The security profiles to build policies for.</param>
|
|
/// <returns>The SDK security policies for the requested profiles.</returns>
|
|
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
|
|
{
|
|
var seen = new HashSet<OpcUaSecurityProfile>();
|
|
var any = false;
|
|
foreach (var profile in profiles)
|
|
{
|
|
if (!seen.Add(profile)) continue;
|
|
any = true;
|
|
yield return profile switch
|
|
{
|
|
OpcUaSecurityProfile.None => new ServerSecurityPolicy
|
|
{
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
},
|
|
OpcUaSecurityProfile.Basic256Sha256Sign => new ServerSecurityPolicy
|
|
{
|
|
SecurityMode = MessageSecurityMode.Sign,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
|
},
|
|
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt => new ServerSecurityPolicy
|
|
{
|
|
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
|
},
|
|
_ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"),
|
|
};
|
|
}
|
|
// lmxopcua#879: an empty profile list used to fall back to a single None endpoint. A misconfiguration must
|
|
// never silently widen the transport to unsecured, so it is an error (the host validator rejects it first).
|
|
if (!any)
|
|
throw new InvalidOperationException(
|
|
"No OPC UA security profile is enabled; OpcUa:EnabledSecurityProfiles must list at least one.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Anonymous + UserName token policies. UserName tokens are always SDK-encrypted with
|
|
/// the server certificate (see docs/security.md "UserName token encryption") so the
|
|
/// policy works on None endpoints too. The Host supplies API-key authentication through
|
|
/// <see cref="IOpcUaUserAuthenticator"/>; <see cref="AttachUserAuthenticator"/> installs it in the
|
|
/// SDK identity registry before listeners open. <see cref="HandleImpersonation"/> retains the
|
|
/// application decision seam, and <see cref="NullOpcUaUserAuthenticator"/> denies all
|
|
/// UserName logins when none is registered.
|
|
/// </summary>
|
|
/// <param name="allowAnonymous">Whether the Anonymous policy is advertised.</param>
|
|
/// <returns>The Anonymous and UserName token policies.</returns>
|
|
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies(bool allowAnonymous = true)
|
|
{
|
|
// Withholding the policy is what makes the refusal visible to a client at DISCOVERY rather than
|
|
// only at activation: it simply is not offered an anonymous option. HandleImpersonation still
|
|
// rejects anonymous activation independently, because a client may attempt a token type the
|
|
// endpoint never advertised.
|
|
if (allowAnonymous)
|
|
{
|
|
yield return new UserTokenPolicy(UserTokenType.Anonymous)
|
|
{
|
|
PolicyId = "anonymous",
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
};
|
|
}
|
|
yield return new UserTokenPolicy(UserTokenType.UserName)
|
|
{
|
|
PolicyId = "username_basic256sha256",
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
|
};
|
|
}
|
|
|
|
/// <summary>Disposes the application host and cleans up resources.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) == 0)
|
|
_ = CompleteDisposalAsync();
|
|
return new ValueTask(_disposeCompletion.Task);
|
|
}
|
|
|
|
private async Task CompleteDisposalAsync()
|
|
{
|
|
Exception? failure = null;
|
|
try
|
|
{
|
|
ApplicationInstance? application;
|
|
StandardServer? server;
|
|
bool adopted;
|
|
lock (_lifetimeLock)
|
|
{
|
|
application = _application;
|
|
server = _server;
|
|
// SDK2 adopts the server only when ApplicationInstance.StartAsync is reached.
|
|
adopted = server is not null && ReferenceEquals(application?.Server, server);
|
|
_application = null;
|
|
}
|
|
try
|
|
{
|
|
if (application is not null)
|
|
{
|
|
try { await application.DisposeAsync().ConfigureAwait(false); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); }
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// Failed configuration/certificate work may precede the application's ownership handoff.
|
|
// An adopted server remains solely owned by ApplicationInstance.DisposeAsync.
|
|
if (!adopted)
|
|
{
|
|
try { if (server is not null) await server.DisposeAsync().ConfigureAwait(false); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: unadopted server disposal threw"); }
|
|
}
|
|
}
|
|
}
|
|
catch (Exception error) { failure = error; }
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
// Only the cleanup owner clears adapters after every listener and registry has closed.
|
|
_identityAuthenticators.Clear();
|
|
_server = null;
|
|
}
|
|
catch (Exception error) { failure = failure is null ? error : new AggregateException(failure, error); }
|
|
finally
|
|
{
|
|
if (failure is null) _disposeCompletion.TrySetResult();
|
|
else _disposeCompletion.TrySetException(failure);
|
|
}
|
|
}
|
|
}
|
|
}
|