From fae960c1572a91fbe5f64f49cbc61ed02049dc46 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 4 Jun 2026 12:56:18 -0400 Subject: [PATCH] fix(opcua): migrate OPC UA server to Opc.Ua SDK 1.5.378 (resolves startup TypeLoadException) Opc.Ua.Server was pinned 1.5.374.126 while Client/Configuration were 1.5.378.106, so the published Host unified Opc.Ua.Core to 1.5.378 (which dropped Opc.Ua.INodeIdFactory that Server 1.5.374 referenced). Every driver-role node (and the fused site nodes) failed to start the OPC UA server with TypeLoadException, leaving the OPC data plane dead and the site UIs at 503. Align all OPC UA packages to 1.5.378.106 (bump Server; drop the Opc.Ua.Configuration/Client VersionOverrides in OpcUaServer + its integration tests) and port the server host to the 1.5.378 async API: - ApplicationInstance requires an ITelemetryContext ctor (DefaultTelemetry.Create) - Start/Stop/LoadApplicationConfiguration/Validate -> async; CheckApplicationInstanceCertificate -> CheckApplicationInstanceCertificatesAsync - ImpersonateEventHandler is now (ISession, ImpersonateEventArgs) - UserNameIdentityToken.DecryptedPassword is now byte[] (UTF-8 decode) - tests ported (byte[] passwords; async discovery/session/read client API) Verified: full solution builds, OpcUaServer unit tests 52/52, and in docker-dev all six OPC endpoints (4840-4845) listen and the site UIs return 302 (were 503). End-to-end OPC behaviour (read/write/subscribe/security under 1.5.378) still needs a functional client test. --- Directory.Packages.props | 2 +- .../OpcUaApplicationHost.cs | 34 ++++++++++++------- .../ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj | 11 +++--- .../DualEndpointTests.cs | 15 ++++---- ...tOpcUa.OpcUaServer.IntegrationTests.csproj | 11 +++--- .../OpcUaApplicationHostImpersonationTests.cs | 9 ++--- 6 files changed, 48 insertions(+), 34 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 07591140..17b9745e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -74,7 +74,7 @@ - + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 5bd459dd..77ca866b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -1,3 +1,4 @@ +using System.Text; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Configuration; @@ -125,7 +126,10 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable public async Task StartAsync(StandardServer server, CancellationToken cancellationToken) { _server = server; - _application = new ApplicationInstance + // 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, @@ -134,7 +138,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable _ = await BuildConfigurationAsync(cancellationToken); await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false); - await _application.Start(server).ConfigureAwait(false); + await _application.StartAsync(server).ConfigureAwait(false); AttachUserAuthenticator(); PopulateServerArray(); @@ -223,7 +227,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable } } - private void OnImpersonateUser(Session session, ImpersonateEventArgs args) => + private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) => HandleImpersonation(_userAuthenticator, args, _logger); /// @@ -248,7 +252,10 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable string password; try { - password = token.DecryptedPassword ?? string.Empty; + // 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) { @@ -299,8 +306,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable { // 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); + var ok = await _application!.CheckApplicationInstanceCertificatesAsync( + false, null, cancellationToken).ConfigureAwait(false); if (!ok) { throw new InvalidOperationException( @@ -313,7 +320,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable { if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath)) { - return await _application!.LoadApplicationConfiguration(_options.ApplicationConfigPath, silent: true); + return await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct); } var serverConfig = new ServerConfiguration @@ -358,7 +365,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable TraceConfiguration = new TraceConfiguration(), }; - await config.Validate(ApplicationType.Server).ConfigureAwait(false); + await config.ValidateAsync(ApplicationType.Server, ct).ConfigureAwait(false); _application!.ApplicationConfiguration = config; return config; } @@ -430,7 +437,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable } /// Disposes the application host and cleans up resources. - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager) { @@ -439,8 +446,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable } _impersonateHandler = null; - try { _application?.Stop(); } - catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); } - return ValueTask.CompletedTask; + 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"); } + } } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj index c94c0b29..54dae8ad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj @@ -7,11 +7,12 @@ - - + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs index 5b529eb0..8de20862 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs @@ -7,7 +7,6 @@ using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.OpcUaServer; -using ClientSession = Opc.Ua.Client.Session; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; @@ -85,23 +84,27 @@ public sealed class DualEndpointTests }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; - await appConfig.Validate(ApplicationType.Client); + await appConfig.ValidateAsync(ApplicationType.Client, default); appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; - var endpoint = CoreClientUtils.SelectEndpoint(appConfig, endpointUrl, useSecurity: false); + // 1.5.378: the discovery/session/read client surface moved to async. + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), default); var endpointConfiguration = EndpointConfiguration.Create(appConfig); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); - using var session = await ClientSession.Create( + using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( appConfig, configuredEndpoint, updateBeforeConnect: false, + checkDomain: false, sessionName: "DualEndpointTests", sessionTimeout: 60_000, identity: new UserIdentity(new AnonymousIdentityToken()), - preferredLocales: null); + preferredLocales: null, + ct: default); - var value = session.ReadValue(VariableIds.Server_ServerArray); + var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, default); return (string[])value.Value; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj index 3d51e7ad..4f154d1c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj @@ -11,12 +11,11 @@ - - - + + + all diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs index f44bf71d..a7bb5d9e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs @@ -1,3 +1,4 @@ +using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; @@ -21,7 +22,7 @@ public sealed class OpcUaApplicationHostImpersonationTests [Fact] public void HandleImpersonation_username_success_sets_identity_and_no_validation_error() { - var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = "secret" }; + var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator( OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" })); @@ -38,7 +39,7 @@ public sealed class OpcUaApplicationHostImpersonationTests [Fact] public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity() { - var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = "wrong" }; + var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = Encoding.UTF8.GetBytes("wrong") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials")); @@ -68,7 +69,7 @@ public sealed class OpcUaApplicationHostImpersonationTests [Fact] public void HandleImpersonation_authenticator_throwing_results_in_rejection() { - var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = "x" }; + var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = Encoding.UTF8.GetBytes("x") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable")); @@ -82,7 +83,7 @@ public sealed class OpcUaApplicationHostImpersonationTests [Fact] public void HandleImpersonation_null_username_treated_as_empty_string() { - var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = "abc" }; + var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = Encoding.UTF8.GetBytes("abc") }; var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user"));