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; /// /// 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 + /// — the wiring in BuildConfigurationAsync is profile-agnostic. /// public enum OpcUaSecurityProfile { /// No signing or encryption. Dev / isolated networks only. None, /// Basic256Sha256 + Sign. Messages signed, payload visible on the wire. Basic256Sha256Sign, /// Basic256Sha256 + SignAndEncrypt. Full transport protection. Basic256Sha256SignAndEncrypt, } /// Configuration options for OPC UA application hosting. public sealed class OpcUaApplicationHostOptions { /// Gets or sets the application name (default "OtOpcUa"). public string ApplicationName { get; set; } = "OtOpcUa"; /// Gets or sets the application URI (default "urn:OtOpcUa"). public string ApplicationUri { get; set; } = "urn:OtOpcUa"; /// Gets or sets the product URI (default "https://zb.com/otopcua"). public string ProductUri { get; set; } = "https://zb.com/otopcua"; /// Listening port for the binary endpoint (default 4840). public int OpcUaPort { get; set; } = 4840; /// Hostname or IP advertised in endpoint descriptions. public string PublicHostname { get; set; } = "0.0.0.0"; /// Application config XML path; when set, loaded instead of building from defaults. public string? ApplicationConfigPath { get; set; } /// /// Root of the application's PKI hierarchy. Sub-stores (own, issuer, /// trusted, rejected) 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. /// public string PkiStoreRoot { get; set; } = "pki"; /// /// Whether anonymous sessions may connect at all. When the /// Anonymous token policy is not advertised AND anonymous activation is rejected, so a client /// must present an API key. /// /// /// /// Defaults to deliberately, which preserves today's behaviour. /// 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. /// /// /// Note this controls admission, not authority. An admitted anonymous session is a /// normal principal (anonymous) whose grants come from NodeAcl 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 BadUserAccessDenied. /// With the authorizer disabled or not wired, reads retain the legacy behaviour without /// per-node ACL enforcement. /// /// public bool AllowAnonymous { get; set; } = true; /// /// Transport-security profiles exposed by the server (OpcUa:EnabledSecurityProfiles); the SDK /// publishes one endpoint descriptor per profile and clients choose at session open. /// /// /// /// Replace semantics (lmxopcua#879). This is by default and the /// three-profile default (: None + /// Basic256Sha256Sign + Basic256Sha256SignAndEncrypt) is applied by /// only when nothing was configured. It must NOT be /// pre-filled: ConfigurationBinder appends configured collection items to an existing list /// instead of replacing it, so a pre-filled default made it impossible to drop None. /// /// /// Empty list. A key that is absent, or an array with no items ([] 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: OpcUaApplicationHostOptionsValidator fails startup and /// throws. It never falls back to None. /// /// public IList? EnabledSecurityProfiles { get; set; } /// /// The profiles applied when is not configured — all three /// baseline profiles, unchanged from before lmxopcua#879. Production deployments configure the list /// explicitly (without None) to drop the unsecured endpoint. /// public static IReadOnlyList DefaultSecurityProfiles { get; } = new[] { OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256Sign, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, }; /// /// The profiles the server actually publishes: the configured list when one was given, otherwise /// . /// /// The list was set but is empty. public IReadOnlyList 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(); } /// /// 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 None endpoints, which don't exchange certs. /// public bool AutoAcceptUntrustedClientCertificates { get; set; } /// /// Peer server URIs published in Server.ServerArray after start, in addition to /// the local . 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. /// public IList PeerApplicationUris { get; set; } = new List(); /// /// URL of an OPC UA discovery server (LDS / GDS, e.g. opc.tcp://lds-host:4840) this server /// registers with via RegisterServer2 (OpcUa:DiscoveryServerUrl, lmxopcua#876). /// or blank (the default) disables discovery registration entirely. /// /// /// The SDK's own default is to register every 30 s with an LDS at opc.tcp://localhost:4840, which on /// an OtOpcUa node is this server itself (no LDS) — it answered BadServiceUnsupported and the SDK /// retried forever. Registration is therefore opt-in. /// public string? DiscoveryServerUrl { get; set; } /// /// Maximum interval in milliseconds between RegisterServer2 calls while /// is set (OpcUa:DiscoveryRegistrationIntervalMs; default 30000, the /// SDK's own default). Ignored when no discovery server is configured. /// public int DiscoveryRegistrationIntervalMs { get; set; } = 30_000; /// /// SDK request-processing and transport quotas (OpcUa:Quotas:*, 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. /// public OpcUaQuotaOptions Quotas { get; set; } = new(); /// /// Failed-credential lockout for UserName activations (OpcUa:CredentialLockout:*, lmxopcua#833). /// Replaces the SDK's own lockout, which also refused Anonymous; see . /// public OpcUaCredentialLockoutOptions CredentialLockout { get; set; } = new(); } /// /// The OpcUa:Quotas section: the SDK's request-thread / queue ceilings and its transport quotas. /// Request quotas carry the former hard-coded defaults; a transport quota left null keeps the SDK /// default ('s constructor values). /// public sealed class OpcUaQuotaOptions { /// Minimum SDK request-processing threads (default 5). public int MinRequestThreadCount { get; set; } = 5; /// Maximum SDK request-processing threads (default 100). public int MaxRequestThreadCount { get; set; } = 100; /// Requests the SDK queues before refusing with BadTooManyOperations (default 200) — the /// burst ceiling review 01 P6 flagged. public int MaxQueuedRequestCount { get; set; } = 200; /// Per-operation timeout in milliseconds; null keeps the SDK default. public int? OperationTimeoutMs { get; set; } /// Largest string the transport decodes; null keeps the SDK default. public int? MaxStringLength { get; set; } /// Largest byte string the transport decodes; null keeps the SDK default. public int? MaxByteStringLength { get; set; } /// Largest array the transport decodes; null keeps the SDK default. public int? MaxArrayLength { get; set; } /// Largest message the transport accepts, in bytes; null keeps the SDK default. public int? MaxMessageSize { get; set; } /// Transport buffer size in bytes; null keeps the SDK default. public int? MaxBufferSize { get; set; } /// Secure-channel lifetime in milliseconds; null keeps the SDK default. public int? ChannelLifetimeMs { get; set; } /// Security-token lifetime in milliseconds; null keeps the SDK default. public int? SecurityTokenLifetimeMs { get; set; } } /// /// Thin facade over the OPC Foundation .NET Standard SDK's application bootstrap. /// Owns the + lifetime, /// builds the programmatically (security profiles + user /// token policies + PKI stores), installs an SDK identity-registry authenticator before /// the opens its listeners via /// , and publishes the redundancy peer URIs through /// Server.ServerArray. See docs/OpcUaServer.md and docs/security.md. /// public sealed class OpcUaApplicationHost : IAsyncDisposable { private readonly OpcUaApplicationHostOptions _options; private readonly IOpcUaUserAuthenticator _userAuthenticator; private readonly ILogger _logger; private ApplicationInstance? _application; private StandardServer? _server; private readonly ITelemetryContext _telemetry; private readonly List _identityAuthenticators = []; private readonly FailedCredentialLockout _credentialLockout; private readonly object _lifetimeLock = new(); private readonly TaskCompletionSource _disposeCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _disposeStarted; /// Initializes a new instance of the OPC UA application host. /// The host configuration options. /// The logger for diagnostic output. /// An optional user authenticator for UserName tokens; uses null implementation if not provided. /// The SDK telemetry context (, #874); null /// discards the SDK's own log output. The caller owns and releases it. public OpcUaApplicationHost( OpcUaApplicationHostOptions options, ILogger 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()); } /// Gets the OPC Foundation application instance, or null if not yet started. public ApplicationInstance? ApplicationInstance => _application; /// Gets the OPC UA server instance, or null if not yet started. public StandardServer? Server => _server; /// Starts the OPC UA application and server. /// The standard server instance to start. /// A cancellation token for the operation. /// A task that represents the asynchronous operation. 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 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); } } } /// /// Publishes via the OPC UA /// standard Server.ServerArray property (NodeId i=2254) so warm-redundancy clients /// can discover the partner endpoint. /// /// The wire-served value of Server.ServerArray comes from /// (an ) via the /// SDK's OnReadServerArray callback — writes to /// ServerObject.ServerArray.Value are NOT what clients read. The SDK auto-populates /// slot 0 with the local ApplicationUri on ApplicationInstance.Start; 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. /// 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(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 { _options.ApplicationUri }; foreach (var peer in _options.PeerApplicationUris) { if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer)) uris.Add(peer); } serverObject.ServerArray!.Value = (ArrayOf)uris; } } /// /// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting /// the full SDK. Side-effects are confined to mutating /// and logging. /// /// /// 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. /// /// The user authenticator to validate credentials. /// The impersonation event arguments to process. /// The logger for diagnostic output. /// Whether anonymous sessions may connect. /// 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. /// when a UserName denial was TRANSIENT — not a verdict on the credential /// (the authenticator threw or reported ), so the failed-credential /// lockout must not count it (lmxopcua#845). 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 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; } /// /// The credential identity a UserName activation presents, for the lockout's per-identity count /// (lmxopcua#844): over the decrypted token. Never /// throws: an unreadable token or a faulting implementation falls back to the username. /// 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; } } /// /// Admits or rejects an anonymous session, and — when admitted — attaches the roles the /// anonymous principal has been granted. /// /// /// 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 /// the UserName path uses, so every downstream gate reads /// it identically and there is no anonymous-shaped branch in any of them. /// 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)); } /// /// Guarantees the application instance certificate exists in {PkiStoreRoot}/own. /// 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. /// 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 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; } /// /// 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; /// enforces the UserName-only replacement. /// /// The configuration to set. internal static void DisableSdkAuthenticationLockout(ServerConfiguration serverConfig) => serverConfig.MaxFailedAuthenticationAttempts = 0; /// /// Configures discovery-server registration (lmxopcua#876). Without /// registration is OFF: MaxRegistrationInterval = 0 is what makes the SDK (2.0.1 /// StandardServer.StartApplicationAsync) skip its registration timer, and the endpoint stays null. /// With a URL, the server registers against it every /// ; the endpoint mirrors the SDK's own /// default (SignAndEncrypt / Basic256Sha256, refreshed from the discovery server before connecting). /// /// The configuration to set. /// The host options carrying the discovery settings. 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; } /// /// Applies the request-processing quotas (OpcUa:Quotas:*, review 01 P6 — they were hard-coded at /// 5 / 100 / 200) to the SDK server configuration. /// /// The configuration to set. /// The configured quotas. internal static void ApplyRequestQuotas(ServerConfiguration serverConfig, OpcUaQuotaOptions quotas) { serverConfig.MinRequestThreadCount = quotas.MinRequestThreadCount; serverConfig.MaxRequestThreadCount = quotas.MaxRequestThreadCount; serverConfig.MaxQueuedRequestCount = quotas.MaxQueuedRequestCount; } /// /// Builds the SDK transport quotas from OpcUa:Quotas:*: 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 /// new TransportQuotas(). /// /// The configured quotas. /// The transport quotas to install. 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; } /// /// Maps each configured to a SDK /// . Duplicate profiles are silently de-duped because /// the SDK rejects duplicate (policy,mode) pairs at Validate 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. /// /// The security profiles to build policies for. /// The SDK security policies for the requested profiles. internal static IEnumerable BuildSecurityPolicies(IEnumerable profiles) { var seen = new HashSet(); 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."); } /// /// 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 /// ; installs it in the /// SDK identity registry before listeners open. retains the /// application decision seam, and denies all /// UserName logins when none is registered. /// /// Whether the Anonymous policy is advertised. /// The Anonymous and UserName token policies. internal static IEnumerable 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, }; } /// Disposes the application host and cleans up resources. /// A task that represents the asynchronous operation. 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); } } } }