using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Configuration; using Opc.Ua.Server; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; public sealed class OpcUaApplicationHostOptions { public string ApplicationName { get; set; } = "OtOpcUa"; public string ApplicationUri { get; set; } = "urn: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"; } /// /// Thin facade over the OPC Foundation .NET Standard SDK's application bootstrap. /// Owns the + lifetime /// and starts a with the supplied node-manager factory. /// /// Full extraction from legacy OtOpcUa.Server (security wiring, ScriptedAlarmDescriptor /// pipeline, ResilienceController, history backend, observability hooks) is tracked as /// follow-up F13. This facade compiles + boots the SDK so Task 53 can wire the fused Host's /// driver-role startup against it. /// public sealed class OpcUaApplicationHost : IAsyncDisposable { private readonly OpcUaApplicationHostOptions _options; private readonly ILogger _logger; private ApplicationInstance? _application; private StandardServer? _server; public OpcUaApplicationHost( OpcUaApplicationHostOptions options, ILogger logger) { _options = options; _logger = logger; } public ApplicationInstance? ApplicationInstance => _application; public StandardServer? Server => _server; public async Task StartAsync(StandardServer server, CancellationToken cancellationToken) { _server = server; _application = new ApplicationInstance { ApplicationName = _options.ApplicationName, ApplicationType = ApplicationType.Server, ConfigSectionName = "OtOpcUa", }; _ = await BuildConfigurationAsync(cancellationToken); await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false); await _application.Start(server).ConfigureAwait(false); _logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}", _options.PublicHostname, _options.OpcUaPort); } /// /// 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!.CheckApplicationInstanceCertificate( silent: false, minimumKeySize: 0, lifeTimeInMonths: 0, ct: 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!.LoadApplicationConfiguration(_options.ApplicationConfigPath, silent: true); } // Minimal defaults — security and certificate stores hardcoded to local files in // the app's working directory. Full security wiring stays in legacy Server until F13. var config = new ApplicationConfiguration { ApplicationName = _options.ApplicationName, ApplicationUri = _options.ApplicationUri, ProductUri = _options.ProductUri, ApplicationType = ApplicationType.Server, ServerConfiguration = new ServerConfiguration { BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" }, MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200, }, 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 = false, }, TransportQuotas = new TransportQuotas(), ClientConfiguration = new ClientConfiguration(), TraceConfiguration = new TraceConfiguration(), }; await config.Validate(ApplicationType.Server).ConfigureAwait(false); _application!.ApplicationConfiguration = config; return config; } public ValueTask DisposeAsync() { try { _application?.Stop(); } catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); } return ValueTask.CompletedTask; } }