From 70ffd2849df030773fbefb89ca0f6f1532a8590a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 11:21:11 -0400 Subject: [PATCH] feat(opcua): OpcUaApplicationHost publishes peer URIs in Server.ServerArray --- .../OpcUaApplicationHost.cs | 30 ++++++++++ .../OpcUaApplicationHostServerArrayTests.cs | 59 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 20978b9..0509e60 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -63,6 +63,15 @@ public sealed class OpcUaApplicationHostOptions /// 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(); } /// @@ -112,6 +121,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable await _application.Start(server).ConfigureAwait(false); AttachUserAuthenticator(); + PopulateServerArray(); _logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}", _options.PublicHostname, _options.OpcUaPort); @@ -143,6 +153,26 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable sessionManager.ImpersonateUser += _impersonateHandler; } + /// + /// Writes the union of and + /// to the OPC UA standard + /// Server.ServerArray property (NodeId i=2254). Clients in a warm-redundancy + /// deployment discover the partner endpoint by reading this property. + /// + private void PopulateServerArray() + { + var serverObject = _server?.CurrentInstance?.ServerObject; + if (serverObject is null) return; + + 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(Session session, ImpersonateEventArgs args) => HandleImpersonation(_userAuthenticator, args, _logger); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs new file mode 100644 index 0000000..c95d811 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostServerArrayTests.cs @@ -0,0 +1,59 @@ +using System.IO; +using System.Net.Sockets; +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Audit gap closeout — verifies +/// is reflected in Server.ServerArray after start. Single-server in-process check; the +/// cross-server visibility check lives in OtOpcUa.OpcUaServer.IntegrationTests. +/// +public sealed class OpcUaApplicationHostServerArrayTests +{ + [Fact] + public async Task ServerArray_contains_local_uri_and_configured_peers_after_start() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}"); + try + { + var options = new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.UnitTest", + ApplicationUri = "urn:OtOpcUa.UnitTest.NodeA", + OpcUaPort = AllocateFreePort(), + PublicHostname = "127.0.0.1", + PkiStoreRoot = pkiRoot, + PeerApplicationUris = new[] { "urn:OtOpcUa.UnitTest.NodeB" }, + }; + + var server = new StandardServer(); + await using var host = new OpcUaApplicationHost(options, NullLogger.Instance); + await host.StartAsync(server, CancellationToken.None); + + var serverArray = server.CurrentInstance.ServerObject.ServerArray.Value; + serverArray.ShouldNotBeNull(); + serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeA"); + serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeB"); + } + finally + { + if (Directory.Exists(pkiRoot)) Directory.Delete(pkiRoot, recursive: true); + } + } + + private static int AllocateFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +}