diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs index dedb9de..d6738e3 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs @@ -29,6 +29,9 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder private readonly IWritable? _writable; private readonly ILogger _logger; + /// The driver whose address space this node manager exposes. + public IDriver Driver => _driver; + private FolderState? _driverRoot; private readonly Dictionary _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs new file mode 100644 index 0000000..2f9fc1c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -0,0 +1,181 @@ +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; + +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 ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private ApplicationInstance? _application; + private OtOpcUaServer? _server; + private bool _disposed; + + public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost, + ILoggerFactory loggerFactory, ILogger logger) + { + _options = options; + _driverHost = driverHost; + _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, _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 = new ServerSecurityPolicyCollection + { + new ServerSecurityPolicy + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + }, + }, + UserTokenPolicies = new UserTokenPolicyCollection + { + new UserTokenPolicy(UserTokenType.Anonymous) + { + PolicyId = "Anonymous", + SecurityPolicyUri = SecurityPolicies.None, + }, + }, + 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; + } + + 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; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs new file mode 100644 index 0000000..8827142 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs @@ -0,0 +1,42 @@ +namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; + +/// +/// OPC UA server endpoint + application-identity configuration. Bound from the +/// OpcUaServer section of appsettings.json. PR 17 minimum-viable scope: no LDAP, +/// no security profiles beyond None — those wire in alongside a future deployment-policy PR +/// that reads from the central config DB instead of appsettings. +/// +public sealed class OpcUaServerOptions +{ + public const string SectionName = "OpcUaServer"; + + /// + /// Fully-qualified endpoint URI clients connect to. Use 0.0.0.0 to bind all + /// interfaces; the stack rewrites to the machine's hostname for the returned endpoint + /// description at GetEndpoints time. + /// + public string EndpointUrl { get; init; } = "opc.tcp://0.0.0.0:4840/OtOpcUa"; + + /// Human-readable application name surfaced in the endpoint description. + public string ApplicationName { get; init; } = "OtOpcUa Server"; + + /// Stable application URI — must match the subjectAltName of the app cert. + public string ApplicationUri { get; init; } = "urn:OtOpcUa:Server"; + + /// + /// Directory where the OPC UA stack stores the application certificate + trusted / + /// rejected cert folders. Defaults to %ProgramData%\OtOpcUa\pki; the stack + /// creates the directory tree on first run and generates a self-signed cert. + /// + public string PkiStoreRoot { get; init; } = + System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "OtOpcUa", "pki"); + + /// + /// When true, the stack auto-trusts client certs on first connect. Dev-default = true, + /// production deployments should flip this to false and manually trust clients via the + /// Admin UI. + /// + public bool AutoAcceptUntrustedClientCertificates { get; init; } = true; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs index c4721d9..b451f2c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs @@ -1,18 +1,20 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Server; /// /// BackgroundService that owns the OPC UA server lifecycle (decision #30, replacing TopShelf). -/// Bootstraps config, starts the , and runs until stopped. -/// Phase 1 scope: bootstrap-only — the OPC UA transport layer that serves endpoints stays in -/// the legacy Host until the Phase 2 cutover. +/// Bootstraps config, starts the , starts the OPC UA server via +/// , drives each driver's discovery into the address space, +/// runs until stopped. /// public sealed class OpcUaServerService( NodeBootstrap bootstrap, DriverHost driverHost, + OpcUaApplicationHost applicationHost, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -22,8 +24,11 @@ public sealed class OpcUaServerService( var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken); logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId); - // Phase 1: no drivers are wired up at bootstrap — Galaxy still lives in legacy Host. - // Phase 2 will register drivers here based on the fetched generation. + // PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver + // registration itself (RegisterAsync on DriverHost) happens during an earlier DI + // extension once the central config DB query + per-driver factory land; for now the + // server comes up with whatever drivers are in DriverHost at start time. + await applicationHost.StartAsync(stoppingToken); logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count); @@ -40,6 +45,7 @@ public sealed class OpcUaServerService( public override async Task StopAsync(CancellationToken cancellationToken) { await base.StopAsync(cancellationToken); + await applicationHost.DisposeAsync(); await driverHost.DisposeAsync(); } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index c6ade3d..c34e63e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -5,6 +5,7 @@ using Serilog; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Server; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; var builder = Host.CreateApplicationBuilder(args); @@ -29,10 +30,23 @@ var options = new NodeOptions LocalCachePath = nodeSection.GetValue("LocalCachePath") ?? "config_cache.db", }; +var opcUaSection = builder.Configuration.GetSection(OpcUaServerOptions.SectionName); +var opcUaOptions = new OpcUaServerOptions +{ + EndpointUrl = opcUaSection.GetValue("EndpointUrl") ?? "opc.tcp://0.0.0.0:4840/OtOpcUa", + ApplicationName = opcUaSection.GetValue("ApplicationName") ?? "OtOpcUa Server", + ApplicationUri = opcUaSection.GetValue("ApplicationUri") ?? "urn:OtOpcUa:Server", + PkiStoreRoot = opcUaSection.GetValue("PkiStoreRoot") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OtOpcUa", "pki"), + AutoAcceptUntrustedClientCertificates = opcUaSection.GetValue("AutoAcceptUntrustedClientCertificates") ?? true, +}; + builder.Services.AddSingleton(options); +builder.Services.AddSingleton(opcUaOptions); builder.Services.AddSingleton(_ => new LiteDbConfigCache(options.LocalCachePath)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs new file mode 100644 index 0000000..2d4ab4c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs @@ -0,0 +1,158 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +[Trait("Category", "Integration")] +public sealed class OpcUaServerIntegrationTests : IAsyncLifetime +{ + // Use a non-default port + per-test-run PKI root to avoid colliding with anything else + // running on the box (a live v1 Host or a developer's previous run). + private static readonly int Port = 48400 + Random.Shared.Next(0, 99); + private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaTest"; + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-test-{Guid.NewGuid():N}"); + + private DriverHost _driverHost = null!; + private OpcUaApplicationHost _server = null!; + private FakeDriver _driver = null!; + + public async ValueTask InitializeAsync() + { + _driverHost = new DriverHost(); + _driver = new FakeDriver(); + await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None); + + var options = new OpcUaServerOptions + { + EndpointUrl = _endpoint, + ApplicationName = "OtOpcUaTest", + ApplicationUri = "urn:OtOpcUa:Server:Test", + PkiStoreRoot = _pkiRoot, + AutoAcceptUntrustedClientCertificates = true, + }; + + _server = new OpcUaApplicationHost(options, _driverHost, NullLoggerFactory.Instance, + NullLogger.Instance); + await _server.StartAsync(CancellationToken.None); + } + + public async ValueTask DisposeAsync() + { + await _server.DisposeAsync(); + await _driverHost.DisposeAsync(); + try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ } + } + + [Fact] + public async Task Client_can_connect_and_browse_driver_subtree() + { + using var session = await OpenSessionAsync(); + + // Browse the driver subtree registered under ObjectsFolder. FakeDriver registers one + // folder ("TestFolder") with one variable ("Var1"), so we expect to see our driver's + // root folder plus standard UA children. + var rootRef = new NodeId("fake", (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake")); + session.Browse(null, null, rootRef, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences, + true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var references); + + references.Count.ShouldBeGreaterThan(0); + references.ShouldContain(r => r.BrowseName.Name == "TestFolder"); + } + + [Fact] + public async Task Client_can_read_a_driver_variable_through_the_node_manager() + { + using var session = await OpenSessionAsync(); + + var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake"); + var varNodeId = new NodeId("TestFolder.Var1", nsIndex); + + var dv = session.ReadValue(varNodeId); + dv.ShouldNotBeNull(); + // FakeDriver.ReadAsync returns 42 as the value. + dv.Value.ShouldBe(42); + } + + private async Task OpenSessionAsync() + { + var cfg = new ApplicationConfiguration + { + ApplicationName = "OtOpcUaTestClient", + ApplicationUri = "urn:OtOpcUa:TestClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(_pkiRoot, "client-own"), + SubjectName = "CN=OtOpcUaTestClient", + }, + TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") }, + TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") }, + RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") }, + AutoAcceptUntrustedCertificates = true, + AddAppCertToTrustedStore = true, + }, + TransportConfigurations = new TransportConfigurationCollection(), + TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, + }; + await cfg.Validate(ApplicationType.Client); + cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client }; + await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize); + + // Let the client fetch the live endpoint description from the running server so the + // UserTokenPolicy it signs with matches what the server actually advertised (including + // the PolicyId = "Anonymous" the server sets). + var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false); + var endpointConfig = EndpointConfiguration.Create(cfg); + var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig); + + return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaTestClientSession", 60000, + new UserIdentity(new AnonymousIdentityToken()), null); + } + + /// + /// Minimum driver that implements enough of IDriver + ITagDiscovery + IReadable to drive + /// the integration test. Returns a single folder with one variable that reads as 42. + /// + private sealed class FakeDriver : IDriver, ITagDiscovery, IReadable + { + public string DriverInstanceId => "fake"; + public string DriverType => "Fake"; + + public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; + public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; + + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) + { + var folder = builder.Folder("TestFolder", "TestFolder"); + folder.Variable("Var1", "Var1", new DriverAttributeInfo( + "TestFolder.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false)); + return Task.CompletedTask; + } + + public Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + IReadOnlyList result = + fullReferences.Select(_ => new DataValueSnapshot(42, 0u, now, now)).ToArray(); + return Task.FromResult(result); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj index 70c996b..753ad75 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj @@ -14,6 +14,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive