using System.Text; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Configuration; 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 (#520). 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. /// Until per-node enforcement lands, an admitted session of ANY kind can still read the whole /// address space, because reads carry no authorization check yet. /// /// public bool AllowAnonymous { get; set; } = true; /// /// Transport-security profiles exposed by the server. The SDK publishes one endpoint /// descriptor per profile and clients choose at session open. Default = all three /// baseline profiles (None + Basic256Sha256 in both modes); production deployments /// typically drop None. /// public IList EnabledSecurityProfiles { get; set; } = new List { OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256Sign, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, }; /// /// 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(); } /// /// 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), starts a , attaches the /// ImpersonateUser hook for UserName-token authentication 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 ImpersonateEventHandler? _impersonateHandler; /// 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. public OpcUaApplicationHost( OpcUaApplicationHostOptions options, ILogger logger, IOpcUaUserAuthenticator? userAuthenticator = null) { _options = options; _logger = logger; _userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance; } /// 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) { _server = server; // 1.5.378 requires an ITelemetryContext on the ApplicationInstance ctor (the parameterless ctor // is obsolete). DefaultTelemetry.Create wires the SDK's internal logging; an empty builder keeps // the SDK's trace off our ILogger (the host keeps its own _logger) — sufficient for the bootstrap. _application = new ApplicationInstance(DefaultTelemetry.Create(_ => { })) { ApplicationName = _options.ApplicationName, ApplicationType = ApplicationType.Server, ConfigSectionName = "OtOpcUa", }; _ = await BuildConfigurationAsync(cancellationToken); await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false); await _application.StartAsync(server).ConfigureAwait(false); AttachUserAuthenticator(); PopulateServerArray(); _logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}", _options.PublicHostname, _options.OpcUaPort); } /// /// Subscribes to after the SDK has its /// SessionManager ready (only after _application.Start). Anonymous tokens /// pass through; UserName tokens hit and, on /// success, attach a with the mapped role-set to the session /// so downstream ACL checks can read it via OperationContext.UserIdentity. /// /// The SDK calls ImpersonateUser synchronously off the session-activation /// thread, so the authenticator's async work is run via GetAwaiter().GetResult(). /// LDAP binds typically complete in <100 ms; if a backing store ever gets that slow /// it should not block the OPC UA stack — callers must enforce their own timeouts inside /// . /// private void AttachUserAuthenticator() { var sessionManager = _server?.CurrentInstance?.SessionManager; if (sessionManager is null) { _logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled"); return; } _impersonateHandler = OnImpersonateUser; sessionManager.ImpersonateUser += _impersonateHandler; } /// /// 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 = uris.ToArray(); } } private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) => HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous); /// /// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting /// the full SDK. Side-effects are confined to mutating /// and logging. /// /// /// SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378). /// The SDK raises this handler SYNCHRONOUSLY — it is a plain void delegate, and there is no /// async impersonation callback in this SDK version. Worse, SessionManager.ActivateSessionAsync /// invokes it while holding a single SessionManager-wide event lock (m_eventLock), 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 block-bridge below /// (GetAwaiter().GetResult() under ) 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. (#520 replaced the LDAP authenticator with /// ApiKeyOpcUaUserAuthenticator, 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 user authenticator to validate credentials. /// The impersonation event arguments to process. /// The logger for diagnostic output. internal static void HandleImpersonation( IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger, bool allowAnonymous = true) { if (args.NewIdentity is AnonymousIdentityToken anonymousToken) { HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken); return; } if (args.NewIdentity is not UserNameIdentityToken token) { // X509 tokens — let the SDK's default validation stand. return; } string password; try { // 1.5.378 exposes DecryptedPassword as raw bytes (was string); UserName token passwords // are UTF-8 on the wire. var decryptedBytes = token.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"); return; } OpcUaUserAuthResult result; try { result = authenticator .AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None) .GetAwaiter().GetResult(); } catch (Exception ex) { logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName); args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, "Authentication failed"); return; } if (!result.Success) { logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}", token.UserName, result.Error); args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Invalid credentials"); return; } 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)); } /// /// Admits or rejects an anonymous session, and — when admitted — attaches the roles the /// anonymous principal has been granted (#520). /// /// /// Before #520 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 void HandleAnonymousImpersonation( IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger, bool allowAnonymous, AnonymousIdentityToken token) { 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."); 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(); } 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 pre-#520 // 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."); 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."); 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. // minimumKeySize/lifetimeInMonths: 0 → use SDK defaults (2048-bit, 12-month lifetime). 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)) { return await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct); } var serverConfig = new ServerConfiguration { BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" }, MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200, }; foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles)) { serverConfig.SecurityPolicies.Add(policy); } foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous)) { serverConfig.UserTokenPolicies.Add(token); } var config = new ApplicationConfiguration { 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 = new TransportQuotas(), ClientConfiguration = new ClientConfiguration(), TraceConfiguration = new TraceConfiguration(), }; await config.ValidateAsync(ApplicationType.Server, ct).ConfigureAwait(false); _application!.ApplicationConfiguration = config; return config; } /// /// 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}"), }; } if (!any) { yield return new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None, }; } } /// /// 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 real LDAP-bound validator is wired through /// on StandardServer.SessionManager.ImpersonateUser /// (see / ); the Host /// project supplies the LDAP adapter, and denies all /// UserName logins when none is registered. /// /// 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 async ValueTask DisposeAsync() { if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager) { try { sessionManager.ImpersonateUser -= _impersonateHandler; } catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); } } _impersonateHandler = null; if (_application is not null) { // 1.5.378: ApplicationInstance.Stop() → StopAsync(). try { await _application.StopAsync().ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); } } } }