using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; /// /// Wraps to bring the OPC UA server online — builds an /// programmatically (no external XML file), ensures /// the application certificate exists in the PKI store (auto-generates self-signed on first /// run), starts the server, then walks each and invokes /// against it so the driver's /// discovery streams into the already-running server's address space. /// public sealed class OpcUaApplicationHost : IAsyncDisposable { private readonly OpcUaServerOptions _options; private readonly DriverHost _driverHost; private readonly IUserAuthenticator _authenticator; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private ApplicationInstance? _application; private OtOpcUaServer? _server; private bool _disposed; public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost, IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger logger) { _options = options; _driverHost = driverHost; _authenticator = authenticator; _loggerFactory = loggerFactory; _logger = logger; } public OtOpcUaServer? Server => _server; /// /// Builds the , validates/creates the application /// certificate, constructs + starts the , then drives /// per registered driver so /// the address space is populated before the first client connects. /// public async Task StartAsync(CancellationToken ct) { _application = new ApplicationInstance { ApplicationName = _options.ApplicationName, ApplicationType = ApplicationType.Server, ApplicationConfiguration = BuildConfiguration(), }; var hasCert = await _application.CheckApplicationInstanceCertificate(silent: true, minimumKeySize: CertificateFactory.DefaultKeySize).ConfigureAwait(false); if (!hasCert) throw new InvalidOperationException( $"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}"); _server = new OtOpcUaServer(_driverHost, _authenticator, _loggerFactory); await _application.Start(_server).ConfigureAwait(false); _logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}", _options.EndpointUrl, _server.DriverNodeManagers.Count); // Drive each driver's discovery through its node manager. The node manager IS the // IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into // its internal map and wires OnAlarmEvent → sink routing. foreach (var nodeManager in _server.DriverNodeManagers) { var driverId = nodeManager.Driver.DriverInstanceId; try { var generic = new GenericDriverNodeManager(nodeManager.Driver); await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false); _logger.LogInformation("Address space populated for driver {Driver}", driverId); } catch (Exception ex) { // Per decision #12: driver exceptions isolate — log and keep the server serving // the other drivers' subtrees. Re-building this one takes a Reinitialize call. _logger.LogError(ex, "Discovery failed for driver {Driver}; subtree faulted", driverId); } } } private ApplicationConfiguration BuildConfiguration() { Directory.CreateDirectory(_options.PkiStoreRoot); var cfg = new ApplicationConfiguration { ApplicationName = _options.ApplicationName, ApplicationUri = _options.ApplicationUri, ApplicationType = ApplicationType.Server, ProductUri = "urn:OtOpcUa:Server", SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_options.PkiStoreRoot, "own"), SubjectName = "CN=" + _options.ApplicationName, }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_options.PkiStoreRoot, "issuers"), }, TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_options.PkiStoreRoot, "trusted"), }, RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_options.PkiStoreRoot, "rejected"), }, AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates, AddAppCertToTrustedStore = true, }, TransportConfigurations = new TransportConfigurationCollection(), TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, ServerConfiguration = new ServerConfiguration { BaseAddresses = new StringCollection { _options.EndpointUrl }, SecurityPolicies = BuildSecurityPolicies(), UserTokenPolicies = BuildUserTokenPolicies(), MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200, }, TraceConfiguration = new TraceConfiguration(), }; cfg.Validate(ApplicationType.Server).GetAwaiter().GetResult(); if (cfg.SecurityConfiguration.AutoAcceptUntrustedCertificates) { cfg.CertificateValidator.CertificateValidation += (_, e) => { if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted) e.Accept = true; }; } return cfg; } private ServerSecurityPolicyCollection BuildSecurityPolicies() { var policies = new ServerSecurityPolicyCollection { // Keep the None policy present so legacy clients can discover + browse. Locked-down // deployments remove this by setting Ldap.Enabled=true + dropping None here; left in // for PR 19 so the PR 17 test harness continues to pass unchanged. new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None, }, }; if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt) { policies.Add(new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Basic256Sha256, }); } return policies; } private UserTokenPolicyCollection BuildUserTokenPolicies() { var tokens = new UserTokenPolicyCollection { new UserTokenPolicy(UserTokenType.Anonymous) { PolicyId = "Anonymous", SecurityPolicyUri = SecurityPolicies.None, }, }; if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt && _options.Ldap.Enabled) { tokens.Add(new UserTokenPolicy(UserTokenType.UserName) { PolicyId = "UserName", // Passwords must ride an encrypted channel — scope this token to Basic256Sha256 // so the stack rejects any attempt to send UserName over the None endpoint. SecurityPolicyUri = SecurityPolicies.Basic256Sha256, }); } return tokens; } public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; try { _server?.Stop(); } catch (Exception ex) { _logger.LogWarning(ex, "OPC UA server stop threw during dispose"); } await Task.CompletedTask; } }