From 703069ed97c9e9a50a9673ec27999df840e17590 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 12:20:09 +0900 Subject: [PATCH 01/42] Add long-running connection stability test with dynamic token renewal (#3424) * Initial plan * Add connection stability test with 5-minute token renewal Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> * Fix code style issues in ConnectionStabilityTest Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> * Remove unused dictionaries to reduce memory overhead Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> * Extract magic numbers to named constants for better maintainability Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> * Add explicit permissions to workflow for security Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> * Change non-CI test duration to 30 minutes for 6 token renewals Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> * Set local test to 1 min with 10-second token renewal for 6 renewals Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> * Fix SecurityTokenLifeTime to be applied for Server & Client before Startup --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> Co-authored-by: Roman Ettlinger --- .github/workflows/stability-test.yml | 78 +++ README.md | 2 +- Tests/Opc.Ua.Client.Tests/ClientFixture.cs | 2 +- .../ClientTestFramework.cs | 3 + .../ConnectionStabilityTest.cs | 463 ++++++++++++++++++ 5 files changed, 546 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/stability-test.yml create mode 100644 Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs diff --git a/.github/workflows/stability-test.yml b/.github/workflows/stability-test.yml new file mode 100644 index 000000000..905b75a81 --- /dev/null +++ b/.github/workflows/stability-test.yml @@ -0,0 +1,78 @@ +name: Connection Stability Test + +on: + schedule: + # Run nightly at 2:00 AM UTC + - cron: '0 2 * * *' + workflow_dispatch: + inputs: + duration: + description: 'Test duration in minutes' + required: false + default: '90' + type: string + +jobs: + stability-test: + name: Connection Stability Test + runs-on: ubuntu-latest + timeout-minutes: 120 # Allow extra time beyond test duration for setup/teardown + + permissions: + contents: read + + env: + DOTNET_VERSION: '10.0.x' + CONFIGURATION: 'Release' + TEST_DURATION_MINUTES: ${{ github.event.inputs.duration || '90' }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Set Cloud Version + shell: pwsh + run: ./.azurepipelines/set-version.ps1 + + - name: Restore dependencies + run: dotnet restore 'UA.slnx' + + - name: Build Client Tests + run: dotnet build ./Tests/Opc.Ua.Client.Tests/Opc.Ua.Client.Tests.csproj --configuration ${{ env.CONFIGURATION }} --no-restore + + - name: Run Connection Stability Test + run: | + echo "Starting connection stability test for ${{ env.TEST_DURATION_MINUTES }} minutes" + dotnet test ./Tests/Opc.Ua.Client.Tests/Opc.Ua.Client.Tests.csproj \ + --configuration ${{ env.CONFIGURATION }} \ + --no-build \ + --filter "Category=ConnectionStability" \ + --logger "console;verbosity=detailed" \ + --results-directory ./TestResults + timeout-minutes: 110 # Slightly longer than expected test duration + env: + TEST_DURATION_MINUTES: ${{ env.TEST_DURATION_MINUTES }} + + - name: Upload test results + uses: actions/upload-artifact@v6 + with: + name: stability-test-results + path: ./TestResults + if: always() + + - name: Check test results + if: always() + run: | + if [ -f ./TestResults/*.trx ]; then + echo "Test results found" + cat ./TestResults/*.trx + else + echo "No test results found" + fi diff --git a/README.md b/README.md index e3a462902..8a43f9271 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OPC UA .NET Stack -[![Github top language](https://img.shields.io/github/languages/top/OPCFoundation/UA-.NETStandard)](https://github.com/OPCFoundation/UA-.NETStandard) [![Github stars](https://img.shields.io/github/stars/OPCFoundation/UA-.NETStandard?style=flat)](https://github.com/OPCFoundation/UA-.NETStandard) [![Github forks](https://img.shields.io/github/forks/OPCFoundation/UA-.NETStandard?style=flat)](https://github.com/OPCFoundation/UA-.NETStandard) [![Github size](https://img.shields.io/github/repo-size/OPCFoundation/UA-.NETStandard?style=flat)](https://github.com/OPCFoundation/UA-.NETStandard) [![Github release](https://img.shields.io/github/v/release/OPCFoundation/UA-.NETStandard?style=flat)](https://github.com/OPCFoundation/UA-.NETStandard/releases) [![Nuget Downloads](https://img.shields.io/nuget/dt/OPCFoundation.NetStandard.Opc.Ua)](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua/) [![Azure DevOps](https://opcfoundation.visualstudio.com/opcua-netstandard/_apis/build/status/OPCFoundation.UA-.NETStandard?branchName=master)](https://opcfoundation.visualstudio.com/opcua-netstandard/_build/latest?definitionId=14&branchName=master) [![Github Actions](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/buildandtest.yml/badge.svg)](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/buildandtest.yml) [![Tests](https://img.shields.io/azure-devops/tests/opcfoundation/opcua-netstandard/14/master?style=plastic&label=Tests)](https://opcfoundation.visualstudio.com/opcua-netstandard/_test/analytics?definitionId=14&contextType=build) [![CodeQL](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/codeql-analysis.yml) [![Coverage Status](https://codecov.io/gh/OPCFoundation/UA-.NETStandard/branch/master/graph/badge.svg?token=vDf5AnilUt)](https://codecov.io/gh/OPCFoundation/UA-.NETStandard) +[![Github top language](https://img.shields.io/github/languages/top/OPCFoundation/UA-.NETStandard)](https://github.com/OPCFoundation/UA-.NETStandard) [![Github stars](https://img.shields.io/github/stars/OPCFoundation/UA-.NETStandard?style=flat)](https://github.com/OPCFoundation/UA-.NETStandard) [![Github forks](https://img.shields.io/github/forks/OPCFoundation/UA-.NETStandard?style=flat)](https://github.com/OPCFoundation/UA-.NETStandard) [![Github size](https://img.shields.io/github/repo-size/OPCFoundation/UA-.NETStandard?style=flat)](https://github.com/OPCFoundation/UA-.NETStandard) [![Github release](https://img.shields.io/github/v/release/OPCFoundation/UA-.NETStandard?style=flat)](https://github.com/OPCFoundation/UA-.NETStandard/releases) [![Nuget Downloads](https://img.shields.io/nuget/dt/OPCFoundation.NetStandard.Opc.Ua)](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua/) [![Azure DevOps](https://opcfoundation.visualstudio.com/opcua-netstandard/_apis/build/status/OPCFoundation.UA-.NETStandard?branchName=master)](https://opcfoundation.visualstudio.com/opcua-netstandard/_build/latest?definitionId=14&branchName=master) [![Github Actions](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/buildandtest.yml/badge.svg)](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/buildandtest.yml) [![Tests](https://img.shields.io/azure-devops/tests/opcfoundation/opcua-netstandard/14/master?style=plastic&label=Tests)](https://opcfoundation.visualstudio.com/opcua-netstandard/_test/analytics?definitionId=14&contextType=build) [![CodeQL](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/codeql-analysis.yml) [![Coverage Status](https://codecov.io/gh/OPCFoundation/UA-.NETStandard/branch/master/graph/badge.svg?token=vDf5AnilUt)](https://codecov.io/gh/OPCFoundation/UA-.NETStandard) [![Connection Stability](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/stability-test.yml/badge.svg)](https://github.com/OPCFoundation/UA-.NETStandard/actions/workflows/stability-test.yml) ## Overview diff --git a/Tests/Opc.Ua.Client.Tests/ClientFixture.cs b/Tests/Opc.Ua.Client.Tests/ClientFixture.cs index e87855847..d89ac0e92 100644 --- a/Tests/Opc.Ua.Client.Tests/ClientFixture.cs +++ b/Tests/Opc.Ua.Client.Tests/ClientFixture.cs @@ -435,7 +435,7 @@ public async Task GetEndpointsAsync( Uri url, CancellationToken ct = default) { - var endpointConfiguration = EndpointConfiguration.Create(); + var endpointConfiguration = EndpointConfiguration.Create(Config); endpointConfiguration.OperationTimeout = OperationTimeout; using DiscoveryClient client = await DiscoveryClient.CreateAsync( diff --git a/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs b/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs index 3bcce121c..88b8de5d6 100644 --- a/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs +++ b/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs @@ -70,6 +70,7 @@ public class ClientTestFramework public ReferenceDescriptionCollection ReferenceDescriptions { get; set; } public ISession Session { get; protected set; } public OperationLimits OperationLimits { get; private set; } + public int SecurityTokenLifetime { get; set; } = 3_600_000; public string UriScheme { get; } public string PkiRoot { get; set; } public Uri ServerUrl { get; private set; } @@ -164,6 +165,7 @@ await CreateReferenceServerFixtureAsync( .Config .TransportQuotas .MaxStringLength = TransportQuotaMaxStringLength; + ClientFixture.Config.TransportQuotas.SecurityTokenLifetime = SecurityTokenLifetime; if (!string.IsNullOrEmpty(customUrl)) { @@ -224,6 +226,7 @@ public virtual async Task CreateReferenceServerFixtureAsync( .Config .TransportQuotas .MaxStringLength = TransportQuotaMaxStringLength; + ServerFixture.Config.TransportQuotas.SecurityTokenLifetime = SecurityTokenLifetime; ServerFixture.Config.ServerConfiguration.UserTokenPolicies .Add(new UserTokenPolicy(UserTokenType.UserName)); ServerFixture.Config.ServerConfiguration.UserTokenPolicies.Add( diff --git a/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs b/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs new file mode 100644 index 000000000..f73e620c3 --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs @@ -0,0 +1,463 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace Opc.Ua.Client.Tests +{ + /// + /// Long-running connection stability test. + /// + [TestFixture] + [Category("ConnectionStability")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + public class ConnectionStabilityTest : ClientTestFramework + { + private const int SecurityTokenLifetimeCIMs = 5 * 60 * 1000; // 5 minutes for CI + private const int SecurityTokenLifetimeLocalMs = 10 * 1000; // 10 seconds for local testing + private const int StatusReportIntervalSeconds = 60; // Report status every 60 seconds + private const double NotificationToleranceRatio = 0.95; // Accept 95% of expected notifications (5% tolerance) + + public ConnectionStabilityTest() + : base(Utils.UriSchemeOpcTcp) + { + SingleSession = false; + } + + /// + /// Set up a Server and a Client instance. + /// + [OneTimeSetUp] + public override async Task OneTimeSetUpAsync() + { + SupportsExternalServerUrl = true; + + // Check if running in CI environment + bool isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + + // Configure security token lifetime based on environment + // CI: 5 minutes to force 18 renewals in 90 minute test + // Local: 10 seconds to force 6 renewals in 1 minute test + int tokenLifetime = isCI ? SecurityTokenLifetimeCIMs : SecurityTokenLifetimeLocalMs; + + SecurityTokenLifetime = tokenLifetime; + + await base.OneTimeSetUpAsync().ConfigureAwait(false); + } + + /// + /// Tear down the Server and the Client. + /// + [OneTimeTearDown] + public override Task OneTimeTearDownAsync() + { + return base.OneTimeTearDownAsync(); + } + + /// + /// Test setup. + /// + [SetUp] + public override Task SetUpAsync() + { + return base.SetUpAsync(); + } + + /// + /// Test teardown. + /// + [TearDown] + public override Task TearDownAsync() + { + return base.TearDownAsync(); + } + + /// + /// Long-running test that verifies connection stability over a configurable duration. + /// Tests that: + /// - Connection remains stable over extended period + /// - Subscriptions deliver all expected values (no message loss) + /// - Security token renewals happen correctly (every 5 minutes in CI, every 10 seconds locally) + /// Duration can be configured via TEST_DURATION_MINUTES environment variable (default: 90 minutes CI, 1 minute local) + /// + [Test] + [Order(100)] + public async Task LongRunningStabilityTestAsync() + { + // Get test duration from environment variable or use default + int testDurationMinutes = GetTestDurationMinutes(); + int testDurationSeconds = testDurationMinutes * 60; + + // Determine token lifetime based on environment + bool isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + int tokenLifetimeMs = isCI ? SecurityTokenLifetimeCIMs : SecurityTokenLifetimeLocalMs; + + TestContext.Out.WriteLine($"Starting connection stability test for {testDurationMinutes} minutes ({testDurationSeconds} seconds)"); + TestContext.Out.WriteLine($"Security token lifetime: {tokenLifetimeMs / 1000} seconds ({tokenLifetimeMs / 60000.0:F1} minutes)"); + + const int publishingInterval = 1000; // 1 second + const int writerInterval = 2000; // 2 seconds + const int samplingInterval = 500; // 500 ms + + var valueChanges = new ConcurrentDictionary(); + var clientHandles = new ConcurrentDictionary(); + var errors = new ConcurrentBag(); + + ISession session = null; + Subscription subscription = null; + + try + { + // Get nodes for subscription + IDictionary nodeIds = GetTestSetStaticMassNumeric(Session.NamespaceUris); + if (nodeIds.Count == 0) + { + NUnit.Framework.Assert.Ignore("No nodes for simulation found, ignoring test."); + } + + TestContext.Out.WriteLine($"Subscribing to {nodeIds.Count} nodes."); + + // Create session + session = await ClientFixture.ConnectAsync(ServerUrl, SecurityPolicies.Basic256Sha256).ConfigureAwait(false); + Assert.NotNull(session, "Failed to create session"); + + // Create subscription + subscription = new Subscription(session.DefaultSubscription) + { + PublishingInterval = publishingInterval, + PublishingEnabled = true, + KeepAliveCount = 10, + LifetimeCount = 100, + MaxNotificationsPerPublish = 1000, + Priority = 100 + }; + + // Add monitored items + foreach (NodeId nodeId in nodeIds.Keys) + { + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = nodeId, + AttributeId = Attributes.Value, + MonitoringMode = MonitoringMode.Reporting, + SamplingInterval = samplingInterval, + QueueSize = 10, + DiscardOldest = true + }; + + valueChanges.TryAdd(nodeId, 0); + clientHandles.TryAdd(item.ClientHandle, nodeId); + subscription.AddItem(item); + } + + // Set up notification callback + subscription.FastDataChangeCallback = (sub, item, value) => + { + try + { + foreach (MonitoredItemNotification notification in item.MonitoredItems) + { + if (!StatusCode.IsGood(notification.Value.StatusCode)) + { + string error = $"Bad status code received: {notification.Value.StatusCode} for client handle {notification.ClientHandle}"; + errors.Add(error); + TestContext.Out.WriteLine($"ERROR: {error}"); + } + else if (clientHandles.TryGetValue(notification.ClientHandle, out NodeId nodeId)) + { + valueChanges.AddOrUpdate(nodeId, 1, (key, count) => count + 1); + } + } + } + catch (Exception ex) + { + string error = $"Exception in data change callback: {ex.Message}"; + errors.Add(error); + TestContext.Out.WriteLine($"ERROR: {error}"); + } + }; + + // Create subscription on server + session.AddSubscription(subscription); + await subscription.CreateAsync().ConfigureAwait(false); + + TestContext.Out.WriteLine($"Subscription created with {subscription.MonitoredItemCount} monitored items"); + + // Create writer session + ISession writerSession = await ClientFixture.ConnectAsync(ServerUrl, SecurityPolicies.Basic256Sha256).ConfigureAwait(false); + Assert.NotNull(writerSession, "Failed to create writer session"); + + // Writer task - continuously write values + int writeCount = 0; + var writerCts = new CancellationTokenSource(); + var writerTask = Task.Run(async () => + { + while (!writerCts.IsCancellationRequested) + { + writeCount++; + var nodesToWrite = new WriteValueCollection(); + + foreach (KeyValuePair node in nodeIds) + { + nodesToWrite.Add(new WriteValue + { + NodeId = node.Key, + AttributeId = Attributes.Value, + Value = new DataValue( + new Variant( + Convert.ChangeType(writeCount, node.Value, CultureInfo.InvariantCulture) + ) + ) + }); + } + + try + { + await writerSession.WriteAsync(null, nodesToWrite, writerCts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + string error = $"Writer session error: {ex.Message}"; + errors.Add(error); + TestContext.Out.WriteLine($"ERROR: {error}"); + } + + try + { + await Task.Delay(writerInterval, writerCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + + TestContext.Out.WriteLine($"Writer task completed. Total writes: {writeCount}"); + }, writerCts.Token); + + // Status reporting task + var statusReportingCts = new CancellationTokenSource(); + var statusTask = Task.Run(async () => + { + int reportCount = 0; + + while (!statusReportingCts.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(StatusReportIntervalSeconds), statusReportingCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + reportCount++; + int totalNotifications = valueChanges.Values.Sum(); + int elapsedMinutes = reportCount * StatusReportIntervalSeconds / 60; + + TestContext.Out.WriteLine( + $"[Status Report {reportCount}] Elapsed: {elapsedMinutes} minutes, " + + $"Total notifications: {totalNotifications}, Write count: {writeCount}, Errors: {errors.Count}"); + + // Report per-node statistics + if (reportCount % 5 == 0) // Every 5 minutes + { + TestContext.Out.WriteLine("Per-node notification counts:"); + foreach (var kvp in valueChanges.OrderBy(x => x.Key.ToString())) + { + TestContext.Out.WriteLine($" {kvp.Key}: {kvp.Value} notifications"); + } + } + } + }, statusReportingCts.Token); + + // Run test for the specified duration + TestContext.Out.WriteLine($"Test running... will complete at {DateTime.UtcNow.AddSeconds(testDurationSeconds):yyyy-MM-dd HH:mm:ss} UTC"); + await Task.Delay(TimeSpan.FromSeconds(testDurationSeconds)).ConfigureAwait(false); + + // Stop tasks + TestContext.Out.WriteLine("Test duration elapsed. Stopping writer and status tasks..."); + writerCts.Cancel(); + statusReportingCts.Cancel(); + + try + { + await writerTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + /* expected */ + } + + try + { + await statusTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + /* expected */ + } + + // Wait for final notifications to be processed + TestContext.Out.WriteLine("Waiting for final notifications to be processed..."); + await Task.Delay(publishingInterval * 5).ConfigureAwait(false); + + // Verification + TestContext.Out.WriteLine("=== Final Results ==="); + TestContext.Out.WriteLine($"Test duration: {testDurationMinutes} minutes"); + TestContext.Out.WriteLine($"Security token lifetime: {tokenLifetimeMs / 1000} seconds ({tokenLifetimeMs / 60000.0:F1} minutes)"); + TestContext.Out.WriteLine($"Expected token renewals: ~{(testDurationMinutes * 60000) / tokenLifetimeMs} times"); + TestContext.Out.WriteLine($"Total write operations: {writeCount}"); + TestContext.Out.WriteLine($"Total errors: {errors.Count}"); + + // Calculate expected and received notifications + int totalNotifications = valueChanges.Values.Sum(); + int expectedMinNotifications = (writeCount - 1) * nodeIds.Count; // Allow for some timing variance + + TestContext.Out.WriteLine($"Total notifications received: {totalNotifications}"); + TestContext.Out.WriteLine($"Expected minimum notifications: {expectedMinNotifications}"); + + // Per-node verification + TestContext.Out.WriteLine("Per-node results:"); + bool allNodesReceivedData = true; + foreach (NodeId nodeId in nodeIds.Keys) + { + if (valueChanges.TryGetValue(nodeId, out int changes)) + { + TestContext.Out.WriteLine($" {nodeId}: {changes} notifications"); + if (changes < (writeCount * NotificationToleranceRatio)) + { + allNodesReceivedData = false; + TestContext.Out.WriteLine($" WARNING: Expected at least {writeCount * NotificationToleranceRatio:F0} notifications"); + } + } + else + { + allNodesReceivedData = false; + TestContext.Out.WriteLine($" {nodeId}: 0 notifications (ERROR)"); + } + } + + // List all errors + if (!errors.IsEmpty) + { + TestContext.Out.WriteLine($"Errors encountered ({errors.Count}):"); + foreach (string error in errors.Take(20)) // Show first 20 errors + { + TestContext.Out.WriteLine($" - {error}"); + } + if (errors.Count > 20) + { + TestContext.Out.WriteLine($" ... and {errors.Count - 20} more errors"); + } + } + + // Cleanup writer session + try + { + await writerSession.CloseAsync().ConfigureAwait(false); + writerSession.Dispose(); + } + catch (Exception ex) + { + TestContext.Out.WriteLine($"Failed to close writer session: {ex.Message}"); + } + + // Assertions + Assert.IsTrue(allNodesReceivedData, "Not all nodes received expected data"); + Assert.AreEqual(0, errors.Count, $"Test encountered {errors.Count} errors"); + Assert.GreaterOrEqual(totalNotifications, expectedMinNotifications, "Total notifications received is less than expected minimum"); + + TestContext.Out.WriteLine("Connection stability test PASSED"); + } + finally + { + // Cleanup + if (subscription != null) + { + try + { + await subscription.DeleteAsync(true).ConfigureAwait(false); + } + catch (Exception ex) + { + TestContext.Out.WriteLine($"Failed to delete subscription: {ex.Message}"); + } + } + + if (session != null) + { + try + { + await session.CloseAsync().ConfigureAwait(false); + session.Dispose(); + } + catch (Exception ex) + { + TestContext.Out.WriteLine($"Failed to close session: {ex.Message}"); + } + } + } + } + + /// + /// Gets the test duration in minutes from environment variable or returns default. + /// + private int GetTestDurationMinutes() + { + string envValue = Environment.GetEnvironmentVariable("TEST_DURATION_MINUTES"); + + if (!string.IsNullOrEmpty(envValue) && int.TryParse(envValue, out int minutes) && minutes > 0) + { + return minutes; + } + + // Default to 90 minutes for nightly runs, but use 1 minute for manual/local testing + // CI: 90 minutes with 5-minute token lifetime = 18 renewals + // Local: 1 minute with 10-second token lifetime = 6 renewals + // Check if running in CI environment + bool isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + + return isCI ? 90 : 1; // 90 minutes for CI (18 renewals), 1 minute for local (6 renewals) + } + } +} From e6f5bd0b9b3435f40cfbcc8ac14acc6c7f83528c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:16:47 +0900 Subject: [PATCH 02/42] Bump Roslynator.Analyzers from 4.14.1 to 4.15.0 (#3433) --- updated-dependencies: - dependency-name: Roslynator.Analyzers dependency-version: 4.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index c400d6c8d..815ec0b09 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -31,7 +31,7 @@ - + From 4f44096ad4fb76ea285bfbda2b0fb469cfad9579 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:17:15 +0900 Subject: [PATCH 03/42] Bump NUnit3TestAdapter from 6.0.0 to 6.0.1 (#3432) --- updated-dependencies: - dependency-name: NUnit3TestAdapter dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 815ec0b09..e82c9dfb4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,7 @@ - + From 56b481831188e08808d8cfe1c6f2e5f4af6d307d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:09:33 +0900 Subject: [PATCH 04/42] Bump NUnit.Console from 3.21.0 to 3.21.1 (#3431) --- updated-dependencies: - dependency-name: NUnit.Console dependency-version: 3.21.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marc Schier --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e82c9dfb4..1317aae7b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -28,7 +28,7 @@ - + From 75be609b1080f91afeb1503327c4b6968bebed1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:46:34 +0900 Subject: [PATCH 05/42] Bump System.Text.Json from 10.0.0 to 10.0.1 (#3438) --- updated-dependencies: - dependency-name: System.Text.Json dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1317aae7b..5dc6c6d8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -46,7 +46,7 @@ - + From 03af6458bc9e6c201dc993120994907a3ed59e40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 09:33:33 +0900 Subject: [PATCH 06/42] Bump System.Formats.Asn1 from 10.0.0 to 10.0.1 (#3437) --- updated-dependencies: - dependency-name: System.Formats.Asn1 dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5dc6c6d8e..b34adb4d8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,7 +42,7 @@ - + From 90156aee98362755f5beb2ee38edb081652e655e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 09:33:53 +0900 Subject: [PATCH 07/42] Bump System.Collections.Immutable from 10.0.0 to 10.0.1 (#3436) --- updated-dependencies: - dependency-name: System.Collections.Immutable dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marc Schier --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b34adb4d8..ffd1fc1f8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,7 @@ - + From 0e18d5c25654e472c86ca3284a3fc4a5099f02fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 09:34:34 +0900 Subject: [PATCH 08/42] Bump Roslynator.Formatting.Analyzers from 4.14.1 to 4.15.0 (#3435) --- updated-dependencies: - dependency-name: Roslynator.Formatting.Analyzers dependency-version: 4.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ffd1fc1f8..ea401f805 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,7 +32,7 @@ - + From 1806172500e5a33cca0a52858333a342cb0be6b1 Mon Sep 17 00:00:00 2001 From: romanett Date: Tue, 6 Jan 2026 03:32:15 +0100 Subject: [PATCH 09/42] only run test on net 10, only print per node status in debug runs (#3439) --- .github/workflows/stability-test.yml | 1 + Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs | 4 ++++ UA.slnx | 1 + 3 files changed, 6 insertions(+) diff --git a/.github/workflows/stability-test.yml b/.github/workflows/stability-test.yml index 905b75a81..9ade56ae2 100644 --- a/.github/workflows/stability-test.yml +++ b/.github/workflows/stability-test.yml @@ -53,6 +53,7 @@ jobs: dotnet test ./Tests/Opc.Ua.Client.Tests/Opc.Ua.Client.Tests.csproj \ --configuration ${{ env.CONFIGURATION }} \ --no-build \ + --framework ${{ env.DOTNET_VERSION }} --filter "Category=ConnectionStability" \ --logger "console;verbosity=detailed" \ --results-directory ./TestResults diff --git a/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs b/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs index f73e620c3..a61ac135a 100644 --- a/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs +++ b/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs @@ -297,6 +297,7 @@ public async Task LongRunningStabilityTestAsync() $"[Status Report {reportCount}] Elapsed: {elapsedMinutes} minutes, " + $"Total notifications: {totalNotifications}, Write count: {writeCount}, Errors: {errors.Count}"); +#if DEBUG // Report per-node statistics if (reportCount % 5 == 0) // Every 5 minutes { @@ -306,6 +307,7 @@ public async Task LongRunningStabilityTestAsync() TestContext.Out.WriteLine($" {kvp.Key}: {kvp.Value} notifications"); } } +#endif } }, statusReportingCts.Token); @@ -362,7 +364,9 @@ public async Task LongRunningStabilityTestAsync() { if (valueChanges.TryGetValue(nodeId, out int changes)) { +#if DEBUG TestContext.Out.WriteLine($" {nodeId}: {changes} notifications"); +#endif if (changes < (writeCount * NotificationToleranceRatio)) { allNodesReceivedData = false; diff --git a/UA.slnx b/UA.slnx index 3ffdbdb9f..5029191a9 100644 --- a/UA.slnx +++ b/UA.slnx @@ -69,6 +69,7 @@ + From e92e469923ebeb03b46a9fb3b754e528e0294380 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:33:23 +0900 Subject: [PATCH 10/42] [GDS] Add configurable validation limits for trust list file read/write operations (#3411) * Initial plan * Add trust list file size validation with configurable limits Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Fix potential integer overflow in trust list size validation Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Fix integer overflow in client trust list read validation Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Add comprehensive tests for trust list size validation Add TrustListValidationTest with tests for: - Normal-sized trust lists (verify basic functionality) - Trust lists exceeding 16MB limit (verify BadEncodingLimitsExceeded error) - Trust lists just under limit (~15MB) (verify boundary condition) Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Make limits configurable on client side * fix tests --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> Co-authored-by: Roman Ettlinger --- .../GlobalDiscoveryServerClient.cs | 27 +- .../ServerPushConfigurationClient.cs | 45 ++- .../Configuration/ConfigurationNodeManager.cs | 3 +- .../Opc.Ua.Server/Configuration/TrustList.cs | 36 ++- Tests/Opc.Ua.Gds.Tests/Common.cs | 5 +- .../GlobalDiscoveryTestServer.cs | 14 +- Tests/Opc.Ua.Gds.Tests/PushTest.cs | 1 + .../TrustListValidationTest.cs | 305 ++++++++++++++++++ 8 files changed, 425 insertions(+), 11 deletions(-) create mode 100644 Tests/Opc.Ua.Gds.Tests/TrustListValidationTest.cs diff --git a/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs b/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs index 2841c8f5a..c1ef8089b 100644 --- a/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs +++ b/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs @@ -68,6 +68,11 @@ public GlobalDiscoveryServerClient( AdminCredentials = adminUserIdentity; } + /// + /// 1MB default max trust list size + /// + private const int kDefaultMaxTrustListSize = 1 * 1024 * 1024; + /// /// Gets the application. /// @@ -1442,7 +1447,8 @@ public TrustListDataType ReadTrustList(NodeId trustListId) /// /// Reads the trust list. /// - public async Task ReadTrustListAsync(NodeId trustListId, CancellationToken ct = default) + /// + public async Task ReadTrustListAsync(NodeId trustListId, long maxTrustListSize = 0, CancellationToken ct = default) { ISession session = await ConnectIfNeededAsync(ct).ConfigureAwait(false); @@ -1456,6 +1462,14 @@ public async Task ReadTrustListAsync(NodeId trustListId, Canc using var ostrm = new MemoryStream(); try { + // Use a reasonable maximum size limit for trust lists + if (maxTrustListSize == 0) + { + maxTrustListSize = kDefaultMaxTrustListSize; + } + + long totalBytesRead = 0; + while (true) { const int length = 4096; @@ -1468,6 +1482,17 @@ public async Task ReadTrustListAsync(NodeId trustListId, Canc length).ConfigureAwait(false); byte[] bytes = (byte[])outputArguments[0]; + + // Validate total size before writing + totalBytesRead += bytes.Length; + if (totalBytesRead > maxTrustListSize) + { + throw ServiceResultException.Create( + StatusCodes.BadEncodingLimitsExceeded, + "Trust list size exceeds maximum allowed size of {0} bytes", + maxTrustListSize); + } + ostrm.Write(bytes, 0, bytes.Length); if (length != bytes.Length) diff --git a/Libraries/Opc.Ua.Gds.Client.Common/ServerPushConfigurationClient.cs b/Libraries/Opc.Ua.Gds.Client.Common/ServerPushConfigurationClient.cs index e2927da54..d70c73a52 100644 --- a/Libraries/Opc.Ua.Gds.Client.Common/ServerPushConfigurationClient.cs +++ b/Libraries/Opc.Ua.Gds.Client.Common/ServerPushConfigurationClient.cs @@ -64,6 +64,11 @@ public ServerPushConfigurationClient( }; } + /// + /// 1MB default max trust list size + /// + private const int kDefaultMaxTrustListSize = 1 * 1024 * 1024; + public NodeId DefaultApplicationGroup { get; private set; } public NodeId DefaultHttpsGroup { get; private set; } public NodeId DefaultUserTokenGroup { get; private set; } @@ -462,8 +467,10 @@ public TrustListDataType ReadTrustList(TrustListMasks masks = TrustListMasks.All /// /// Reads the trust list. /// + /// public async Task ReadTrustListAsync( TrustListMasks masks = TrustListMasks.All, + long maxTrustListSize = 0, CancellationToken ct = default) { ISession session = await ConnectIfNeededAsync(ct).ConfigureAwait(false); @@ -489,6 +496,14 @@ public async Task ReadTrustListAsync( using var ostrm = new MemoryStream(); try { + // Use a reasonable maximum size limit for trust lists + if (maxTrustListSize == 0) + { + maxTrustListSize = kDefaultMaxTrustListSize; + } + + long totalBytesRead = 0; + while (true) { const int length = 256; @@ -510,6 +525,17 @@ public async Task ReadTrustListAsync( .ConfigureAwait(false); byte[] bytes = (byte[])outputArguments[0]; + + // Validate total size before reading + totalBytesRead += bytes.Length; + if (totalBytesRead > maxTrustListSize) + { + throw ServiceResultException.Create( + StatusCodes.BadEncodingLimitsExceeded, + "Trust list size exceeds maximum allowed size of {0} bytes", + maxTrustListSize); + } + ostrm.Write(bytes, 0, bytes.Length); if (length != bytes.Length) @@ -581,7 +607,8 @@ public bool UpdateTrustList(TrustListDataType trustList) /// /// Updates the trust list. /// - public async Task UpdateTrustListAsync(TrustListDataType trustList, CancellationToken ct = default) + /// + public async Task UpdateTrustListAsync(TrustListDataType trustList, long maxTrustListSize = 0, CancellationToken ct = default) { ISession session = await ConnectIfNeededAsync(ct).ConfigureAwait(false); IUserIdentity oldUser = await ElevatePermissionsAsync(session, ct).ConfigureAwait(false); @@ -595,6 +622,22 @@ public async Task UpdateTrustListAsync(TrustListDataType trustList, Cancel } strm.Position = 0; + // Use a reasonable maximum size limit for trust lists + if (maxTrustListSize == 0) + { + maxTrustListSize = kDefaultMaxTrustListSize; + } + + // Validate trust list size before attempting to write + if (strm.Length > maxTrustListSize) + { + throw ServiceResultException.Create( + StatusCodes.BadEncodingLimitsExceeded, + "Trust list size {0} exceeds maximum allowed size of {1} bytes", + strm.Length, + maxTrustListSize); + } + System.Collections.Generic.IList outputArguments = await session.CallAsync( ExpandedNodeId.ToNodeId( Ua.ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup_TrustList, diff --git a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs index fde4bcd1a..8eae6cba7 100644 --- a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs +++ b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs @@ -319,7 +319,8 @@ .. configuration.ServerConfiguration.SupportedPrivateKeyFormats certGroup.IssuerStore, new TrustList.SecureAccess(HasApplicationSecureAdminAccess), new TrustList.SecureAccess(HasApplicationSecureAdminAccess), - Server.Telemetry); + Server.Telemetry, + m_configuration.ServerConfiguration.MaxTrustListSize); certGroup.Node.ClearChangeMasks(systemContext, true); } diff --git a/Libraries/Opc.Ua.Server/Configuration/TrustList.cs b/Libraries/Opc.Ua.Server/Configuration/TrustList.cs index 4cdd06754..481eb8bcf 100644 --- a/Libraries/Opc.Ua.Server/Configuration/TrustList.cs +++ b/Libraries/Opc.Ua.Server/Configuration/TrustList.cs @@ -43,7 +43,12 @@ namespace Opc.Ua.Server /// public class TrustList { - private const int kDefaultTrustListCapacity = 0x10000; + private const int kDefaultTrustListCapacity = 1 * 1024 * 1024; + + /// + /// 1MB default max trust list size + /// + private const int kDefaultMaxTrustListSize = 1 * 1024 * 1024; /// /// Initialize the trustlist with default values. @@ -54,7 +59,8 @@ public TrustList( CertificateStoreIdentifier issuerListStore, SecureAccess readAccess, SecureAccess writeAccess, - ITelemetryContext telemetry) + ITelemetryContext telemetry, + int maxTrustListSize = 0) { m_telemetry = telemetry; m_logger = telemetry.CreateLogger(); @@ -63,6 +69,8 @@ public TrustList( m_issuerStore = issuerListStore; m_readAccess = readAccess; m_writeAccess = writeAccess; + // If maxTrustListSize is 0 (unlimited), use a sensible default limit + m_maxTrustListSize = maxTrustListSize > 0 ? maxTrustListSize : kDefaultMaxTrustListSize; node.Open.OnCall = new OpenMethodStateMethodCallHandler(Open); node.OpenWithMasks.OnCall @@ -155,6 +163,7 @@ private ServiceResult Open( m_readMode = mode == OpenFileMode.Read; m_sessionId = (context as ISessionSystemContext)?.SessionId; fileHandle = ++m_fileHandle; + m_totalBytesProcessed = 0; // Reset counter for new file operation var trustList = new TrustListDataType { SpecifiedLists = (uint)masks }; @@ -264,11 +273,22 @@ private ServiceResult Read( "Invalid file handle"); } + // Check if we would exceed the maximum trust list size + if (m_totalBytesProcessed + length > m_maxTrustListSize) + { + return ServiceResult.Create( + StatusCodes.BadEncodingLimitsExceeded, + "Trust list size exceeds maximum allowed size of {0} bytes", + m_maxTrustListSize); + } + data = new byte[length]; int bytesRead = m_strm.Read(data, 0, length); Debug.Assert(bytesRead >= 0); + m_totalBytesProcessed += bytesRead; + if (bytesRead < length) { byte[] bytes = new byte[bytesRead]; @@ -302,7 +322,17 @@ private ServiceResult Write( return StatusCodes.BadInvalidArgument; } + // Check if we would exceed the maximum trust list size + if (m_totalBytesProcessed + data.Length > m_maxTrustListSize) + { + return ServiceResult.Create( + StatusCodes.BadEncodingLimitsExceeded, + "Trust list size exceeds maximum allowed size of {0} bytes", + m_maxTrustListSize); + } + m_strm.Write(data, 0, data.Length); + m_totalBytesProcessed += data.Length; } return ServiceResult.Good; @@ -825,5 +855,7 @@ private void HasSecureWriteAccess(ISystemContext context) private readonly TrustListState m_node; private MemoryStream m_strm; private bool m_readMode; + private readonly int m_maxTrustListSize; + private long m_totalBytesProcessed; } } diff --git a/Tests/Opc.Ua.Gds.Tests/Common.cs b/Tests/Opc.Ua.Gds.Tests/Common.cs index da14daddf..23c499375 100644 --- a/Tests/Opc.Ua.Gds.Tests/Common.cs +++ b/Tests/Opc.Ua.Gds.Tests/Common.cs @@ -399,7 +399,8 @@ public static string PatchOnlyGDSEndpointUrlPort(string url, int port) public static async Task StartGDSAsync( bool clean, - string storeType = CertificateStoreType.Directory) + string storeType = CertificateStoreType.Directory, + int maxTrustListSize = 0) { GlobalDiscoveryTestServer server = null; int testPort = ServerFixtureUtils.GetNextFreeIPPort(); @@ -409,7 +410,7 @@ public static async Task StartGDSAsync( { try { - server = new GlobalDiscoveryTestServer(true, NUnitTelemetryContext.Create(true)); + server = new GlobalDiscoveryTestServer(true, NUnitTelemetryContext.Create(true), maxTrustListSize); await server.StartServerAsync(clean, testPort, storeType).ConfigureAwait(false); } catch (ServiceResultException sre) diff --git a/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs b/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs index 5816d8d00..f368e58c2 100644 --- a/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs +++ b/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs @@ -47,11 +47,12 @@ public class GlobalDiscoveryTestServer public ApplicationConfiguration Config { get; private set; } public int BasePort { get; private set; } - public GlobalDiscoveryTestServer(bool autoAccept, ITelemetryContext telemetry) + public GlobalDiscoveryTestServer(bool autoAccept, ITelemetryContext telemetry, int maxTrustListSize) { s_autoAccept = autoAccept; m_telemetry = telemetry; m_logger = telemetry.CreateLogger(); + m_maxTrustListSize = maxTrustListSize; } public async Task StartServerAsync( @@ -79,7 +80,7 @@ public async Task StartServerAsync( }; BasePort = basePort; - Config = await LoadAsync(Application, basePort).ConfigureAwait(false); + Config = await LoadAsync(Application, basePort, m_maxTrustListSize).ConfigureAwait(false); if (clean) { @@ -110,7 +111,7 @@ await TestUtils Config.SecurityConfiguration.RejectedCertificateStore, m_telemetry) .ConfigureAwait(false); - Config = await LoadAsync(Application, basePort).ConfigureAwait(false); + Config = await LoadAsync(Application, basePort, m_maxTrustListSize).ConfigureAwait(false); } // check the application certificate. @@ -237,7 +238,8 @@ private static void RegisterDefaultUsers(IUserDatabase userDatabase) private static async Task LoadAsync( ApplicationInstance application, - int basePort) + int basePort, + int maxTrustListSize) { #if !USE_FILE_CONFIG // load the application configuration. @@ -313,6 +315,9 @@ private static async Task LoadAsync( .CreateAsync() .ConfigureAwait(false); #endif + + config.ServerConfiguration.MaxTrustListSize = maxTrustListSize; + TestUtils.PatchBaseAddressesPorts(config, basePort); return config; } @@ -320,5 +325,6 @@ private static async Task LoadAsync( private static bool s_autoAccept; private readonly ITelemetryContext m_telemetry; private readonly ILogger m_logger; + private readonly int m_maxTrustListSize = 0; } } diff --git a/Tests/Opc.Ua.Gds.Tests/PushTest.cs b/Tests/Opc.Ua.Gds.Tests/PushTest.cs index a4e3ee280..29b965fe4 100644 --- a/Tests/Opc.Ua.Gds.Tests/PushTest.cs +++ b/Tests/Opc.Ua.Gds.Tests/PushTest.cs @@ -1053,6 +1053,7 @@ private async Task RegisterPushServerApplicationAsync( NodeId trustListId = await m_gdsClient.GDSClient.GetTrustListAsync(id, null, ct).ConfigureAwait(false); TrustListDataType trustList = await m_gdsClient.GDSClient.ReadTrustListAsync( trustListId, + 0, ct).ConfigureAwait(false); bool result = await AddTrustListToStoreAsync( m_gdsClient.Configuration.SecurityConfiguration, diff --git a/Tests/Opc.Ua.Gds.Tests/TrustListValidationTest.cs b/Tests/Opc.Ua.Gds.Tests/TrustListValidationTest.cs new file mode 100644 index 000000000..06b71ea0e --- /dev/null +++ b/Tests/Opc.Ua.Gds.Tests/TrustListValidationTest.cs @@ -0,0 +1,305 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Tests; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace Opc.Ua.Gds.Tests +{ + [TestFixture] + [Category("GDSPush")] + [Category("GDS")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [NonParallelizable] + public class TrustListValidationTest + { + private GlobalDiscoveryTestServer m_server; + private ServerConfigurationPushTestClient m_pushClient; + private ITelemetryContext m_telemetry; + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + // Start GDS server + m_telemetry = NUnitTelemetryContext.Create(); + m_server = await TestUtils.StartGDSAsync(true, CertificateStoreType.Directory).ConfigureAwait(false); + + // Load client + m_pushClient = new ServerConfigurationPushTestClient(true, m_telemetry); + await m_pushClient.LoadClientConfigurationAsync(m_server.BasePort).ConfigureAwait(false); + + // Set admin credentials and connect + await m_pushClient.ConnectAsync(SecurityPolicies.Aes256_Sha256_RsaPss).ConfigureAwait(false); + m_pushClient.PushClient.AdminCredentials = m_pushClient.SysAdminUser; + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + try + { + await m_pushClient.DisconnectClientAsync().ConfigureAwait(false); + await m_server.StopServerAsync().ConfigureAwait(false); + } + catch + { + } + finally + { + m_pushClient?.Dispose(); + m_pushClient = null; + m_server = null; + } + } + + /// + /// Test that normal-sized trust lists work correctly. + /// + [Test] + [Order(100)] + public async Task NormalSizeTrustListAsync() + { + // Create a normal-sized trust list + var normalTrustList = new TrustListDataType + { + SpecifiedLists = (uint)TrustListMasks.TrustedCertificates, + TrustedCertificates = [], + TrustedCrls = [], + IssuerCertificates = [], + IssuerCrls = [] + }; + + // Add a reasonable number of certificates (10) + for (int i = 0; i < 10; i++) + { + using X509Certificate2 cert = CertificateFactory + .CreateCertificate($"urn:test:cert{i}", $"NormalCert{i}", $"CN=NormalCert{i}, O=OPC Foundation", null) + .CreateForRSA(); + normalTrustList.TrustedCertificates.Add(cert.RawData); + } + + // This should succeed + bool requireReboot = await m_pushClient.PushClient.UpdateTrustListAsync(normalTrustList).ConfigureAwait(false); + Assert.False(requireReboot); + + // Read it back to verify + TrustListDataType readTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false); + Assert.IsNotNull(readTrustList); + Assert.AreEqual(normalTrustList.TrustedCertificates.Count, readTrustList.TrustedCertificates.Count); + } + + /// + /// Test that writing a trust list exceeding the size limit fails. + /// + [Test] + [Order(200)] + public async Task WriteTrustListExceedsSizeLimitAsync() + { + // Create a trust list with a few certificates + var oversizedTrustList = new TrustListDataType + { + SpecifiedLists = (uint)TrustListMasks.TrustedCertificates, + TrustedCertificates = [], + TrustedCrls = [], + IssuerCertificates = [], + IssuerCrls = [] + }; + + for (int i = 0; i < 20; i++) + { + using X509Certificate2 cert = CertificateFactory + .CreateCertificate($"urn:test:cert{i}", $"TestCert{i}", $"CN=TestCert{i}, O=OPC Foundation", null) + .SetRSAKeySize(2048) + .CreateForRSA(); + oversizedTrustList.TrustedCertificates.Add(cert.RawData); + } + + // Calculate the encoded size + long encodedSize = GetEncodedSize(oversizedTrustList); + TestContext.Out.WriteLine($"Generated trust list with encoded size: {encodedSize} bytes."); + + // Set the client's max trust list size to be smaller than the actual size + uint maxTrustListSize = (uint)encodedSize - 1; + TestContext.Out.WriteLine($"Client MaxTrustListSize set to: {maxTrustListSize}"); + + // This should throw ServiceResultException with BadEncodingLimitsExceeded + ServiceResultException ex = Assert.ThrowsAsync( + async () => await m_pushClient.PushClient.UpdateTrustListAsync(oversizedTrustList, maxTrustListSize).ConfigureAwait(false)); + + Assert.IsNotNull(ex); + Assert.AreEqual(StatusCodes.BadEncodingLimitsExceeded, ex.StatusCode); + TestContext.Out.WriteLine($"Expected exception caught: {ex.Message}"); + } + + /// + /// Test boundary condition - trust list just under the limit. + /// + [Test] + [Order(300)] + public async Task TrustListJustUnderLimitAsync() + { + var boundaryTrustList = new TrustListDataType + { + SpecifiedLists = (uint)TrustListMasks.TrustedCertificates, + TrustedCertificates = [], + TrustedCrls = [], + IssuerCertificates = [], + IssuerCrls = [] + }; + + for (int i = 0; i < 20; i++) + { + using X509Certificate2 cert = CertificateFactory + .CreateCertificate($"urn:test:cert{i}", $"BoundaryCert{i}", $"CN=BoundaryCert{i}, O=OPC Foundation", null) + .SetRSAKeySize(2048) + .CreateForRSA(); + boundaryTrustList.TrustedCertificates.Add(cert.RawData); + } + + // Calculate the encoded size + long encodedSize = GetEncodedSize(boundaryTrustList); + TestContext.Out.WriteLine($"Generated trust list with encoded size: {encodedSize} bytes."); + + // Set the client's max trust list size to be exactly the encoded size (should pass) + uint maxTrustListSize = (uint)encodedSize; + TestContext.Out.WriteLine($"Client MaxTrustListSize set to: {maxTrustListSize}"); + + // This should succeed + bool requireReboot = await m_pushClient.PushClient.UpdateTrustListAsync(boundaryTrustList, maxTrustListSize).ConfigureAwait(false); + Assert.False(requireReboot); + + // Read it back + TrustListDataType readTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false); + Assert.IsNotNull(readTrustList); + Assert.AreEqual(boundaryTrustList.TrustedCertificates.Count, readTrustList.TrustedCertificates.Count); + } + + /// + /// Test reading and writing with a custom MaxTrustListSize set in the ServerConfiguration. + /// + [Test] + [Order(400)] + public async Task ReadWriteWithCustomServerMaxTrustListSizeAsync() + { + // Define a custom size limit for the server + const int customMaxTrustListSize = 8192; // 8 KB + + // Update server configuration + await m_server.StopServerAsync().ConfigureAwait(false); + m_server = await TestUtils.StartGDSAsync(false, CertificateStoreType.Directory, customMaxTrustListSize).ConfigureAwait(false); + await m_pushClient.LoadClientConfigurationAsync(m_server.BasePort).ConfigureAwait(false); + await m_pushClient.ConnectAsync(SecurityPolicies.Aes256_Sha256_RsaPss).ConfigureAwait(false); + m_pushClient.PushClient.AdminCredentials = m_pushClient.SysAdminUser; + + TestContext.Out.WriteLine($"Server MaxTrustListSize set to: {customMaxTrustListSize}"); + + try + { + // 1. Test writing a trust list that exceeds the server's limit + var oversizedTrustList = new TrustListDataType + { + SpecifiedLists = (uint)TrustListMasks.TrustedCertificates, + TrustedCertificates = [] + }; + + long currentSize = 0; + int certCount = 0; + while (currentSize <= customMaxTrustListSize) + { + using X509Certificate2 cert = + CertificateFactory.CreateCertificate($"urn:test:oversized{certCount}", "Oversized", "CN=Oversized", null).CreateForRSA(); + oversizedTrustList.TrustedCertificates.Add(cert.RawData); + currentSize = GetEncodedSize(oversizedTrustList); + certCount++; + } + TestContext.Out.WriteLine($"Oversized trust list created with {certCount} certs and size {currentSize}"); + + ServiceResultException ex = Assert.ThrowsAsync(async () => + await m_pushClient.PushClient.UpdateTrustListAsync(oversizedTrustList).ConfigureAwait(false)); + Assert.AreEqual(StatusCodes.BadEncodingLimitsExceeded, ex.StatusCode); + TestContext.Out.WriteLine("Successfully caught exception for writing oversized trust list to server."); + + // 2. Test writing a valid trust list (under the server's limit) + var validTrustList = new TrustListDataType + { + SpecifiedLists = (uint)TrustListMasks.TrustedCertificates, + TrustedCertificates = [] + }; + for (int i = 0; i < 2; i++) + { + using X509Certificate2 cert = CertificateFactory.CreateCertificate($"urn:test:valid{i}", "Valid", "CN=Valid", null).CreateForRSA(); + validTrustList.TrustedCertificates.Add(cert.RawData); + } + long validSize = GetEncodedSize(validTrustList); + Assert.True(validSize < customMaxTrustListSize); + TestContext.Out.WriteLine($"Valid trust list created with size {validSize}"); + + bool reboot = await m_pushClient.PushClient.UpdateTrustListAsync(validTrustList).ConfigureAwait(false); + Assert.False(reboot); + TestContext.Out.WriteLine("Successfully wrote valid trust list to server."); + + // 3. Test reading the trust list with a client limit that is too small + ServiceResultException exRead = Assert.ThrowsAsync(async () => + await m_pushClient.PushClient.ReadTrustListAsync(TrustListMasks.TrustedCertificates, (uint)validSize - 1).ConfigureAwait(false)); + Assert.AreEqual(StatusCodes.BadEncodingLimitsExceeded, exRead.StatusCode); + TestContext.Out.WriteLine("Successfully caught exception for reading trust list with small client limit."); + + // 4. Test reading with a sufficient client limit + TrustListDataType readTrustList = await m_pushClient.PushClient + .ReadTrustListAsync(TrustListMasks.TrustedCertificates, (uint)validSize).ConfigureAwait(false); + Assert.IsNotNull(readTrustList); + Assert.AreEqual(validTrustList.TrustedCertificates.Count, readTrustList.TrustedCertificates.Count); + TestContext.Out.WriteLine("Successfully read trust list with sufficient client limit."); + } + finally + { + // Restore original server configuration + await m_server.StopServerAsync().ConfigureAwait(false); + m_server = await TestUtils.StartGDSAsync(false, CertificateStoreType.Directory, 0).ConfigureAwait(false); + await m_pushClient.LoadClientConfigurationAsync(m_server.BasePort).ConfigureAwait(false); + await m_pushClient.ConnectAsync(SecurityPolicies.Aes256_Sha256_RsaPss).ConfigureAwait(false); + m_pushClient.PushClient.AdminCredentials = m_pushClient.SysAdminUser; + + TestContext.Out.WriteLine("Restored original server configuration."); + } + } + + private long GetEncodedSize(TrustListDataType trustList) + { + using var stream = new System.IO.MemoryStream(); + using var encoder = new BinaryEncoder(stream, m_pushClient.PushClient.Session.MessageContext, false); + encoder.WriteEncodeable(null, trustList, trustList.GetType()); + return stream.Length; + } + } +} From 7ec6788ecf569514fe4a44dcd48f53c23d039235 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:02:08 +0900 Subject: [PATCH 11/42] Bump NUnit.Console from 3.21.1 to 3.22.0 (#3440) --- updated-dependencies: - dependency-name: NUnit.Console dependency-version: 3.22.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ea401f805..e672dc64c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -28,7 +28,7 @@ - + From 5c80f9bc5ad34f34a46b1c0e50b13a666246ec8b Mon Sep 17 00:00:00 2001 From: romanett Date: Thu, 8 Jan 2026 13:12:07 +0100 Subject: [PATCH 12/42] [Client & Server] Remove all usages of SoftwareCertificates (#3443) * remove all usages of SoftwareCertificates - Client - Server As per spec they are not relevant for security. If an SDK User wants to use the SoftwareCertificates ActivateSession can be overriden * remove from SessionClientBatchTests --- Libraries/Opc.Ua.Client/Session/Session.cs | 86 +------------------ .../Opc.Ua.Server/Diagnostics/AuditEvents.cs | 21 ----- .../Opc.Ua.Server/Server/StandardServer.cs | 80 +---------------- Libraries/Opc.Ua.Server/Session/ISession.cs | 2 - .../Opc.Ua.Server/Session/ISessionManager.cs | 1 - Libraries/Opc.Ua.Server/Session/Session.cs | 25 ------ .../Opc.Ua.Server/Session/SessionManager.cs | 3 - .../Stack/Configuration/ServerProperties.cs | 6 -- .../Stack/Types/SoftwareCertificate.cs | 77 ----------------- .../SessionClientBatchTests.cs | 9 +- 10 files changed, 6 insertions(+), 304 deletions(-) delete mode 100644 Stack/Opc.Ua.Core/Stack/Types/SoftwareCertificate.cs diff --git a/Libraries/Opc.Ua.Client/Session/Session.cs b/Libraries/Opc.Ua.Client/Session/Session.cs index 73670373b..21d014100 100644 --- a/Libraries/Opc.Ua.Client/Session/Session.cs +++ b/Libraries/Opc.Ua.Client/Session/Session.cs @@ -1196,8 +1196,6 @@ await m_configuration byte[] serverCertificateData = response.ServerCertificate; SignatureData serverSignature = response.ServerSignature; EndpointDescriptionCollection serverEndpoints = response.ServerEndpoints; - SignedSoftwareCertificateCollection serverSoftwareCertificates = response - .ServerSoftwareCertificates; m_sessionTimeout = response.RevisedSessionTimeout; m_maxRequestMessageSize = response.MaxRequestMessageSize; @@ -1232,8 +1230,6 @@ await m_configuration clientCertificateChainData, clientNonce); - HandleSignedSoftwareCertificates(serverSoftwareCertificates); - // process additional header ProcessResponseAdditionalHeader(response.ResponseHeader, serverCertificate); @@ -1280,10 +1276,6 @@ await m_configuration m_instanceCertificateChain, m_endpoint.Description.SecurityMode != MessageSecurityMode.None); - // send the software certificates assigned to the client. - SignedSoftwareCertificateCollection clientSoftwareCertificates - = GetSoftwareCertificates(); - // copy the preferred locales if provided. if (preferredLocales != null && preferredLocales.Count > 0) { @@ -1294,7 +1286,7 @@ SignedSoftwareCertificateCollection clientSoftwareCertificates ActivateSessionResponse activateResponse = await ActivateSessionAsync( null, clientSignature, - clientSoftwareCertificates, + null, m_preferredLocales, new ExtensionObject(identityToken), userTokenSignature, @@ -1320,12 +1312,6 @@ SignedSoftwareCertificateCollection clientSoftwareCertificates } } - if (clientSoftwareCertificates?.Count > 0 && - (certificateResults == null || certificateResults.Count == 0)) - { - m_logger.LogInformation("Empty results were received for the ActivateSession call."); - } - // fetch namespaces. await FetchNamespaceTablesAsync(ct).ConfigureAwait(false); @@ -1487,14 +1473,10 @@ public async Task UpdateSessionAsync( m_instanceCertificateChain, m_endpoint.Description.SecurityMode != MessageSecurityMode.None); - // send the software certificates assigned to the client. - SignedSoftwareCertificateCollection clientSoftwareCertificates - = GetSoftwareCertificates(); - ActivateSessionResponse response = await ActivateSessionAsync( null, clientSignature, - clientSoftwareCertificates, + null, preferredLocales, new ExtensionObject(identityToken), userTokenSignature, @@ -2339,10 +2321,6 @@ public async Task ReconnectAsync( m_instanceCertificateChain, m_endpoint.Description.SecurityMode != MessageSecurityMode.None); - // send the software certificates assigned to the client. - SignedSoftwareCertificateCollection clientSoftwareCertificates - = GetSoftwareCertificates(); - m_logger.LogInformation("Session REPLACING channel for {SessionId}.", SessionId); if (connection != null) @@ -2640,14 +2618,6 @@ public bool RemoveTransferredSubscription(Subscription subscription) return true; } - /// - /// Returns the software certificates assigned to the application. - /// - protected virtual SignedSoftwareCertificateCollection GetSoftwareCertificates() - { - return []; - } - /// /// Handles an error when validating the application instance certificate provided by the server. /// @@ -2659,26 +2629,6 @@ protected virtual void OnApplicationCertificateError( throw new ServiceResultException(result); } - /// - /// Handles an error when validating software certificates provided by the server. - /// - /// - protected virtual void OnSoftwareCertificateError( - SignedSoftwareCertificate signedCertificate, - ServiceResult result) - { - throw new ServiceResultException(result); - } - - /// - /// Inspects the software certificates provided by the server. - /// - protected virtual void ValidateSoftwareCertificates( - List softwareCertificates) - { - // always accept valid certificates. - } - /// /// Starts a timer to check that the connection to the server is still available. /// @@ -4175,38 +4125,6 @@ private static void UpdateDescription( return currentToken?.ServerNonce; } - /// - /// Handles the validation of server software certificates and application callback. - /// - private void HandleSignedSoftwareCertificates( - SignedSoftwareCertificateCollection serverSoftwareCertificates) - { - // get a validator to check certificates provided by server. - CertificateValidator validator = m_configuration.CertificateValidator; - - // validate software certificates. - var softwareCertificates = new List(); - - foreach (SignedSoftwareCertificate signedCertificate in serverSoftwareCertificates) - { - ServiceResult result = SoftwareCertificate.Validate( - validator, - signedCertificate.CertificateData, - m_telemetry, - out SoftwareCertificate softwareCertificate); - - if (ServiceResult.IsBad(result)) - { - OnSoftwareCertificateError(signedCertificate, result); - } - - softwareCertificates.Add(softwareCertificate); - } - - // check if software certificates meet application requirements. - ValidateSoftwareCertificates(softwareCertificates); - } - /// /// Processes the response from a publish request. /// diff --git a/Libraries/Opc.Ua.Server/Diagnostics/AuditEvents.cs b/Libraries/Opc.Ua.Server/Diagnostics/AuditEvents.cs index 73ff4e8a7..a3d0d17df 100644 --- a/Libraries/Opc.Ua.Server/Diagnostics/AuditEvents.cs +++ b/Libraries/Opc.Ua.Server/Diagnostics/AuditEvents.cs @@ -1021,14 +1021,12 @@ public static void ReportAuditCreateSessionEvent( /// A contextual logger to log to /// The audit entry id. /// The session that is activated. - /// The software certificates /// The exception received during activate session request public static void ReportAuditActivateSessionEvent( this IAuditEventServer server, ILogger logger, string auditEntryId, ISession session, - IList softwareCertificates, Exception exception = null) { if (server?.Auditing != true) @@ -1078,25 +1076,6 @@ public static void ReportAuditActivateSessionEvent( Utils.Clone(session?.IdentityToken), false); - if (softwareCertificates != null) - { - // build the list of SignedSoftwareCertificate - var signedSoftwareCertificates = new List(); - foreach (SoftwareCertificate softwareCertificate in softwareCertificates) - { - var item = new SignedSoftwareCertificate - { - CertificateData = softwareCertificate.SignedCertificate.RawData - }; - signedSoftwareCertificates.Add(item); - } - e.SetChildValue( - systemContext, - BrowseNames.ClientSoftwareCertificates, - signedSoftwareCertificates.ToArray(), - false); - } - server.ReportAuditEvent(systemContext, e); } catch (Exception e) diff --git a/Libraries/Opc.Ua.Server/Server/StandardServer.cs b/Libraries/Opc.Ua.Server/Server/StandardServer.cs index 834e47ea4..6a0511729 100644 --- a/Libraries/Opc.Ua.Server/Server/StandardServer.cs +++ b/Libraries/Opc.Ua.Server/Server/StandardServer.cs @@ -346,7 +346,6 @@ public override async Task CreateSessionAsync( byte[] serverNonce; byte[] serverCertificate = null; EndpointDescriptionCollection serverEndpoints = null; - SignedSoftwareCertificateCollection serverSoftwareCertificates = null; SignatureData serverSignature = null; uint maxRequestMessageSize = (uint)MessageContext.MaxMessageSize; @@ -528,9 +527,6 @@ X509Certificate2Collection clientCertificateChain // return the endpoints supported by the server. serverEndpoints = GetEndpointDescriptions(endpointUrl, BaseAddresses, null); - // return the software certificates assigned to the server. - serverSoftwareCertificates = [.. ServerProperties.SoftwareCertificates]; - // sign the nonce provided by the client. serverSignature = null; @@ -580,7 +576,6 @@ X509Certificate2Collection clientCertificateChain ServerNonce = serverNonce, ServerCertificate = serverCertificate, ServerEndpoints = serverEndpoints, - ServerSoftwareCertificates = serverSoftwareCertificates, ServerSignature = serverSignature, MaxRequestMessageSize = maxRequestMessageSize }; @@ -724,75 +719,14 @@ public override async Task ActivateSessionAsync( DiagnosticInfoCollection diagnosticInfos = null; OperationContext context = ValidateRequest(secureChannelContext, requestHeader, RequestType.ActivateSession); - // validate client's software certificates. - var softwareCertificates = new List(); try { - if (context?.SecurityPolicyUri != SecurityPolicies.None) - { - bool diagnosticsExist = false; - - if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0) - { - diagnosticInfos = []; - } - - results = []; - diagnosticInfos = []; - - foreach (SignedSoftwareCertificate signedCertificate in clientSoftwareCertificates) - { - ServiceResult result = SoftwareCertificate.Validate( - CertificateValidator, - signedCertificate.CertificateData, - m_serverInternal.Telemetry, - out SoftwareCertificate softwareCertificate); - - if (ServiceResult.IsBad(result)) - { - results.Add(result.Code); - - // add diagnostics if requested. - if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0) - { - DiagnosticInfo diagnosticInfo = ServerUtils.CreateDiagnosticInfo( - ServerInternal, - context, - result, - m_logger); - diagnosticInfos.Add(diagnosticInfo); - diagnosticsExist = true; - } - } - else - { - softwareCertificates.Add(softwareCertificate); - results.Add(StatusCodes.Good); - - // add diagnostics if requested. - if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0) - { - diagnosticInfos.Add(null); - } - } - } - - if (!diagnosticsExist && diagnosticInfos != null) - { - diagnosticInfos.Clear(); - } - } - - // check if certificates meet the server's requirements. - ValidateSoftwareCertificates(softwareCertificates); - // activate the session. (bool identityChanged, serverNonce) = await ServerInternal.SessionManager.ActivateSessionAsync( context, requestHeader.AuthenticationToken, clientSignature, - softwareCertificates, userIdentityToken, userTokenSignature, localeIds, @@ -817,8 +751,7 @@ public override async Task ActivateSessionAsync( ServerInternal.ReportAuditActivateSessionEvent( m_logger, context?.AuditEntryId, - session, - softwareCertificates); + session); ResponseHeader responseHeader = CreateResponse(requestHeader, StatusCodes.Good); @@ -845,7 +778,6 @@ public override async Task ActivateSessionAsync( m_logger, context?.AuditEntryId, session, - softwareCertificates, e); lock (ServerInternal.DiagnosticsWriteLock) @@ -2728,16 +2660,6 @@ protected virtual void OnApplicationCertificateError( throw new ServiceResultException(result); } - /// - /// Inspects the software certificates provided by the server. - /// - /// The software certificates. - protected virtual void ValidateSoftwareCertificates( - List softwareCertificates) - { - // always accept valid certificates. - } - /// /// Verifies that the request header is valid. /// diff --git a/Libraries/Opc.Ua.Server/Session/ISession.cs b/Libraries/Opc.Ua.Server/Session/ISession.cs index 020ae48e6..b4681d8a5 100644 --- a/Libraries/Opc.Ua.Server/Session/ISession.cs +++ b/Libraries/Opc.Ua.Server/Session/ISession.cs @@ -113,7 +113,6 @@ public interface ISession : IDisposable /// bool Activate( OperationContext context, - List clientSoftwareCertificates, UserIdentityToken identityToken, IUserIdentity identity, IUserIdentity effectiveIdentity, @@ -187,7 +186,6 @@ bool Activate( void ValidateBeforeActivate( OperationContext context, SignatureData clientSignature, - List clientSoftwareCertificates, ExtensionObject userIdentityToken, SignatureData userTokenSignature, out UserIdentityToken identityToken, diff --git a/Libraries/Opc.Ua.Server/Session/ISessionManager.cs b/Libraries/Opc.Ua.Server/Session/ISessionManager.cs index 29ee3ac84..a090db803 100644 --- a/Libraries/Opc.Ua.Server/Session/ISessionManager.cs +++ b/Libraries/Opc.Ua.Server/Session/ISessionManager.cs @@ -123,7 +123,6 @@ ValueTask CreateSessionAsync( OperationContext context, NodeId authenticationToken, SignatureData clientSignature, - List clientSoftwareCertificates, ExtensionObject userIdentityToken, SignatureData userTokenSignature, StringCollection localeIds, diff --git a/Libraries/Opc.Ua.Server/Session/Session.cs b/Libraries/Opc.Ua.Server/Session/Session.cs index fb348f74e..bc18ce119 100644 --- a/Libraries/Opc.Ua.Server/Session/Session.cs +++ b/Libraries/Opc.Ua.Server/Session/Session.cs @@ -438,7 +438,6 @@ public bool UpdateLocaleIds(StringCollection localeIds) public void ValidateBeforeActivate( OperationContext context, SignatureData clientSignature, - List clientSoftwareCertificates, ExtensionObject userIdentityToken, SignatureData userTokenSignature, out UserIdentityToken identityToken, @@ -531,14 +530,6 @@ public void ValidateBeforeActivate( throw new ServiceResultException(StatusCodes.BadSecureChannelIdInvalid); } } - else - { - // cannot change the certificates after activation. - if (clientSoftwareCertificates != null && clientSoftwareCertificates.Count > 0) - { - throw new ServiceResultException(StatusCodes.BadInvalidArgument); - } - } // validate the user identity token. identityToken = ValidateUserIdentityToken( @@ -555,7 +546,6 @@ public void ValidateBeforeActivate( /// public bool Activate( OperationContext context, - List clientSoftwareCertificates, UserIdentityToken identityToken, IUserIdentity identity, IUserIdentity effectiveIdentity, @@ -597,21 +587,6 @@ public bool Activate( // update server nonce. m_serverNonce = serverNonce; - // build list of signed certificates for audit event. - var signedSoftwareCertificates = new List(); - - if (clientSoftwareCertificates != null) - { - foreach (SoftwareCertificate softwareCertificate in clientSoftwareCertificates) - { - var item = new SignedSoftwareCertificate - { - CertificateData = softwareCertificate.SignedCertificate.RawData - }; - signedSoftwareCertificates.Add(item); - } - } - // update the contact time. lock (DiagnosticsLock) { diff --git a/Libraries/Opc.Ua.Server/Session/SessionManager.cs b/Libraries/Opc.Ua.Server/Session/SessionManager.cs index a087b3bf8..b725e6a2d 100644 --- a/Libraries/Opc.Ua.Server/Session/SessionManager.cs +++ b/Libraries/Opc.Ua.Server/Session/SessionManager.cs @@ -283,7 +283,6 @@ public virtual async ValueTask CreateSessionAsync( OperationContext context, NodeId authenticationToken, SignatureData clientSignature, - List clientSoftwareCertificates, ExtensionObject userIdentityToken, SignatureData userTokenSignature, StringCollection localeIds, @@ -332,7 +331,6 @@ await m_semaphoreSlim.WaitAsync(cancellationToken) session.ValidateBeforeActivate( context, clientSignature, - clientSoftwareCertificates, userIdentityToken, userTokenSignature, out newIdentity, @@ -400,7 +398,6 @@ await m_semaphoreSlim.WaitAsync(cancellationToken) bool contextChanged = session.Activate( context, - clientSoftwareCertificates, newIdentity, identity, effectiveIdentity, diff --git a/Stack/Opc.Ua.Core/Stack/Configuration/ServerProperties.cs b/Stack/Opc.Ua.Core/Stack/Configuration/ServerProperties.cs index e019ca018..18b1aca96 100644 --- a/Stack/Opc.Ua.Core/Stack/Configuration/ServerProperties.cs +++ b/Stack/Opc.Ua.Core/Stack/Configuration/ServerProperties.cs @@ -48,7 +48,6 @@ public ServerProperties() BuildNumber = string.Empty; BuildDate = DateTime.MinValue; DatatypeAssemblies = []; - SoftwareCertificates = []; } /// @@ -85,10 +84,5 @@ public ServerProperties() /// The assemblies that contain encodeable types that could be uses a variable values. /// public StringCollection DatatypeAssemblies { get; } - - /// - /// The software certificates granted to the server. - /// - public SignedSoftwareCertificateCollection SoftwareCertificates { get; } } } diff --git a/Stack/Opc.Ua.Core/Stack/Types/SoftwareCertificate.cs b/Stack/Opc.Ua.Core/Stack/Types/SoftwareCertificate.cs deleted file mode 100644 index 7a8144b03..000000000 --- a/Stack/Opc.Ua.Core/Stack/Types/SoftwareCertificate.cs +++ /dev/null @@ -1,77 +0,0 @@ -/* ======================================================================== - * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. - * - * OPC Foundation MIT License 1.00 - * - * Permission is hereby granted, free of charge, to any person - * obtaining a copy of this software and associated documentation - * files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following - * conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - * - * The complete license agreement can be found here: - * http://opcfoundation.org/License/MIT/1.00/ - * ======================================================================*/ - -using System; -using System.IO; -using System.Runtime.Serialization; -using System.Security.Cryptography.X509Certificates; - -namespace Opc.Ua -{ - /// - /// The SoftwareCertificate class. - /// - public class SoftwareCertificate - { - /// - /// The SignedSoftwareCertificate that contains the SoftwareCertificate - /// - public X509Certificate2 SignedCertificate { get; set; } - - /// - /// Validates a software certificate. - /// - public static ServiceResult Validate( - CertificateValidator validator, - byte[] signedCertificate, - ITelemetryContext telemetry, - out SoftwareCertificate softwareCertificate) - { - softwareCertificate = null; - - // validate the certificate. - X509Certificate2 certificate; - try - { - certificate = CertificateFactory.Create(signedCertificate); - validator.ValidateAsync(certificate, default).GetAwaiter().GetResult(); - } - catch (Exception e) - { - return ServiceResult.Create( - e, - StatusCodes.BadDecodingError, - "Could not decode software certificate body."); - } - - // certificate is valid. - return ServiceResult.Good; - } - } -} diff --git a/Tests/Opc.Ua.Client.Tests/SessionClientBatchTests.cs b/Tests/Opc.Ua.Client.Tests/SessionClientBatchTests.cs index 443c4f0ee..9635885e7 100644 --- a/Tests/Opc.Ua.Client.Tests/SessionClientBatchTests.cs +++ b/Tests/Opc.Ua.Client.Tests/SessionClientBatchTests.cs @@ -64,7 +64,6 @@ public async Task ActivateSessionAsyncShouldSimplyCallBaseMethodWhenNoLimitsSetA RequestHeader requestHeader) { var clientSignature = new SignatureData(); - var clientSoftwareCertificates = new SignedSoftwareCertificateCollection(); var localeIds = new StringCollection(); var userIdentityToken = new ExtensionObject(); var userTokenSignature = new SignatureData(); @@ -81,7 +80,7 @@ public async Task ActivateSessionAsyncShouldSimplyCallBaseMethodWhenNoLimitsSetA ActivateSessionResponse response = await sessionMock.ActivateSessionAsync( requestHeader, clientSignature, - clientSoftwareCertificates, + null, localeIds, userIdentityToken, userTokenSignature, @@ -100,7 +99,6 @@ public void ActivateSessionAsyncShouldThrowExceptionWhenResponseContainsBadStatu RequestHeader requestHeader) { var clientSignature = new SignatureData(); - var clientSoftwareCertificates = new SignedSoftwareCertificateCollection(); var localeIds = new StringCollection(); var userIdentityToken = new ExtensionObject(); var userTokenSignature = new SignatureData(); @@ -124,7 +122,7 @@ public void ActivateSessionAsyncShouldThrowExceptionWhenResponseContainsBadStatu async () => await sessionMock.ActivateSessionAsync( requestHeader, clientSignature, - clientSoftwareCertificates, + null, localeIds, userIdentityToken, userTokenSignature, @@ -138,7 +136,6 @@ public void ActivateSessionAsyncShouldThrowExceptionWhenSendRequestAsyncThrows( RequestHeader requestHeader) { var clientSignature = new SignatureData(); - var clientSoftwareCertificates = new SignedSoftwareCertificateCollection(); var localeIds = new StringCollection(); var userIdentityToken = new ExtensionObject(); var userTokenSignature = new SignatureData(); @@ -156,7 +153,7 @@ public void ActivateSessionAsyncShouldThrowExceptionWhenSendRequestAsyncThrows( async () => await sessionMock.ActivateSessionAsync( requestHeader, clientSignature, - clientSoftwareCertificates, + null, localeIds, userIdentityToken, userTokenSignature, From a3a6709d6f45df0ca77c72f22c7f1e2587c81fd2 Mon Sep 17 00:00:00 2001 From: romanett Date: Thu, 8 Jan 2026 13:16:12 +0100 Subject: [PATCH 13/42] Refactor server for full async subscription management (#3442) Refactor to make OPC UA server subscription and monitored item management fully asynchronous, improving scalability and responsiveness. All key methods in ISubscription, ISubscriptionManager, and related classes are now async, with synchronous counterparts removed. --- .../ReferenceServer/ReferenceNodeManager.cs | 31 +- .../Configuration/ConfigurationNodeManager.cs | 15 - .../SystemConfigurationIdentity.cs | 50 ++++ .../CustomNodeManager.cs | 0 .../CustomNodeManagerAsync.cs | 0 .../NodeManager/MasterNodeManager.cs | 88 +----- .../Opc.Ua.Server/Server/IServerInternal.cs | 3 +- .../Server/ServerInternalData.cs | 8 +- .../Opc.Ua.Server/Server/StandardServer.cs | 272 ++++++++---------- .../Subscription/ISubscription.cs | 20 +- .../Subscription/ISubscriptionManager.cs | 40 ++- .../MonitoredItem/MonitoredItem.cs | 13 +- .../Subscription/Subscription.cs | 122 ++++---- .../Subscription/SubscriptionManager.cs | 134 +++++---- .../ClientTestFramework.cs | 4 +- Tests/Opc.Ua.Client.Tests/LoadTest.cs | 1 + 16 files changed, 381 insertions(+), 420 deletions(-) create mode 100644 Libraries/Opc.Ua.Server/Configuration/SystemConfigurationIdentity.cs rename Libraries/Opc.Ua.Server/{Diagnostics => NodeManager}/CustomNodeManager.cs (100%) rename Libraries/Opc.Ua.Server/{Diagnostics => NodeManager}/CustomNodeManagerAsync.cs (100%) diff --git a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs index 2b0717c9f..32b5d09e9 100644 --- a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs +++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs @@ -5068,26 +5068,23 @@ protected override NodeHandle GetManagerHandle( NodeId nodeId, IDictionary cache) { - lock (Lock) + // quickly exclude nodes that are not in the namespace. + if (!IsNodeIdInNamespace(nodeId)) { - // quickly exclude nodes that are not in the namespace. - if (!IsNodeIdInNamespace(nodeId)) - { - return null; - } - - if (!PredefinedNodes.TryGetValue(nodeId, out NodeState node)) - { - return null; - } + return null; + } - return new NodeHandle - { - NodeId = nodeId, - Node = node, - Validated = true - }; + if (!PredefinedNodes.TryGetValue(nodeId, out NodeState node)) + { + return null; } + + return new NodeHandle + { + NodeId = nodeId, + Node = node, + Validated = true + }; } /// diff --git a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs index 8eae6cba7..1a0059918 100644 --- a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs +++ b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs @@ -43,21 +43,6 @@ namespace Opc.Ua.Server { - /// - /// Privileged identity which can access the system configuration. - /// - public class SystemConfigurationIdentity : RoleBasedIdentity - { - /// - /// Create a user identity with the privilege - /// to modify the system configuration. - /// - /// The user identity. - public SystemConfigurationIdentity(IUserIdentity identity) - : base(identity, [Role.SecurityAdmin, Role.ConfigureAdmin]) - { - } - } /// /// The Server Configuration Node Manager. diff --git a/Libraries/Opc.Ua.Server/Configuration/SystemConfigurationIdentity.cs b/Libraries/Opc.Ua.Server/Configuration/SystemConfigurationIdentity.cs new file mode 100644 index 000000000..b43f8d893 --- /dev/null +++ b/Libraries/Opc.Ua.Server/Configuration/SystemConfigurationIdentity.cs @@ -0,0 +1,50 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +#if !NET9_0_OR_GREATER +#endif + +namespace Opc.Ua.Server +{ + /// + /// Privileged identity which can access the system configuration. + /// + public class SystemConfigurationIdentity : RoleBasedIdentity + { + /// + /// Create a user identity with the privilege + /// to modify the system configuration. + /// + /// The user identity. + public SystemConfigurationIdentity(IUserIdentity identity) + : base(identity, [Role.SecurityAdmin, Role.ConfigureAdmin]) + { + } + } +} diff --git a/Libraries/Opc.Ua.Server/Diagnostics/CustomNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs similarity index 100% rename from Libraries/Opc.Ua.Server/Diagnostics/CustomNodeManager.cs rename to Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs diff --git a/Libraries/Opc.Ua.Server/Diagnostics/CustomNodeManagerAsync.cs b/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManagerAsync.cs similarity index 100% rename from Libraries/Opc.Ua.Server/Diagnostics/CustomNodeManagerAsync.cs rename to Libraries/Opc.Ua.Server/NodeManager/CustomNodeManagerAsync.cs diff --git a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs index 38ebb8fee..0a672abc9 100644 --- a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs +++ b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs @@ -648,6 +648,7 @@ public virtual object GetManagerHandle(NodeId nodeId, out INodeManager nodeManag /// /// Returns node handle and its node manager. /// + [Obsolete("Use GetManagerHandleAsync instead.")] public virtual object GetManagerHandle(NodeId nodeId, out IAsyncNodeManager nodeManager) { (object handle, IAsyncNodeManager nodeManager) result = @@ -706,6 +707,7 @@ public virtual object GetManagerHandle(NodeId nodeId, out IAsyncNodeManager node /// /// Adds the references to the target. /// + [Obsolete("Use AddReferencesAsync instead.")] public virtual void AddReferences(NodeId sourceId, IList references) { AddReferencesAsync(sourceId, references).AsTask().GetAwaiter().GetResult(); @@ -890,6 +892,7 @@ public virtual void UnregisterNodes( /// /// is null. /// + [Obsolete("Use TranslateBrowsePathsToNodeIdsAsync instead.")] public virtual void TranslateBrowsePathsToNodeIds( OperationContext context, BrowsePathCollection browsePaths, @@ -2545,35 +2548,6 @@ await nodeManager.ConditionRefreshAsync(context, monitoredItems, cancellationTok } } - /// - /// Creates a set of monitored items. - /// - /// is null. - /// - /// - public virtual void CreateMonitoredItems( - OperationContext context, - uint subscriptionId, - double publishingInterval, - TimestampsToReturn timestampsToReturn, - IList itemsToCreate, - IList errors, - IList filterResults, - IList monitoredItems, - bool createDurable) - { - CreateMonitoredItemsAsync( - context, - subscriptionId, - publishingInterval, - timestampsToReturn, - itemsToCreate, - errors, - filterResults, - monitoredItems, - createDurable).AsTask().GetAwaiter().GetResult(); - } - /// /// Creates a set of monitored items. /// @@ -2983,28 +2957,6 @@ await manager.SubscribeToAllEventsAsync( } } - /// - /// Modifies a set of monitored items. - /// - /// is null. - /// - public virtual void ModifyMonitoredItems( - OperationContext context, - TimestampsToReturn timestampsToReturn, - IList monitoredItems, - IList itemsToModify, - IList errors, - IList filterResults) - { - ModifyMonitoredItemsAsync( - context, - timestampsToReturn, - monitoredItems, - itemsToModify, - errors, - filterResults).AsTask().GetAwaiter().GetResult(); - } - /// /// Modifies a set of monitored items. /// @@ -3203,23 +3155,6 @@ await nodeManager.SubscribeToAllEventsAsync( } } - /// - /// Transfers a set of monitored items. - /// - /// is null. - public virtual void TransferMonitoredItems( - OperationContext context, - bool sendInitialValues, - IList monitoredItems, - IList errors) - { - TransferMonitoredItemsAsync( - context, - sendInitialValues, - monitoredItems, - errors).AsTask().GetAwaiter().GetResult(); - } - /// /// Transfers a set of monitored items. /// @@ -3269,23 +3204,6 @@ await nodeManager.TransferMonitoredItemsAsync( } } - /// - /// Deletes a set of monitored items. - /// - /// is null. - public virtual void DeleteMonitoredItems( - OperationContext context, - uint subscriptionId, - IList itemsToDelete, - IList errors) - { - DeleteMonitoredItemsAsync( - context, - subscriptionId, - itemsToDelete, - errors).AsTask().GetAwaiter().GetResult(); - } - /// /// Deletes a set of monitored items. /// diff --git a/Libraries/Opc.Ua.Server/Server/IServerInternal.cs b/Libraries/Opc.Ua.Server/Server/IServerInternal.cs index dee2635d4..18f6d626f 100644 --- a/Libraries/Opc.Ua.Server/Server/IServerInternal.cs +++ b/Libraries/Opc.Ua.Server/Server/IServerInternal.cs @@ -240,7 +240,8 @@ ValueTask CloseSessionAsync( /// Deletes the specified subscription. /// /// The subscription identifier. - void DeleteSubscription(uint subscriptionId); + /// The cancellation token. + ValueTask DeleteSubscriptionAsync(uint subscriptionId, CancellationToken cancellationToken = default); /// /// Called by any component to report a global event. diff --git a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs index 6032cb8b0..d6096626c 100644 --- a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs +++ b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs @@ -494,7 +494,8 @@ public async ValueTask CloseSessionAsync( { await NodeManager.SessionClosingAsync(context, sessionId, deleteSubscriptions, cancellationToken) .ConfigureAwait(false); - SubscriptionManager.SessionClosing(context, sessionId, deleteSubscriptions); + await SubscriptionManager.SessionClosingAsync(context, sessionId, deleteSubscriptions, cancellationToken) + .ConfigureAwait(false); SessionManager.CloseSession(sessionId); } @@ -502,9 +503,10 @@ await NodeManager.SessionClosingAsync(context, sessionId, deleteSubscriptions, c /// Deletes the specified subscription. /// /// The subscription identifier. - public void DeleteSubscription(uint subscriptionId) + /// The cancellation token + public async ValueTask DeleteSubscriptionAsync(uint subscriptionId, CancellationToken cancellationToken = default) { - SubscriptionManager.DeleteSubscription(null, subscriptionId); + await SubscriptionManager.DeleteSubscriptionAsync(null, subscriptionId, cancellationToken).ConfigureAwait(false); } /// diff --git a/Libraries/Opc.Ua.Server/Server/StandardServer.cs b/Libraries/Opc.Ua.Server/Server/StandardServer.cs index 6a0511729..817a217e4 100644 --- a/Libraries/Opc.Ua.Server/Server/StandardServer.cs +++ b/Libraries/Opc.Ua.Server/Server/StandardServer.cs @@ -1474,7 +1474,7 @@ public override async Task HistoryUpdateAsync( /// /// Returns a object /// - public override Task CreateSubscriptionAsync( + public override async Task CreateSubscriptionAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, double requestedPublishingInterval, @@ -1492,7 +1492,7 @@ public override Task CreateSubscriptionAsync( try { - ServerInternal.SubscriptionManager.CreateSubscription( + CreateSubscriptionResponse response = await ServerInternal.SubscriptionManager.CreateSubscriptionAsync( context, requestedPublishingInterval, requestedLifetimeCount, @@ -1500,19 +1500,11 @@ public override Task CreateSubscriptionAsync( maxNotificationsPerPublish, publishingEnabled, priority, - out uint subscriptionId, - out double revisedPublishingInterval, - out uint revisedLifetimeCount, - out uint revisedMaxKeepAliveCount); + ct).ConfigureAwait(false); - return Task.FromResult(new CreateSubscriptionResponse - { - ResponseHeader = CreateResponse(requestHeader, context.StringTable), - SubscriptionId = subscriptionId, - RevisedPublishingInterval = revisedPublishingInterval, - RevisedLifetimeCount = revisedLifetimeCount, - RevisedMaxKeepAliveCount = revisedMaxKeepAliveCount - }); + response.ResponseHeader = CreateResponse(requestHeader, context.StringTable); + + return response; } catch (ServiceResultException e) { @@ -1542,7 +1534,7 @@ public override Task CreateSubscriptionAsync( /// The list of Subscriptions to transfer. /// If the initial values should be sent. /// The cancellation token. - public override Task TransferSubscriptionsAsync( + public override async Task TransferSubscriptionsAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, UInt32Collection subscriptionIds, @@ -1558,19 +1550,15 @@ public override Task TransferSubscriptionsAsync( { ValidateOperationLimits(subscriptionIds); - ServerInternal.SubscriptionManager.TransferSubscriptions( + TransferSubscriptionsResponse response = await ServerInternal.SubscriptionManager.TransferSubscriptionsAsync( context, subscriptionIds, sendInitialValues, - out TransferResultCollection results, - out DiagnosticInfoCollection diagnosticInfos); + ct).ConfigureAwait(false); - return Task.FromResult(new TransferSubscriptionsResponse - { - ResponseHeader = CreateResponse(requestHeader, context.StringTable), - Results = results, - DiagnosticInfos = diagnosticInfos - }); + response.ResponseHeader = CreateResponse(requestHeader, context.StringTable); + + return response; } catch (ServiceResultException e) { @@ -1602,7 +1590,7 @@ public override Task TransferSubscriptionsAsync( /// /// Returns a object /// - public override Task DeleteSubscriptionsAsync( + public override async Task DeleteSubscriptionsAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, UInt32Collection subscriptionIds, @@ -1617,18 +1605,14 @@ public override Task DeleteSubscriptionsAsync( { ValidateOperationLimits(subscriptionIds); - ServerInternal.SubscriptionManager.DeleteSubscriptions( + DeleteSubscriptionsResponse response = await ServerInternal.SubscriptionManager.DeleteSubscriptionsAsync( context, subscriptionIds, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos); + ct).ConfigureAwait(false); - return Task.FromResult(new DeleteSubscriptionsResponse - { - ResponseHeader = CreateResponse(requestHeader, context.StringTable), - Results = results, - DiagnosticInfos = diagnosticInfos - }); + response.ResponseHeader = CreateResponse(requestHeader, context.StringTable); + + return response; } catch (ServiceResultException e) { @@ -2005,7 +1989,7 @@ public override Task SetTriggeringAsync( /// /// Returns a object /// - public override Task CreateMonitoredItemsAsync( + public override async Task CreateMonitoredItemsAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, uint subscriptionId, @@ -2022,20 +2006,16 @@ public override Task CreateMonitoredItemsAsync( { ValidateOperationLimits(itemsToCreate, OperationLimits.MaxMonitoredItemsPerCall); - ServerInternal.SubscriptionManager.CreateMonitoredItems( + CreateMonitoredItemsResponse result = await ServerInternal.SubscriptionManager.CreateMonitoredItemsAsync( context, subscriptionId, timestampsToReturn, itemsToCreate, - out MonitoredItemCreateResultCollection results, - out DiagnosticInfoCollection diagnosticInfos); + ct).ConfigureAwait(false); - return Task.FromResult(new CreateMonitoredItemsResponse - { - Results = results, - DiagnosticInfos = diagnosticInfos, - ResponseHeader = CreateResponse(requestHeader, context.StringTable) - }); + result.ResponseHeader = CreateResponse(requestHeader, context.StringTable); + + return result; } catch (ServiceResultException e) { @@ -2069,7 +2049,7 @@ public override Task CreateMonitoredItemsAsync( /// /// Returns a object /// - public override Task ModifyMonitoredItemsAsync( + public override async Task ModifyMonitoredItemsAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, uint subscriptionId, @@ -2086,20 +2066,16 @@ public override Task ModifyMonitoredItemsAsync( { ValidateOperationLimits(itemsToModify, OperationLimits.MaxMonitoredItemsPerCall); - ServerInternal.SubscriptionManager.ModifyMonitoredItems( + ModifyMonitoredItemsResponse response = await ServerInternal.SubscriptionManager.ModifyMonitoredItemsAsync( context, subscriptionId, timestampsToReturn, itemsToModify, - out MonitoredItemModifyResultCollection results, - out DiagnosticInfoCollection diagnosticInfos); + ct).ConfigureAwait(false); - return Task.FromResult(new ModifyMonitoredItemsResponse - { - Results = results, - DiagnosticInfos = diagnosticInfos, - ResponseHeader = CreateResponse(requestHeader, context.StringTable) - }); + response.ResponseHeader = CreateResponse(requestHeader, context.StringTable); + + return response; } catch (ServiceResultException e) { @@ -2132,7 +2108,7 @@ public override Task ModifyMonitoredItemsAsync( /// /// Returns a object /// - public override Task DeleteMonitoredItemsAsync( + public override async Task DeleteMonitoredItemsAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, uint subscriptionId, @@ -2148,19 +2124,15 @@ public override Task DeleteMonitoredItemsAsync( { ValidateOperationLimits(monitoredItemIds, OperationLimits.MaxMonitoredItemsPerCall); - ServerInternal.SubscriptionManager.DeleteMonitoredItems( + DeleteMonitoredItemsResponse response = await ServerInternal.SubscriptionManager.DeleteMonitoredItemsAsync( context, subscriptionId, monitoredItemIds, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos); + ct).ConfigureAwait(false); - return Task.FromResult(new DeleteMonitoredItemsResponse - { - Results = results, - DiagnosticInfos = diagnosticInfos, - ResponseHeader = CreateResponse(requestHeader, context.StringTable) - }); + response.ResponseHeader = CreateResponse(requestHeader, context.StringTable); + + return response; } catch (ServiceResultException e) { @@ -2380,109 +2352,109 @@ await configuration if (m_registrationEndpoints != null) { foreach (ConfiguredEndpoint endpoint in m_registrationEndpoints.Endpoints) - { - RegistrationClient client = null; - int i = 0; + { + RegistrationClient client = null; + int i = 0; - while (i++ < 2) + while (i++ < 2) + { + try { - try - { - // update from the server. - bool updateRequired = true; + // update from the server. + bool updateRequired = true; - lock (m_registrationLock) - { - updateRequired = endpoint.UpdateBeforeConnect; - } + lock (m_registrationLock) + { + updateRequired = endpoint.UpdateBeforeConnect; + } - if (updateRequired) - { - await endpoint.UpdateFromServerAsync(MessageContext.Telemetry, ct).ConfigureAwait(false); - } + if (updateRequired) + { + await endpoint.UpdateFromServerAsync(MessageContext.Telemetry, ct).ConfigureAwait(false); + } - lock (m_registrationLock) - { - endpoint.UpdateBeforeConnect = false; - } + lock (m_registrationLock) + { + endpoint.UpdateBeforeConnect = false; + } - var requestHeader = new RequestHeader + var requestHeader = new RequestHeader + { + Timestamp = DateTime.UtcNow + }; + + // create the client. + X509Certificate2 instanceCertificate = + InstanceCertificateTypesProvider.GetInstanceCertificate( + endpoint.Description?.SecurityPolicyUri ?? + SecurityPolicies.None); + client = await RegistrationClient.CreateAsync( + configuration, + endpoint.Description, + endpoint.Configuration, + instanceCertificate, + ct: ct).ConfigureAwait(false); + + client.OperationTimeout = 10000; + + // register the server. + if (m_useRegisterServer2) + { + var discoveryConfiguration = new ExtensionObjectCollection(); + var mdnsDiscoveryConfig = new MdnsDiscoveryConfiguration { - Timestamp = DateTime.UtcNow + ServerCapabilities = configuration.ServerConfiguration + .ServerCapabilities, + MdnsServerName = Utils.GetHostName() }; - - // create the client. - X509Certificate2 instanceCertificate = - InstanceCertificateTypesProvider.GetInstanceCertificate( - endpoint.Description?.SecurityPolicyUri ?? - SecurityPolicies.None); - client = await RegistrationClient.CreateAsync( - configuration, - endpoint.Description, - endpoint.Configuration, - instanceCertificate, - ct: ct).ConfigureAwait(false); - - client.OperationTimeout = 10000; - - // register the server. - if (m_useRegisterServer2) - { - var discoveryConfiguration = new ExtensionObjectCollection(); - var mdnsDiscoveryConfig = new MdnsDiscoveryConfiguration - { - ServerCapabilities = configuration.ServerConfiguration - .ServerCapabilities, - MdnsServerName = Utils.GetHostName() - }; - var extensionObject = new ExtensionObject(mdnsDiscoveryConfig); - discoveryConfiguration.Add(extensionObject); - await client.RegisterServer2Async( - requestHeader, - m_registrationInfo, - discoveryConfiguration, - ct).ConfigureAwait(false); - } - else - { - await client.RegisterServerAsync( - requestHeader, - m_registrationInfo, - ct) - .ConfigureAwait(false); - } - - m_registeredWithDiscoveryServer = m_registrationInfo.IsOnline; - return true; + var extensionObject = new ExtensionObject(mdnsDiscoveryConfig); + discoveryConfiguration.Add(extensionObject); + await client.RegisterServer2Async( + requestHeader, + m_registrationInfo, + discoveryConfiguration, + ct).ConfigureAwait(false); } - catch (Exception e) + else { - m_logger.LogWarning( - "RegisterServer{Api} failed for {EndpointUrl}. Exception={ErrorMessage}", - m_useRegisterServer2 ? "2" : string.Empty, - endpoint.EndpointUrl, - e.Message); - m_useRegisterServer2 = !m_useRegisterServer2; + await client.RegisterServerAsync( + requestHeader, + m_registrationInfo, + ct) + .ConfigureAwait(false); } - finally + + m_registeredWithDiscoveryServer = m_registrationInfo.IsOnline; + return true; + } + catch (Exception e) + { + m_logger.LogWarning( + "RegisterServer{Api} failed for {EndpointUrl}. Exception={ErrorMessage}", + m_useRegisterServer2 ? "2" : string.Empty, + endpoint.EndpointUrl, + e.Message); + m_useRegisterServer2 = !m_useRegisterServer2; + } + finally + { + if (client != null) { - if (client != null) + try { - try - { - await client.CloseAsync(ct).ConfigureAwait(false); - client = null; - } - catch (Exception e) - { - m_logger.LogWarning( - "Could not cleanly close connection with LDS. Exception={ErrorMessage}", - e.Message); - } + await client.CloseAsync(ct).ConfigureAwait(false); + client = null; + } + catch (Exception e) + { + m_logger.LogWarning( + "Could not cleanly close connection with LDS. Exception={ErrorMessage}", + e.Message); } } } } + } // retry to start with RegisterServer2 if both failed m_useRegisterServer2 = true; } diff --git a/Libraries/Opc.Ua.Server/Subscription/ISubscription.cs b/Libraries/Opc.Ua.Server/Subscription/ISubscription.cs index aaecfb3ea..eaa8297a7 100644 --- a/Libraries/Opc.Ua.Server/Subscription/ISubscription.cs +++ b/Libraries/Opc.Ua.Server/Subscription/ISubscription.cs @@ -158,31 +158,28 @@ void Modify( /// /// Deletes the monitored items in a subscription. /// - void DeleteMonitoredItems( + ValueTask DeleteMonitoredItemsAsync( OperationContext context, UInt32Collection monitoredItemIds, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos); + CancellationToken cancellationToken = default); /// /// Modifies monitored items in a subscription. /// - void ModifyMonitoredItems( + ValueTask ModifyMonitoredItemsAsync( OperationContext context, TimestampsToReturn timestampsToReturn, MonitoredItemModifyRequestCollection itemsToModify, - out MonitoredItemModifyResultCollection results, - out DiagnosticInfoCollection diagnosticInfos); + CancellationToken cancellationToken = default); /// /// Adds monitored items to a subscription. /// - void CreateMonitoredItems( + ValueTask CreateMonitoredItemsAsync( OperationContext context, TimestampsToReturn timestampsToReturn, MonitoredItemCreateRequestCollection itemsToCreate, - out MonitoredItemCreateResultCollection results, - out DiagnosticInfoCollection diagnosticInfos); + CancellationToken cancellationToken = default); /// /// Gets the monitored items for the subscription. @@ -212,7 +209,7 @@ void CreateMonitoredItems( /// /// Deletes the subscription. /// - void Delete(OperationContext context); + ValueTask DeleteAsync(OperationContext context, CancellationToken cancellationToken = default); /// /// Verifies that a condition refresh operation is permitted. @@ -252,7 +249,8 @@ NotificationMessage Publish( /// /// The session to which the subscription is transferred. /// Whether the first Publish response shall contain current values. - void TransferSession(OperationContext context, bool sendInitialValues); + /// The cancellation token. + ValueTask TransferSessionAsync(OperationContext context, bool sendInitialValues, CancellationToken cancellationToken = default); /// /// Updates the triggers for the monitored item. diff --git a/Libraries/Opc.Ua.Server/Subscription/ISubscriptionManager.cs b/Libraries/Opc.Ua.Server/Subscription/ISubscriptionManager.cs index a42d402b2..009ea13e9 100644 --- a/Libraries/Opc.Ua.Server/Subscription/ISubscriptionManager.cs +++ b/Libraries/Opc.Ua.Server/Subscription/ISubscriptionManager.cs @@ -70,7 +70,7 @@ ServiceResult SetSubscriptionDurable( /// /// Creates a new subscription. /// - void CreateSubscription( + ValueTask CreateSubscriptionAsync( OperationContext context, double requestedPublishingInterval, uint requestedLifetimeCount, @@ -78,10 +78,7 @@ void CreateSubscription( uint maxNotificationsPerPublish, bool publishingEnabled, byte priority, - out uint subscriptionId, - out double revisedPublishingInterval, - out uint revisedLifetimeCount, - out uint revisedMaxKeepAliveCount); + CancellationToken cancellationToken = default); /// /// Starts up the manager makes it ready to create subscriptions. @@ -106,11 +103,10 @@ void CreateSubscription( /// /// Deletes group of subscriptions. /// - void DeleteSubscriptions( + ValueTask DeleteSubscriptionsAsync( OperationContext context, UInt32Collection subscriptionIds, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos); + CancellationToken cancellationToken = default); /// /// Publishes a subscription. @@ -148,12 +144,11 @@ void SetPublishingMode( /// /// Attaches a groups of subscriptions to a different session. /// - void TransferSubscriptions( + ValueTask TransferSubscriptionsAsync( OperationContext context, UInt32Collection subscriptionIds, bool sendInitialValues, - out TransferResultCollection results, - out DiagnosticInfoCollection diagnosticInfos); + CancellationToken cancellationToken = default); /// /// Republishes a previously published notification message. @@ -180,34 +175,31 @@ void SetTriggering( /// /// Adds monitored items to a subscription. /// - void CreateMonitoredItems( + ValueTask CreateMonitoredItemsAsync( OperationContext context, uint subscriptionId, TimestampsToReturn timestampsToReturn, MonitoredItemCreateRequestCollection itemsToCreate, - out MonitoredItemCreateResultCollection results, - out DiagnosticInfoCollection diagnosticInfos); + CancellationToken cancellationToken = default); /// /// Modifies monitored items in a subscription. /// - void ModifyMonitoredItems( + ValueTask ModifyMonitoredItemsAsync( OperationContext context, uint subscriptionId, TimestampsToReturn timestampsToReturn, MonitoredItemModifyRequestCollection itemsToModify, - out MonitoredItemModifyResultCollection results, - out DiagnosticInfoCollection diagnosticInfos); + CancellationToken cancellationToken = default); /// /// Deletes the monitored items in a subscription. /// - void DeleteMonitoredItems( + ValueTask DeleteMonitoredItemsAsync( OperationContext context, uint subscriptionId, UInt32Collection monitoredItemIds, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos); + CancellationToken cancellationToken = default); /// /// Changes the monitoring mode for a set of items. @@ -222,12 +214,16 @@ void DeleteMonitoredItems( /// /// Signals that a session is closing. /// - void SessionClosing(OperationContext context, NodeId sessionId, bool deleteSubscriptions); + ValueTask SessionClosingAsync( + OperationContext context, + NodeId sessionId, + bool deleteSubscriptions, + CancellationToken cancellationToken); /// /// Deletes the specified subscription. /// - StatusCode DeleteSubscription(OperationContext context, uint subscriptionId); + ValueTask DeleteSubscriptionAsync(OperationContext context, uint subscriptionId, CancellationToken cancellationToken = default); /// /// Refreshes the conditions for the specified subscription. diff --git a/Libraries/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs b/Libraries/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs index 8bc072b74..b53b5ad67 100644 --- a/Libraries/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs +++ b/Libraries/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs @@ -866,11 +866,14 @@ public virtual void QueueValue(DataValue value, ServiceResult error, bool ignore // make a shallow copy of the value. if (value != null) { - m_logger.LogTrace( - Utils.TraceMasks.OperationDetail, - "RECEIVED VALUE[{MonitoredItemId}] Value={Value}", - Id, - value.WrappedValue); + if (m_logger.IsEnabled(LogLevel.Trace)) + { + m_logger.LogTrace( + Utils.TraceMasks.OperationDetail, + "RECEIVED VALUE[{MonitoredItemId}] Value={Value}", + Id, + value.WrappedValue); + } value = new DataValue { diff --git a/Libraries/Opc.Ua.Server/Subscription/Subscription.cs b/Libraries/Opc.Ua.Server/Subscription/Subscription.cs index 05965a2fa..7c497ee54 100644 --- a/Libraries/Opc.Ua.Server/Subscription/Subscription.cs +++ b/Libraries/Opc.Ua.Server/Subscription/Subscription.cs @@ -390,7 +390,7 @@ public int MonitoredItemCount /// /// Deletes the subscription. /// - public void Delete(OperationContext context) + public async ValueTask DeleteAsync(OperationContext context, CancellationToken cancellationToken = default) { // delete the diagnostics. if (m_diagnosticsId != null && !m_diagnosticsId.IsNullNodeId) @@ -400,34 +400,30 @@ public void Delete(OperationContext context) .DeleteSubscriptionDiagnostics(systemContext, m_diagnosticsId); } - lock (m_lock) + try { - try - { - TraceState(LogLevel.Information, TraceStateId.Deleted, "DELETED"); - - // the context may be null if the server is cleaning up expired subscriptions. - // in this case we create a context with a dummy request and use the current session. - if (context == null) - { - var requestHeader = new RequestHeader - { - ReturnDiagnostics = (int)DiagnosticsMasks.OperationSymbolicIdAndText - }; - context = new OperationContext(requestHeader, null, RequestType.Unknown); - } + TraceState(LogLevel.Information, TraceStateId.Deleted, "DELETED"); - DeleteMonitoredItems( - context, - [.. m_monitoredItems.Keys], - true, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos); - } - catch (Exception e) + // the context may be null if the server is cleaning up expired subscriptions. + // in this case we create a context with a dummy request and use the current session. + if (context == null) { - m_logger.LogError(e, "Delete items for subscription failed."); + var requestHeader = new RequestHeader + { + ReturnDiagnostics = (int)DiagnosticsMasks.OperationSymbolicIdAndText + }; + context = new OperationContext(requestHeader, null, RequestType.Unknown); } + + await DeleteMonitoredItemsAsync( + context, + [.. m_monitoredItems.Keys], + true, + cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + m_logger.LogError(e, "Delete items for subscription failed."); } } @@ -579,7 +575,8 @@ public PublishingState PublishTimerExpired() /// /// The session to which the subscription is transferred. /// Whether the first Publish response shall contain current values. - public void TransferSession(OperationContext context, bool sendInitialValues) + /// The cancellation token. + public async ValueTask TransferSessionAsync(OperationContext context, bool sendInitialValues, CancellationToken cancellationToken = default) { // locked by caller Session = context.Session; @@ -591,8 +588,9 @@ public void TransferSession(OperationContext context, bool sendInitialValues) errors.Add(null); } - m_server.NodeManager - .TransferMonitoredItems(context, sendInitialValues, monitoredItems, errors); + await m_server.NodeManager + .TransferMonitoredItemsAsync(context, sendInitialValues, monitoredItems, errors, cancellationToken) + .ConfigureAwait(false); int badTransfers = 0; for (int ii = 0; ii < errors.Count; ii++) @@ -1540,12 +1538,11 @@ public void SetTriggering( /// Adds monitored items to a subscription. /// /// is null. - public void CreateMonitoredItems( + public async ValueTask CreateMonitoredItemsAsync( OperationContext context, TimestampsToReturn timestampsToReturn, MonitoredItemCreateRequestCollection itemsToCreate, - out MonitoredItemCreateResultCollection results, - out DiagnosticInfoCollection diagnosticInfos) + CancellationToken cancellationToken = default) { if (context == null) { @@ -1559,6 +1556,9 @@ public void CreateMonitoredItems( int count = itemsToCreate.Count; + MonitoredItemCreateResultCollection results; + DiagnosticInfoCollection diagnosticInfos; + lock (m_lock) { // check session. @@ -1580,7 +1580,7 @@ public void CreateMonitoredItems( filterResults.Add(null); } - m_server.NodeManager.CreateMonitoredItems( + await m_server.NodeManager.CreateMonitoredItemsAsync( context, Id, m_publishingInterval, @@ -1589,7 +1589,8 @@ public void CreateMonitoredItems( errors, filterResults, monitoredItems, - IsDurable); + IsDurable, + cancellationToken).ConfigureAwait(false); // allocate results. bool diagnosticsExist = false; @@ -1670,6 +1671,12 @@ public void CreateMonitoredItems( TraceState(LogLevel.Information, TraceStateId.Items, "ITEMS CREATED"); } + + return new CreateMonitoredItemsResponse + { + Results = results, + DiagnosticInfos = diagnosticInfos + }; } /// @@ -1748,12 +1755,11 @@ private void ModifyItemMonitoringMode( /// Modifies monitored items in a subscription. /// /// is null. - public void ModifyMonitoredItems( + public async ValueTask ModifyMonitoredItemsAsync( OperationContext context, TimestampsToReturn timestampsToReturn, MonitoredItemModifyRequestCollection itemsToModify, - out MonitoredItemModifyResultCollection results, - out DiagnosticInfoCollection diagnosticInfos) + CancellationToken cancellationToken = default) { if (context == null) { @@ -1769,8 +1775,8 @@ public void ModifyMonitoredItems( // allocate results. bool diagnosticsExist = false; - results = new MonitoredItemModifyResultCollection(count); - diagnosticInfos = null; + var results = new MonitoredItemModifyResultCollection(count); + DiagnosticInfoCollection diagnosticInfos = null; if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0) { @@ -1837,13 +1843,15 @@ public void ModifyMonitoredItems( // update items. if (validItems) { - m_server.NodeManager.ModifyMonitoredItems( + await m_server.NodeManager.ModifyMonitoredItemsAsync( context, timestampsToReturn, monitoredItems, itemsToModify, errors, - filterResults); + filterResults, + cancellationToken) + .ConfigureAwait(false); } lock (m_lock) @@ -1908,35 +1916,38 @@ public void ModifyMonitoredItems( TraceState(LogLevel.Information, TraceStateId.Items, "ITEMS MODIFIED"); } + + return new ModifyMonitoredItemsResponse + { + Results = results, + DiagnosticInfos = diagnosticInfos + }; } /// /// Deletes the monitored items in a subscription. /// - public void DeleteMonitoredItems( + public ValueTask DeleteMonitoredItemsAsync( OperationContext context, UInt32Collection monitoredItemIds, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos) + CancellationToken cancellationToken = default) { - DeleteMonitoredItems( + return DeleteMonitoredItemsAsync( context, monitoredItemIds, false, - out results, - out diagnosticInfos); + cancellationToken); } /// /// Deletes the monitored items in a subscription. /// /// is null. - private void DeleteMonitoredItems( + private async ValueTask DeleteMonitoredItemsAsync( OperationContext context, UInt32Collection monitoredItemIds, bool doNotCheckSession, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos) + CancellationToken cancellationToken = default) { if (context == null) { @@ -1951,8 +1962,8 @@ private void DeleteMonitoredItems( int count = monitoredItemIds.Count; bool diagnosticsExist = false; - results = new StatusCodeCollection(count); - diagnosticInfos = null; + var results = new StatusCodeCollection(count); + DiagnosticInfoCollection diagnosticInfos = null; if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0) { @@ -2043,7 +2054,8 @@ private void DeleteMonitoredItems( // update items. if (validItems) { - m_server.NodeManager.DeleteMonitoredItems(context, Id, monitoredItems, errors); + await m_server.NodeManager.DeleteMonitoredItemsAsync(context, Id, monitoredItems, errors, cancellationToken) + .ConfigureAwait(false); } //dispose monitored Items @@ -2097,6 +2109,12 @@ private void DeleteMonitoredItems( TraceState(LogLevel.Information, TraceStateId.Items, "ITEMS DELETED"); } + + return new DeleteMonitoredItemsResponse + { + Results = results, + DiagnosticInfos = diagnosticInfos + }; } /// diff --git a/Libraries/Opc.Ua.Server/Subscription/SubscriptionManager.cs b/Libraries/Opc.Ua.Server/Subscription/SubscriptionManager.cs index 07e622919..3a55c687d 100644 --- a/Libraries/Opc.Ua.Server/Subscription/SubscriptionManager.cs +++ b/Libraries/Opc.Ua.Server/Subscription/SubscriptionManager.cs @@ -32,6 +32,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -236,7 +237,7 @@ await RestoreSubscriptionsAsync(cancellationToken) // TODO: Ensure shutdown awaits completion and a cancellation token is passed _ = Task.Factory.StartNew( - () => PublishSubscriptions(m_publishingResolution), + () => PublishSubscriptionsAsync(m_publishingResolution), default, TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); @@ -437,7 +438,7 @@ protected virtual async ValueTask RestoreSubscriptionAsync( storedSubscription.MaxNotificationsPerPublish); // create the subscription. - var subscription = await Subscription.RestoreAsync(m_server, storedSubscription, cancellationToken) + Subscription subscription = await Subscription.RestoreAsync(m_server, storedSubscription, cancellationToken) .ConfigureAwait(false); uint publishingIntervalCount; @@ -468,10 +469,11 @@ protected virtual async ValueTask RestoreSubscriptionAsync( /// /// Signals that a session is closing. /// - public virtual void SessionClosing( + public virtual async ValueTask SessionClosingAsync( OperationContext context, NodeId sessionId, - bool deleteSubscriptions) + bool deleteSubscriptions, + CancellationToken cancellationToken) { IList subscriptionsToDelete = null; @@ -513,7 +515,7 @@ public virtual void SessionClosing( RaiseSubscriptionEvent(subscription, true); // delete subscription. - subscription.Delete(context); + await subscription.DeleteAsync(context, cancellationToken).ConfigureAwait(false); // get the count for the diagnostics. uint publishingIntervalCount = GetPublishingIntervalCount(); @@ -528,7 +530,7 @@ public virtual void SessionClosing( // mark the subscriptions as abandoned. else { - m_semaphoreSlim.Wait(); + await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); try { (m_abandonedSubscriptions ??= []).Add(subscription); @@ -670,11 +672,11 @@ await subscription.ConditionRefresh2Async(monitoredItemId, cancellationToken) /// Deletes the specified subscription. /// /// - public StatusCode DeleteSubscription(OperationContext context, uint subscriptionId) + public async ValueTask DeleteSubscriptionAsync(OperationContext context, uint subscriptionId, CancellationToken cancellationToken = default) { ISubscription subscription = null; - m_semaphoreSlim.Wait(); + await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); try { // remove from publish queue. @@ -730,7 +732,7 @@ public StatusCode DeleteSubscription(OperationContext context, uint subscription RaiseSubscriptionEvent(subscription, true); // delete subscription. - subscription.Delete(context); + await subscription.DeleteAsync(context, cancellationToken).ConfigureAwait(false); // get the count for the diagnostics. uint publishingIntervalCount = GetPublishingIntervalCount(); @@ -804,7 +806,7 @@ private uint GetPublishingIntervalCount() /// Creates a new subscription. /// /// - public virtual void CreateSubscription( + public virtual async ValueTask CreateSubscriptionAsync( OperationContext context, double requestedPublishingInterval, uint requestedLifetimeCount, @@ -812,16 +814,18 @@ public virtual void CreateSubscription( uint maxNotificationsPerPublish, bool publishingEnabled, byte priority, - out uint subscriptionId, - out double revisedPublishingInterval, - out uint revisedLifetimeCount, - out uint revisedMaxKeepAliveCount) + CancellationToken cancellationToken = default) { if (m_subscriptions.Count >= m_maxSubscriptionCount) { throw new ServiceResultException(StatusCodes.BadTooManySubscriptions); } + uint subscriptionId; + double revisedPublishingInterval; + uint revisedLifetimeCount; + uint revisedMaxKeepAliveCount; + uint publishingIntervalCount = 0; // get session from context. @@ -859,7 +863,7 @@ public virtual void CreateSubscription( priority, publishingEnabled); - m_semaphoreSlim.Wait(); + await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); try { // save subscription. @@ -925,26 +929,33 @@ public virtual void CreateSubscription( // raise subscription event. RaiseSubscriptionEvent(subscription, false); + + return new CreateSubscriptionResponse + { + SubscriptionId = subscriptionId, + RevisedPublishingInterval = revisedPublishingInterval, + RevisedLifetimeCount = revisedLifetimeCount, + RevisedMaxKeepAliveCount = revisedMaxKeepAliveCount + }; } /// /// Deletes group of subscriptions. /// - public void DeleteSubscriptions( + public async ValueTask DeleteSubscriptionsAsync( OperationContext context, UInt32Collection subscriptionIds, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos) + CancellationToken cancellationToken = default) { bool diagnosticsExist = false; - results = new StatusCodeCollection(subscriptionIds.Count); - diagnosticInfos = new DiagnosticInfoCollection(subscriptionIds.Count); + var results = new StatusCodeCollection(subscriptionIds.Count); + var diagnosticInfos = new DiagnosticInfoCollection(subscriptionIds.Count); foreach (uint subscriptionId in subscriptionIds) { try { - StatusCode result = DeleteSubscription(context, subscriptionId); + StatusCode result = await DeleteSubscriptionAsync(context, subscriptionId, cancellationToken).ConfigureAwait(false); results.Add(result); if ((context.DiagnosticsMask & DiagnosticsMasks.OperationAll) != 0) @@ -979,6 +990,12 @@ public void DeleteSubscriptions( { diagnosticInfos.Clear(); } + + return new DeleteSubscriptionsResponse + { + Results = results, + DiagnosticInfos = diagnosticInfos + }; } /// @@ -1342,15 +1359,14 @@ public void SetPublishingMode( /// /// Attaches a groups of subscriptions to a different session. /// - public void TransferSubscriptions( + public async ValueTask TransferSubscriptionsAsync( OperationContext context, UInt32Collection subscriptionIds, bool sendInitialValues, - out TransferResultCollection results, - out DiagnosticInfoCollection diagnosticInfos) + CancellationToken cancellationToken = default) { - results = []; - diagnosticInfos = []; + var results = new TransferResultCollection(); + var diagnosticInfos = new DiagnosticInfoCollection(); m_logger.LogInformation( "TransferSubscriptions to SessionId={SessionId}, Count={Count}, sendInitialValues={SendInitialValues}", @@ -1432,10 +1448,10 @@ public void TransferSubscriptions( } // transfer session, add subscription to publish queue - m_semaphoreSlim.Wait(); + await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); try { - subscription.TransferSession(context, sendInitialValues); + await subscription.TransferSessionAsync(context, sendInitialValues, cancellationToken).ConfigureAwait(false); // remove from queue in old session if (ownerSession != null && @@ -1532,7 +1548,7 @@ public void TransferSubscriptions( } } - m_semaphoreSlim.Wait(); + await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); try { // trigger publish response to return status immediately @@ -1606,6 +1622,11 @@ public void TransferSubscriptions( m_logger); } } + return new TransferSubscriptionsResponse + { + Results = results, + DiagnosticInfos = diagnosticInfos + }; } /// @@ -1665,13 +1686,12 @@ public void SetTriggering( /// Adds monitored items to a subscription. /// /// - public void CreateMonitoredItems( + public async ValueTask CreateMonitoredItemsAsync( OperationContext context, uint subscriptionId, TimestampsToReturn timestampsToReturn, MonitoredItemCreateRequestCollection itemsToCreate, - out MonitoredItemCreateResultCollection results, - out DiagnosticInfoCollection diagnosticInfos) + CancellationToken cancellationToken = default) { // find subscription. if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription subscription)) @@ -1682,12 +1702,11 @@ public void CreateMonitoredItems( int currentMonitoredItemCount = subscription.MonitoredItemCount; // create the items. - subscription.CreateMonitoredItems( + CreateMonitoredItemsResponse response = await subscription.CreateMonitoredItemsAsync( context, timestampsToReturn, itemsToCreate, - out results, - out diagnosticInfos); + cancellationToken).ConfigureAwait(false); int monitoredItemCountIncrement = subscription.MonitoredItemCount - currentMonitoredItemCount; @@ -1701,19 +1720,20 @@ public void CreateMonitoredItems( UpdateCurrentMonitoredItemsCount(diagnostics, monitoredItemCountIncrement); } } + + return response; } /// /// Modifies monitored items in a subscription. /// /// - public void ModifyMonitoredItems( + public ValueTask ModifyMonitoredItemsAsync( OperationContext context, uint subscriptionId, TimestampsToReturn timestampsToReturn, MonitoredItemModifyRequestCollection itemsToModify, - out MonitoredItemModifyResultCollection results, - out DiagnosticInfoCollection diagnosticInfos) + CancellationToken cancellationToken = default) { // find subscription. if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription subscription)) @@ -1722,24 +1742,22 @@ public void ModifyMonitoredItems( } // modify the items. - subscription.ModifyMonitoredItems( + return subscription.ModifyMonitoredItemsAsync( context, timestampsToReturn, itemsToModify, - out results, - out diagnosticInfos); + cancellationToken); } /// /// Deletes the monitored items in a subscription. /// /// - public void DeleteMonitoredItems( + public async ValueTask DeleteMonitoredItemsAsync( OperationContext context, uint subscriptionId, UInt32Collection monitoredItemIds, - out StatusCodeCollection results, - out DiagnosticInfoCollection diagnosticInfos) + CancellationToken cancellationToken = default) { // find subscription. if (!m_subscriptions.TryGetValue(subscriptionId, out ISubscription subscription)) @@ -1750,11 +1768,10 @@ public void DeleteMonitoredItems( int currentMonitoredItemCount = subscription.MonitoredItemCount; // create the items. - subscription.DeleteMonitoredItems( + DeleteMonitoredItemsResponse response = await subscription.DeleteMonitoredItemsAsync( context, monitoredItemIds, - out results, - out diagnosticInfos); + cancellationToken).ConfigureAwait(false); int monitoredItemCountIncrement = subscription.MonitoredItemCount - currentMonitoredItemCount; @@ -1768,6 +1785,8 @@ public void DeleteMonitoredItems( UpdateCurrentMonitoredItemsCount(diagnostics, monitoredItemCountIncrement); } } + + return response; } /// @@ -2037,7 +2056,7 @@ private bool ReturnPendingStatusMessage( /// /// Periodically checks if the sessions have timed out. /// - private void PublishSubscriptions(object data) + private async ValueTask PublishSubscriptionsAsync(int sleepCycle, CancellationToken cancellationToken = default) { try { @@ -2045,7 +2064,6 @@ private void PublishSubscriptions(object data) "Subscription - Publish Task {TaskId:X8} Started.", Task.CurrentId); - int sleepCycle = Convert.ToInt32(data, CultureInfo.InvariantCulture); int timeToWait = sleepCycle; while (true) @@ -2055,7 +2073,7 @@ private void PublishSubscriptions(object data) SessionPublishQueue[] queues = null; ISubscription[] abandonedSubscriptions = null; - m_semaphoreSlim.Wait(); + await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); try { // collect active session queues. @@ -2109,7 +2127,7 @@ private void PublishSubscriptions(object data) // schedule cleanup on a background thread. if (subscriptionsToDelete.Count > 0) { - m_semaphoreSlim.Wait(); + await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); try { for (int ii = 0; ii < subscriptionsToDelete.Count; ii++) @@ -2126,7 +2144,7 @@ private void PublishSubscriptions(object data) } } - if (m_shutdownEvent.WaitOne(timeToWait)) + if (m_shutdownEvent.WaitOne(0)) { m_logger.LogInformation( "Subscription - Publish Task {TaskId:X8} Exited Normally.", @@ -2134,8 +2152,7 @@ private void PublishSubscriptions(object data) break; } - int delay = (int)(DateTime.UtcNow - start).TotalMilliseconds; - timeToWait = sleepCycle; + await Task.Delay(timeToWait, cancellationToken).ConfigureAwait(false); } } catch (Exception e) @@ -2228,17 +2245,18 @@ internal static void CleanupSubscriptions( subscriptionsToDelete.Count); Task.Run( - () => CleanupSubscriptionsCore(server, subscriptionsToDelete, logger)); + () => CleanupSubscriptionsCoreAsync(server, subscriptionsToDelete, logger)); } } /// /// Deletes any expired subscriptions. /// - private static void CleanupSubscriptionsCore( + private static async ValueTask CleanupSubscriptionsCoreAsync( IServerInternal server, IList subscriptionsToDelete, - ILogger logger) + ILogger logger, + CancellationToken cancellationToken = default) { try { @@ -2246,7 +2264,7 @@ private static void CleanupSubscriptionsCore( foreach (ISubscription subscription in subscriptionsToDelete) { - server.DeleteSubscription(subscription.Id); + await server.DeleteSubscriptionAsync(subscription.Id, cancellationToken).ConfigureAwait(false); } logger.LogInformation("Server - CleanupSubscriptions Task Completed"); diff --git a/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs b/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs index 88b8de5d6..abc753633 100644 --- a/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs +++ b/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs @@ -63,6 +63,7 @@ public class ClientTestFramework public bool SingleSession { get; set; } = true; public int MaxChannelCount { get; set; } = 100; public bool SupportsExternalServerUrl { get; set; } + public bool UseSamplingGroupsInReferenceNodeManager { get; set; } public ServerFixture ServerFixture { get; set; } public ClientFixture ClientFixture { get; set; } public ReferenceServer ReferenceServer { get; set; } @@ -217,7 +218,8 @@ public virtual async Task CreateReferenceServerFixtureAsync( SecurityNone = securityNone, AutoAccept = true, AllNodeManagers = true, - OperationLimits = true + OperationLimits = true, + UseSamplingGroupsInReferenceNodeManager = UseSamplingGroupsInReferenceNodeManager }; await ServerFixture.LoadConfigurationAsync(PkiRoot).ConfigureAwait(false); diff --git a/Tests/Opc.Ua.Client.Tests/LoadTest.cs b/Tests/Opc.Ua.Client.Tests/LoadTest.cs index 622a136c3..c5ab37e2d 100644 --- a/Tests/Opc.Ua.Client.Tests/LoadTest.cs +++ b/Tests/Opc.Ua.Client.Tests/LoadTest.cs @@ -62,6 +62,7 @@ public LoadTest(string uriScheme) public override Task OneTimeSetUpAsync() { SupportsExternalServerUrl = true; + UseSamplingGroupsInReferenceNodeManager = false; return base.OneTimeSetUpAsync(); } From 6ab1bf7a5db2136496316765d2cc7f09e422e0df Mon Sep 17 00:00:00 2001 From: wxmayifei <412689328@qq.com> Date: Thu, 8 Jan 2026 20:16:37 +0800 Subject: [PATCH 14/42] fix format string (#3441) --- Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs b/Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs index 565a60fee..2fd0ae67a 100644 --- a/Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs +++ b/Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs @@ -1923,7 +1923,7 @@ private static ServiceResult ValidateServerCertificateApplicationUri(X509Certifi { return ServiceResult.Create( StatusCodes.BadCertificateUriInvalid, - "The Server Certificate ({1}) does not contain an applicationUri.", + "The Server Certificate ({0}) does not contain an applicationUri.", serverCertificate.Subject); } From 30f9359a528446211bb4cdb8e45889516d477fd1 Mon Sep 17 00:00:00 2001 From: Tobias Frick Date: Fri, 9 Jan 2026 01:06:56 +0100 Subject: [PATCH 15/42] Fix Session.Save to only save specified subscriptions (#3446) - Use the subscriptions parameter instead of this.Subscriptions - Add test to verify only specified subscriptions are saved --- Libraries/Opc.Ua.Client/Session/Session.cs | 4 +-- Tests/Opc.Ua.Client.Tests/SessionTests.cs | 32 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Libraries/Opc.Ua.Client/Session/Session.cs b/Libraries/Opc.Ua.Client/Session/Session.cs index 21d014100..14c8a25b5 100644 --- a/Libraries/Opc.Ua.Client/Session/Session.cs +++ b/Libraries/Opc.Ua.Client/Session/Session.cs @@ -950,8 +950,8 @@ public virtual void Save( { using Activity? activity = m_telemetry.StartActivity(); // Snapshot subscription state - var subscriptionStateCollection = new SubscriptionStateCollection(SubscriptionCount); - foreach (Subscription subscription in Subscriptions) + var subscriptionStateCollection = new SubscriptionStateCollection(); + foreach (Subscription subscription in subscriptions) { subscription.Snapshot(out SubscriptionState state); subscriptionStateCollection.Add(state); diff --git a/Tests/Opc.Ua.Client.Tests/SessionTests.cs b/Tests/Opc.Ua.Client.Tests/SessionTests.cs index 9ad403374..32947fea6 100644 --- a/Tests/Opc.Ua.Client.Tests/SessionTests.cs +++ b/Tests/Opc.Ua.Client.Tests/SessionTests.cs @@ -28,11 +28,13 @@ * ======================================================================*/ using System; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Moq; using NUnit.Framework; +using Opc.Ua.Tests; namespace Opc.Ua.Client.Tests { @@ -1340,5 +1342,35 @@ public void OpenAsyncShouldHandleInvalidServerResponse() sut.Channel.Verify(); } + + [Test] + public void SaveShouldOnlySaveSpecifiedSubscriptions() + { + var sut = SessionMock.Create(); + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + var subscription1 = new Subscription(telemetry, new SubscriptionOptions { DisplayName = "Subscription1" }); + var subscription2 = new Subscription(telemetry, new SubscriptionOptions { DisplayName = "Subscription2" }); + var subscription3 = new Subscription(telemetry, new SubscriptionOptions { DisplayName = "Subscription3" }); + + sut.AddSubscription(subscription1); + sut.AddSubscription(subscription2); + sut.AddSubscription(subscription3); + + Assert.That(sut.SubscriptionCount, Is.EqualTo(3)); + + // Only save a subset of subscriptions (subscription1 and subscription3) + Subscription[] subscriptionsToSave = [subscription1, subscription3]; + + using var stream = new MemoryStream(); + sut.Save(stream, subscriptionsToSave); + stream.Position = 0; + + var loadSession = SessionMock.Create(); + var loadedSubscriptions = loadSession.Load(stream).ToList(); + + Assert.That(loadedSubscriptions.Count, Is.EqualTo(2), "Only the specified subscriptions should be saved"); + Assert.That(loadedSubscriptions.Select(s => s.DisplayName), Is.EquivalentTo(["Subscription1", "Subscription3"])); + } } } From 05f7023d29c179b1960f9a541ce10036d5e4c2e2 Mon Sep 17 00:00:00 2001 From: Tobias Frick Date: Mon, 12 Jan 2026 08:05:05 +0100 Subject: [PATCH 16/42] Fix two bugs in MonitoredItem (client side) (#3447) - Fix bug in MonitoredItem.Publish (for loop was never executed) - MonitoredItem.CacheQueueSize property was not serialized - Add unit tests for MonitoredItems --- .../Subscription/MonitoredItem.cs | 24 +-- .../Subscription/MonitoredItemState.cs | 6 + .../Opc.Ua.Client.Tests/MonitoredItemTests.cs | 188 ++++++++++++++++++ 3 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 Tests/Opc.Ua.Client.Tests/MonitoredItemTests.cs diff --git a/Libraries/Opc.Ua.Client/Subscription/MonitoredItem.cs b/Libraries/Opc.Ua.Client/Subscription/MonitoredItem.cs index 3adfb9d79..9064b68b5 100644 --- a/Libraries/Opc.Ua.Client/Subscription/MonitoredItem.cs +++ b/Libraries/Opc.Ua.Client/Subscription/MonitoredItem.cs @@ -138,6 +138,7 @@ public virtual void Restore(MonitoredItemState state) ServerId = state.ServerId; TriggeringItemId = state.TriggeringItemId; TriggeredItems = state.TriggeredItems != null ? new UInt32Collection(state.TriggeredItems) : null; + CacheQueueSize = state.CacheQueueSize < 1 ? 1 : state.CacheQueueSize; } /// @@ -148,7 +149,8 @@ public virtual void Snapshot(out MonitoredItemState state) ServerId = Status.Id, ClientId = ClientHandle, TriggeringItemId = TriggeringItemId, - TriggeredItems = TriggeredItems != null ? new UInt32Collection(TriggeredItems) : null + TriggeredItems = TriggeredItems != null ? new UInt32Collection(TriggeredItems) : null, + CacheQueueSize = CacheQueueSize }; } @@ -401,7 +403,7 @@ public NodeId ResolvedNodeId /// /// Returns the queue size used by the cache. /// - public int CacheQueueSize + public uint CacheQueueSize { get { @@ -1122,7 +1124,7 @@ public class MonitoredItemDataCache /// /// Constructs a cache for a monitored item. /// - public MonitoredItemDataCache(ITelemetryContext? telemetry, int queueSize = 1) + public MonitoredItemDataCache(ITelemetryContext? telemetry, uint queueSize = 1) { QueueSize = queueSize; m_logger = telemetry.CreateLogger(); @@ -1139,7 +1141,7 @@ public MonitoredItemDataCache(ITelemetryContext? telemetry, int queueSize = 1) /// /// The size of the queue to maintain. /// - public int QueueSize { get; private set; } + public uint QueueSize { get; private set; } /// /// The last value received from the server. @@ -1155,12 +1157,8 @@ public IList Publish() if (m_values != null) { values = new List(m_values.Count); - for (int ii = 0; ii < values.Count; ii++) + while (m_values.TryDequeue(out DataValue? dequeued)) { - if (!m_values.TryDequeue(out DataValue? dequeued)) - { - break; - } values.Add(dequeued); } } @@ -1222,7 +1220,7 @@ public void OnNotification(MonitoredItemNotification notification) /// /// Changes the queue size. /// - public void SetQueueSize(int queueSize) + public void SetQueueSize(uint queueSize) { if (queueSize == QueueSize) { @@ -1270,7 +1268,7 @@ public class MonitoredItemEventCache /// /// Constructs a cache for a monitored item. /// - public MonitoredItemEventCache(int queueSize) + public MonitoredItemEventCache(uint queueSize) { QueueSize = queueSize; m_events = new Queue(); @@ -1279,7 +1277,7 @@ public MonitoredItemEventCache(int queueSize) /// /// The size of the queue to maintain. /// - public int QueueSize { get; private set; } + public uint QueueSize { get; private set; } /// /// The last event received. @@ -1318,7 +1316,7 @@ public void OnNotification(EventFieldList notification) /// /// Changes the queue size. /// - public void SetQueueSize(int queueSize) + public void SetQueueSize(uint queueSize) { if (queueSize == QueueSize) { diff --git a/Libraries/Opc.Ua.Client/Subscription/MonitoredItemState.cs b/Libraries/Opc.Ua.Client/Subscription/MonitoredItemState.cs index e5ae8d6af..ec22fa2bd 100644 --- a/Libraries/Opc.Ua.Client/Subscription/MonitoredItemState.cs +++ b/Libraries/Opc.Ua.Client/Subscription/MonitoredItemState.cs @@ -95,6 +95,12 @@ public MonitoredItemState(MonitoredItemOptions options) /// [DataMember(Order = 17)] public UInt32Collection? TriggeredItems { get; init; } + + /// + /// The queue size used by the client-side cache. + /// + [DataMember(Order = 18)] + public uint CacheQueueSize { get; init; } } /// diff --git a/Tests/Opc.Ua.Client.Tests/MonitoredItemTests.cs b/Tests/Opc.Ua.Client.Tests/MonitoredItemTests.cs new file mode 100644 index 000000000..562121b07 --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/MonitoredItemTests.cs @@ -0,0 +1,188 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.Client.Tests +{ + [TestFixture] + [Category("Client")] + [Category("MonitoredItem")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + public sealed class MonitoredItemTests + { + [Test] + public void SaveValueInCacheShouldOverwriteWithQueueSizeOne() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + var monitoredItem = new MonitoredItem(telemetry) { CacheQueueSize = 1 }; + + var notification1 = new MonitoredItemNotification + { + ClientHandle = monitoredItem.ClientHandle, + Value = new DataValue(new Variant(100), StatusCodes.Good, DateTime.UtcNow) + }; + monitoredItem.SaveValueInCache(notification1); + + var notification2 = new MonitoredItemNotification + { + ClientHandle = monitoredItem.ClientHandle, + Value = new DataValue(new Variant(200), StatusCodes.Good, DateTime.UtcNow) + }; + monitoredItem.SaveValueInCache(notification2); + + IList result = monitoredItem.DequeueValues(); + + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result[0].Value, Is.EqualTo(200)); + } + + [Test] + public void DequeueValuesShouldReturnAllQueuedValues() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + var monitoredItem = new MonitoredItem(telemetry) { CacheQueueSize = 5 }; + + List expectedValues = [1, 2, 3, 4, 5]; + List notifications = expectedValues + .ConvertAll(value => new MonitoredItemNotification + { + ClientHandle = monitoredItem.ClientHandle, + Value = new DataValue(new Variant(value), StatusCodes.Good, DateTime.UtcNow) + }); + + foreach (MonitoredItemNotification notification in notifications) + { + monitoredItem.SaveValueInCache(notification); + } + + IList result = monitoredItem.DequeueValues(); + + Assert.That(result.Count, Is.EqualTo(expectedValues.Count)); + Assert.That(result.Select(x => x.Value), Is.EquivalentTo(expectedValues)); + + // Ensure the cache is empty after dequeue + IList emptyResult = monitoredItem.DequeueValues(); + Assert.That(emptyResult, Is.Empty); + } + + [Test] + public void SaveValueInCacheShouldOverwriteOldestValues() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + const int kQueueSize = 5; + var monitoredItem = new MonitoredItem(telemetry) { CacheQueueSize = kQueueSize }; + + List values = [1, 2, 3, 4, 5, 6, 7]; + List notifications = values + .ConvertAll(value => new MonitoredItemNotification + { + ClientHandle = monitoredItem.ClientHandle, + Value = new DataValue(new Variant(value), StatusCodes.Good, DateTime.UtcNow) + }); + + foreach (MonitoredItemNotification notification in notifications) + { + monitoredItem.SaveValueInCache(notification); + } + + IList result = monitoredItem.DequeueValues(); + + Assert.That(result.Count, Is.EqualTo(kQueueSize)); + Assert.That(result.Select(x => x.Value), Is.EquivalentTo(values.Skip(2))); + } + + [Test] + public void SerializeDeserializeShouldHaveSameProperties() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + var originalSession = SessionMock.Create(); + var originalSubscription = new TestableSubscription(telemetry); + + var monitoredItems = new List + { + new(telemetry) + { + DisplayName = "MonitoredItem1", QueueSize = 10, CacheQueueSize = 1, SamplingInterval = 500 + }, + new(telemetry) + { + DisplayName = "MonitoredItem2", QueueSize = 25, CacheQueueSize = 50, SamplingInterval = 500 + }, + new(telemetry) + { + DisplayName = "MonitoredItem3", QueueSize = 0, CacheQueueSize = 0, SamplingInterval = 500 + } + }; + + // CacheQueueSize of 0 is invalid and should be set to 1 internally + Assert.That(monitoredItems[^1].CacheQueueSize, Is.EqualTo(1)); + + originalSubscription.AddItems(monitoredItems); + originalSession.AddSubscription(originalSubscription); + + using var stream = new MemoryStream(); + originalSession.Save(stream, [originalSubscription]); + stream.Position = 0; + + var loadedSession = SessionMock.Create(); + loadedSession.Load(stream); + Assert.That(loadedSession.Subscriptions.Count(), Is.EqualTo(1)); + Assert.That(loadedSession.Subscriptions.First().MonitoredItems.Count(), Is.EqualTo(monitoredItems.Count)); + + List originalStates = monitoredItems + .ConvertAll(item => + { + item.Snapshot(out MonitoredItemState state); + return state; + }); + + var loadedItems = loadedSession.Subscriptions.First().MonitoredItems.ToList(); + List loadedStates = loadedItems + .ConvertAll(item => + { + item.Snapshot(out MonitoredItemState state); + return state; + }); + + for (int i = 0; i < monitoredItems.Count; i++) + { + Assert.That(loadedStates[i] with { Timestamp = default }, + Is.EqualTo(originalStates[i] with { Timestamp = default })); + } + } + } +} From 8b11d2bfa8138aee4a31645239126a6a74424c4d Mon Sep 17 00:00:00 2001 From: romanett Date: Mon, 12 Jan 2026 08:06:12 +0100 Subject: [PATCH 17/42] [Server] Add several interfaces to improve testability & prepare for Dependency Injection (#3448) * add server interfaces * Add IConfigurationNodeManager, IDiagnosticsNodeManager, IMasterNodeManager, IStandardServer, IApplicationInstance interfaces to Server. Add IMainNodeManagerFactory to create Diagnostics & MainNodeManager * Use IApplicationInstance interface where appropriate * fix review feedback --- .../ConsoleReferenceServer/UAServer.cs | 2 +- .../ReferenceServer/ReferenceServer.cs | 2 +- Applications/Quickstarts.Servers/Utils.cs | 4 +- .../ApplicationConfigurationBuilder.cs | 2 +- .../ApplicationInstance.cs | 103 ++--- .../IApplicationInstance.cs | 153 ++++++++ .../GlobalDiscoverySampleServer.cs | 2 +- .../Configuration/ConfigurationNodeManager.cs | 27 +- .../IConfigurationNodeManager.cs | 66 ++++ .../Diagnostics/DiagnosticsNodeManager.cs | 72 ++-- .../Diagnostics/IDiagnosticsNodeManager.cs | 120 ++++++ .../NodeManager/CoreNodeManager.cs | 42 +-- .../NodeManager/ICoreNodeManager.cs | 56 +++ .../NodeManager/IMainNodeManagerFactory.cs | 51 +++ .../NodeManager/IMasterNodeManager.cs | 356 ++++++++++++++++++ .../NodeManager/MainNodeManagerFactory.cs | 66 ++++ .../NodeManager/MasterNodeManager.cs | 239 +++--------- .../Opc.Ua.Server/Server/IServerInternal.cs | 26 +- .../Opc.Ua.Server/Server/IStandardServer.cs | 100 +++++ .../Server/ServerInternalData.cs | 28 +- .../Opc.Ua.Server/Server/StandardServer.cs | 74 ++-- .../ReferenceServerWithLimits.cs | 2 +- .../GlobalDiscoveryTestServer.cs | 4 +- Tests/Opc.Ua.Server.Tests/ServerFixture.cs | 2 +- 24 files changed, 1161 insertions(+), 438 deletions(-) create mode 100644 Libraries/Opc.Ua.Configuration/IApplicationInstance.cs create mode 100644 Libraries/Opc.Ua.Server/Configuration/IConfigurationNodeManager.cs create mode 100644 Libraries/Opc.Ua.Server/Diagnostics/IDiagnosticsNodeManager.cs create mode 100644 Libraries/Opc.Ua.Server/NodeManager/ICoreNodeManager.cs create mode 100644 Libraries/Opc.Ua.Server/NodeManager/IMainNodeManagerFactory.cs create mode 100644 Libraries/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs create mode 100644 Libraries/Opc.Ua.Server/NodeManager/MainNodeManagerFactory.cs create mode 100644 Libraries/Opc.Ua.Server/Server/IStandardServer.cs diff --git a/Applications/ConsoleReferenceServer/UAServer.cs b/Applications/ConsoleReferenceServer/UAServer.cs index e1d54bcb9..0b3a9bacd 100644 --- a/Applications/ConsoleReferenceServer/UAServer.cs +++ b/Applications/ConsoleReferenceServer/UAServer.cs @@ -43,7 +43,7 @@ namespace Quickstarts public class UAServer where T : StandardServer, new() { - public ApplicationInstance Application { get; private set; } + public IApplicationInstance Application { get; private set; } public ApplicationConfiguration Configuration => Application.ApplicationConfiguration; diff --git a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs index c98397494..248f2d6f0 100644 --- a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs +++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs @@ -75,7 +75,7 @@ public class ReferenceServer : ReverseConnectServer /// always creates a CoreNodeManager which handles the built-in nodes defined by the specification. /// Any additional NodeManagers are expected to handle application specific nodes. /// - protected override MasterNodeManager CreateMasterNodeManager( + protected override IMasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) { diff --git a/Applications/Quickstarts.Servers/Utils.cs b/Applications/Quickstarts.Servers/Utils.cs index 110a60911..1736f901d 100644 --- a/Applications/Quickstarts.Servers/Utils.cs +++ b/Applications/Quickstarts.Servers/Utils.cs @@ -47,7 +47,7 @@ public static class Utils /// /// Applies custom settings to quickstart servers for CTT run. /// - public static async Task ApplyCTTModeAsync(TextWriter output, StandardServer server) + public static async Task ApplyCTTModeAsync(TextWriter output, IStandardServer server) { var methodsToCall = new CallMethodRequestCollection(); int index = server.CurrentInstance.NamespaceUris.GetIndex(Alarms.Namespaces.Alarms); @@ -97,7 +97,7 @@ public static async Task ApplyCTTModeAsync(TextWriter output, StandardServer ser /// /// Add all available node manager factories to the server. /// - public static void AddDefaultNodeManagers(StandardServer server) + public static void AddDefaultNodeManagers(IStandardServer server) { foreach (INodeManagerFactory nodeManagerFactory in NodeManagerFactories) { diff --git a/Libraries/Opc.Ua.Configuration/ApplicationConfigurationBuilder.cs b/Libraries/Opc.Ua.Configuration/ApplicationConfigurationBuilder.cs index 3a73ba222..2f5601964 100644 --- a/Libraries/Opc.Ua.Configuration/ApplicationConfigurationBuilder.cs +++ b/Libraries/Opc.Ua.Configuration/ApplicationConfigurationBuilder.cs @@ -52,7 +52,7 @@ public ApplicationConfigurationBuilder(ApplicationInstance applicationInstance) /// /// The application instance used to build the configuration. /// - public ApplicationInstance ApplicationInstance { get; } + public IApplicationInstance ApplicationInstance { get; } /// /// The application configuration. diff --git a/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs b/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs index 19f9da779..82bfd6ef1 100644 --- a/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs +++ b/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs @@ -40,10 +40,8 @@ namespace Opc.Ua.Configuration { - /// - /// A class that install, configures and runs a UA application. - /// - public class ApplicationInstance + /// + public class ApplicationInstance : IApplicationInstance { /// /// Obsolete constructor @@ -86,41 +84,22 @@ public ApplicationInstance( ApplicationConfiguration = applicationConfiguration; } - /// - /// Gets or sets the name of the application. - /// - /// The name of the application. + /// public string ApplicationName { get; set; } - /// - /// Gets or sets the type of the application. - /// - /// The type of the application. + /// public ApplicationType ApplicationType { get; set; } - /// - /// Gets or sets the name of the config section containing the path - /// to the application configuration file. - /// - /// The name of the config section. + /// public string ConfigSectionName { get; set; } - /// - /// Gets or sets the type of configuration file. - /// - /// The type of configuration file. + /// public Type ConfigurationType { get; set; } - /// - /// Gets the server. - /// - /// The server. - public ServerBase Server { get; private set; } + /// + public IServerBase Server { get; private set; } - /// - /// Gets the application configuration used when the Start() method was called. - /// - /// The application configuration. + /// public ApplicationConfiguration ApplicationConfiguration { get; set; } /// @@ -128,29 +107,14 @@ public ApplicationInstance( /// public static IApplicationMessageDlg MessageDlg { get; set; } - /// - /// Get or set the certificate password provider. - /// + /// public ICertificatePasswordProvider CertificatePasswordProvider { get; set; } - /// - /// Get or set bool which indicates if the auto creation - /// of a new application certificate during startup is disabled. - /// Default is enabled./> - /// - /// - /// Prevents auto self signed cert creation in use cases - /// where an expired certificate should not be automatically - /// renewed or where it is required to only use certificates - /// provided by the user. - /// + /// public bool DisableCertificateAutoCreation { get; set; } - /// - /// Starts the UA server. - /// - /// The server. - public async Task StartAsync(ServerBase server) + /// + public async Task StartAsync(IServerBase server) { Server = server; @@ -162,9 +126,7 @@ public async Task StartAsync(ServerBase server) await server.StartAsync(ApplicationConfiguration).ConfigureAwait(false); } - /// - /// Stops the UA server. - /// + /// public ValueTask StopAsync() { return Server.StopAsync(); @@ -179,10 +141,7 @@ public void Stop() Server.Stop(); } - /// - /// Loads the application configuration. - /// - /// + /// public async Task LoadApplicationConfigurationAsync( Stream stream, bool silent, @@ -218,10 +177,7 @@ public async Task LoadApplicationConfigurationAsync( return configuration; } - /// - /// Loads the application configuration. - /// - /// + /// public async ValueTask LoadApplicationConfigurationAsync( string filePath, bool silent, @@ -257,9 +213,7 @@ public async ValueTask LoadApplicationConfigurationAsy return configuration; } - /// - /// Loads the application configuration. - /// + /// public ValueTask LoadApplicationConfigurationAsync( bool silent, CancellationToken ct = default) @@ -288,9 +242,7 @@ public static ApplicationConfiguration FixupAppConfig( return configuration; } - /// - /// Create a builder for a UA application configuration. - /// + /// public IApplicationConfigurationBuilderTypes Build(string applicationUri, string productUri) { // App Uri and cert subject @@ -312,10 +264,7 @@ public IApplicationConfigurationBuilderTypes Build(string applicationUri, string return new ApplicationConfigurationBuilder(this); } - /// - /// Deletes all application certificates. - /// - /// + /// public async ValueTask DeleteApplicationInstanceCertificateAsync( string[] profileIds = null, CancellationToken ct = default) @@ -334,13 +283,7 @@ await DeleteApplicationInstanceCertificateAsync(ApplicationConfiguration, id, ct } } - /// - /// Checks for a valid application instance certificate. - /// - /// if set to true no dialogs will be displayed. - /// The lifetime in months. - /// Cancellation token to cancel operation with - /// + /// public async ValueTask CheckApplicationInstanceCertificatesAsync( bool silent, ushort? lifeTimeInMonths = null, @@ -554,11 +497,7 @@ await id.LoadPrivateKeyExAsync(passwordProvider, configuration.ApplicationUri, m return true; } - /// - /// Adds a Certificate to the Trusted Store of the Application, needed e.g. for the GDS to trust it´s own CA - /// - /// The certificate to add to the store - /// The cancellation token + /// public async Task AddOwnCertificateToTrustedStoreAsync( X509Certificate2 certificate, CancellationToken ct) diff --git a/Libraries/Opc.Ua.Configuration/IApplicationInstance.cs b/Libraries/Opc.Ua.Configuration/IApplicationInstance.cs new file mode 100644 index 000000000..9ab50aad3 --- /dev/null +++ b/Libraries/Opc.Ua.Configuration/IApplicationInstance.cs @@ -0,0 +1,153 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Configuration +{ + /// + /// A class that installs, configures and runs a UA application. + /// + public interface IApplicationInstance + { + /// + /// Gets the application configuration used when the Start() method was called. + /// + /// The application configuration. + ApplicationConfiguration ApplicationConfiguration { get; set; } + + /// + /// Gets or sets the name of the application. + /// + /// The name of the application. + string ApplicationName { get; set; } + + /// + /// Gets or sets the type of the application. + /// + /// The type of the application. + ApplicationType ApplicationType { get; set; } + + /// + /// Get or set the certificate password provider. + /// + ICertificatePasswordProvider CertificatePasswordProvider { get; set; } + + /// + /// Gets or sets the name of the config section containing the path + /// to the application configuration file. + /// + /// The name of the config section. + string ConfigSectionName { get; set; } + + /// + /// Gets or sets the type of configuration file. + /// + /// The type of configuration file. + Type ConfigurationType { get; set; } + + /// + /// Get or set bool which indicates if the auto creation + /// of a new application certificate during startup is disabled. + /// Default is enabled./> + /// + /// + /// Prevents auto self signed cert creation in use cases + /// where an expired certificate should not be automatically + /// renewed or where it is required to only use certificates + /// provided by the user. + /// + bool DisableCertificateAutoCreation { get; set; } + + /// + /// Gets the server. + /// + /// The server. + IServerBase Server { get; } + + /// + /// Adds a Certificate to the Trusted Store of the Application, needed e.g. for the GDS to trust it´s own CA + /// + /// The certificate to add to the store + /// The cancellation token + Task AddOwnCertificateToTrustedStoreAsync(X509Certificate2 certificate, CancellationToken ct); + + /// + /// Create a builder for a UA application configuration. + /// + IApplicationConfigurationBuilderTypes Build(string applicationUri, string productUri); + + /// + /// Checks for a valid application instance certificate. + /// + /// if set to true no dialogs will be displayed. + /// The lifetime in months. + /// Cancellation token to cancel operation with + /// + ValueTask CheckApplicationInstanceCertificatesAsync(bool silent, ushort? lifeTimeInMonths = null, CancellationToken ct = default); + + /// + /// Deletes all application certificates. + /// + /// + ValueTask DeleteApplicationInstanceCertificateAsync(string[] profileIds = null, CancellationToken ct = default); + + /// + /// Loads the application configuration. + /// + /// + ValueTask LoadApplicationConfigurationAsync(bool silent, CancellationToken ct = default); + + /// + /// Loads the application configuration. + /// + /// + Task LoadApplicationConfigurationAsync(Stream stream, bool silent, CancellationToken ct = default); + + /// + /// Loads the application configuration. + /// + ValueTask LoadApplicationConfigurationAsync(string filePath, bool silent, CancellationToken ct = default); + + /// + /// Starts the UA server. + /// + /// The server. + Task StartAsync(IServerBase server); + + /// + /// Stops the UA server. + /// + ValueTask StopAsync(); + } +} diff --git a/Libraries/Opc.Ua.Gds.Server.Common/GlobalDiscoverySampleServer.cs b/Libraries/Opc.Ua.Gds.Server.Common/GlobalDiscoverySampleServer.cs index e0ea9d9ae..493eb0361 100644 --- a/Libraries/Opc.Ua.Gds.Server.Common/GlobalDiscoverySampleServer.cs +++ b/Libraries/Opc.Ua.Gds.Server.Common/GlobalDiscoverySampleServer.cs @@ -91,7 +91,7 @@ protected override void OnServerStarted(IServerInternal server) /// always creates a CoreNodeManager which handles the built-in nodes defined by the specification. /// Any additional NodeManagers are expected to handle application specific nodes. /// - protected override MasterNodeManager CreateMasterNodeManager( + protected override IMasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) { diff --git a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs index 1a0059918..f21e37e87 100644 --- a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs +++ b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs @@ -43,11 +43,10 @@ namespace Opc.Ua.Server { - /// /// The Server Configuration Node Manager. /// - public class ConfigurationNodeManager : DiagnosticsNodeManager, ICallAsyncNodeManager + public class ConfigurationNodeManager : DiagnosticsNodeManager, ICallAsyncNodeManager, IConfigurationNodeManager { /// /// Initializes the configuration and diagnostics manager. @@ -250,9 +249,7 @@ protected override NodeState AddBehaviourToPredefinedNode( return base.AddBehaviourToPredefinedNode(context, predefinedNode); } - /// - /// Creates the configuration node for the server. - /// + /// public void CreateServerConfiguration( ServerSystemContext systemContext, ApplicationConfiguration configuration) @@ -318,9 +315,7 @@ .. configuration.ServerConfiguration.SupportedPrivateKeyFormats } } - /// - /// Gets and returns the node associated with the specified NamespaceUri - /// + /// public NamespaceMetadataState GetNamespaceMetadataState(string namespaceUri) { if (namespaceUri == null) @@ -347,9 +342,7 @@ public NamespaceMetadataState GetNamespaceMetadataState(string namespaceUri) return namespaceMetadataState; } - /// - /// Gets or creates the node for the specified NamespaceUri. - /// + /// public NamespaceMetadataState CreateNamespaceMetadataState(string namespaceUri) { NamespaceMetadataState namespaceMetadataState = FindNamespaceMetadataState( @@ -391,21 +384,13 @@ public NamespaceMetadataState CreateNamespaceMetadataState(string namespaceUri) return namespaceMetadataState; } - /// - /// Determine if the impersonated user has admin access. - /// - /// - /// + /// public void HasApplicationSecureAdminAccess(ISystemContext context) { HasApplicationSecureAdminAccess(context, null); } - /// - /// Determine if the impersonated user has admin access. - /// - /// - /// + /// public void HasApplicationSecureAdminAccess( ISystemContext context, CertificateStoreIdentifier _) diff --git a/Libraries/Opc.Ua.Server/Configuration/IConfigurationNodeManager.cs b/Libraries/Opc.Ua.Server/Configuration/IConfigurationNodeManager.cs new file mode 100644 index 000000000..b7b73916c --- /dev/null +++ b/Libraries/Opc.Ua.Server/Configuration/IConfigurationNodeManager.cs @@ -0,0 +1,66 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.Server +{ + /// + /// The Server Configuration Node Manager. + /// + public interface IConfigurationNodeManager : INodeManager2 + { + /// + /// Gets or creates the node for the specified NamespaceUri. + /// + NamespaceMetadataState CreateNamespaceMetadataState(string namespaceUri); + + /// + /// Creates the configuration node for the server. + /// + void CreateServerConfiguration(ServerSystemContext systemContext, ApplicationConfiguration configuration); + + /// + /// Gets and returns the node associated with the specified NamespaceUri + /// + NamespaceMetadataState GetNamespaceMetadataState(string namespaceUri); + + /// + /// Determine if the impersonated user has admin access. + /// + /// + /// + void HasApplicationSecureAdminAccess(ISystemContext context); + + /// + /// Determine if the impersonated user has admin access. + /// + /// + /// + void HasApplicationSecureAdminAccess(ISystemContext context, CertificateStoreIdentifier trustedStore); + } +} diff --git a/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs b/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs index 0ed69b096..db971d714 100644 --- a/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs +++ b/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs @@ -37,10 +37,8 @@ namespace Opc.Ua.Server { - /// - /// A node manager the diagnostic information exposed by the server. - /// - public class DiagnosticsNodeManager : CustomNodeManager2 + /// + public class DiagnosticsNodeManager : CustomNodeManager2, IDiagnosticsNodeManager { /// /// Initializes the node manager. @@ -237,7 +235,7 @@ var setSubscriptionDurable /// /// Called when a client sets a subscription as durable. /// - public ServiceResult OnSetSubscriptionDurable( + protected ServiceResult OnSetSubscriptionDurable( ISystemContext context, MethodState method, NodeId objectId, @@ -255,7 +253,7 @@ public ServiceResult OnSetSubscriptionDurable( /// /// Called when a client gets the monitored items of a subscription. /// - public ServiceResult OnGetMonitoredItems( + protected ServiceResult OnGetMonitoredItems( ISystemContext context, MethodState method, IList inputArguments, @@ -301,7 +299,7 @@ public ServiceResult OnGetMonitoredItems( /// /// Called when a client initiates resending of all data monitored items in a Subscription. /// - public ServiceResult OnResendData( + protected ServiceResult OnResendData( ISystemContext context, MethodState method, IList inputArguments, @@ -342,7 +340,7 @@ public ServiceResult OnResendData( /// /// Called when a client locks the server. /// - public ServiceResult OnLockServer( + protected ServiceResult OnLockServer( ISystemContext context, MethodState method, IList inputArguments, @@ -363,7 +361,7 @@ public ServiceResult OnLockServer( /// /// Called when a client locks the server. /// - public ServiceResult OnUnlockServer( + protected ServiceResult OnUnlockServer( ISystemContext context, MethodState method, IList inputArguments, @@ -508,7 +506,7 @@ protected override NodeState AddBehaviourToPredefinedNode( /// /// Handles a request to refresh conditions for a subscription. /// - private ServiceResult OnConditionRefresh( + protected ServiceResult OnConditionRefresh( ISystemContext context, MethodState method, NodeId objectId, @@ -524,7 +522,7 @@ private ServiceResult OnConditionRefresh( /// /// Handles a request to refresh conditions for a subscription and specific monitored item. /// - private ServiceResult OnConditionRefresh2( + protected ServiceResult OnConditionRefresh2( ISystemContext context, MethodState method, NodeId objectId, @@ -598,22 +596,16 @@ private static bool IsDiagnosticsStructureNode(NodeState node) } } - /// - /// Force out of band diagnostics update after a change of diagnostics variables. - /// + /// public void ForceDiagnosticsScan() { m_lastDiagnosticsScanTime = DateTime.MinValue; } - /// - /// True if diagnostics are currently enabled. - /// + /// public bool DiagnosticsEnabled { get; private set; } - /// - /// Sets the flag controlling whether diagnostics is enabled for the server. - /// + /// public void SetDiagnosticsEnabled(ServerSystemContext context, bool enabled) { var nodesToDelete = new List(); @@ -730,9 +722,7 @@ public void SetDiagnosticsEnabled(ServerSystemContext context, bool enabled) } } - /// - /// Creates the diagnostics node for the server. - /// + /// public void CreateServerDiagnostics( ServerSystemContext systemContext, ServerDiagnosticsSummaryDataType diagnostics, @@ -804,9 +794,7 @@ public void CreateServerDiagnostics( } } - /// - /// Creates the diagnostics node for a subscription. - /// + /// public NodeId CreateSessionDiagnostics( ServerSystemContext systemContext, SessionDiagnosticsDataType diagnostics, @@ -908,9 +896,7 @@ public NodeId CreateSessionDiagnostics( return nodeId; } - /// - /// Delete the diagnostics node for a session. - /// + /// public void DeleteSessionDiagnostics(ServerSystemContext systemContext, NodeId nodeId) { lock (Lock) @@ -936,9 +922,7 @@ public void DeleteSessionDiagnostics(ServerSystemContext systemContext, NodeId n DeleteNode(systemContext, nodeId); } - /// - /// Creates the diagnostics node for a subscription. - /// + /// public NodeId CreateSubscriptionDiagnostics( ServerSystemContext systemContext, SubscriptionDiagnosticsDataType diagnostics, @@ -1030,9 +1014,7 @@ public NodeId CreateSubscriptionDiagnostics( return nodeId; } - /// - /// Delete the diagnostics node for a subscription. - /// + /// public void DeleteSubscriptionDiagnostics(ServerSystemContext systemContext, NodeId nodeId) { lock (Lock) @@ -1052,9 +1034,7 @@ public void DeleteSubscriptionDiagnostics(ServerSystemContext systemContext, Nod DeleteNode(systemContext, nodeId); } - /// - /// Gets the default history capabilities object. - /// + /// public HistoryServerCapabilitiesState GetDefaultHistoryCapabilities() { lock (Lock) @@ -1121,13 +1101,7 @@ var historyServerCapabilitiesNode } } - /// - /// Updates the Server object EventNotifier based on history capabilities. - /// - /// - /// This method can be overridden to customize the Server EventNotifier based on - /// history capabilities settings. - /// + /// public virtual void UpdateServerEventNotifier() { lock (Lock) @@ -1176,9 +1150,7 @@ public virtual void UpdateServerEventNotifier() } } - /// - /// Adds an aggregate function to the server capabilities object. - /// + /// public void AddAggregateFunction( NodeId aggregateId, string aggregateName, @@ -1226,9 +1198,7 @@ public void AddAggregateFunction( } } - /// - /// Adds a modelling rule to the server capabilities object. - /// + /// public void AddModellingRule( NodeId modellingRuleId, string modellingRuleName) diff --git a/Libraries/Opc.Ua.Server/Diagnostics/IDiagnosticsNodeManager.cs b/Libraries/Opc.Ua.Server/Diagnostics/IDiagnosticsNodeManager.cs new file mode 100644 index 000000000..77f39453d --- /dev/null +++ b/Libraries/Opc.Ua.Server/Diagnostics/IDiagnosticsNodeManager.cs @@ -0,0 +1,120 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Server +{ + /// + /// A node manager the diagnostic information exposed by the server. + /// + public interface IDiagnosticsNodeManager : INodeManager2, INodeIdFactory + { + /// + /// True if diagnostics are currently enabled. + /// + bool DiagnosticsEnabled { get; } + + /// + /// Adds an aggregate function to the server capabilities object. + /// + void AddAggregateFunction(NodeId aggregateId, string aggregateName, bool isHistorical); + + /// + /// Adds a modelling rule to the server capabilities object. + /// + void AddModellingRule(NodeId modellingRuleId, string modellingRuleName); + + /// + /// Creates the diagnostics node for the server. + /// + void CreateServerDiagnostics( + ServerSystemContext systemContext, + ServerDiagnosticsSummaryDataType diagnostics, + NodeValueSimpleEventHandler updateCallback); + + /// + /// Creates the diagnostics node for a session. + /// + NodeId CreateSessionDiagnostics( + ServerSystemContext systemContext, + SessionDiagnosticsDataType diagnostics, + NodeValueSimpleEventHandler updateCallback, + SessionSecurityDiagnosticsDataType securityDiagnostics, + NodeValueSimpleEventHandler updateSecurityCallback); + + /// + /// Creates the diagnostics node for a subscription. + /// + NodeId CreateSubscriptionDiagnostics( + ServerSystemContext systemContext, + SubscriptionDiagnosticsDataType diagnostics, + NodeValueSimpleEventHandler updateCallback); + + /// + /// Delete the diagnostics node for a session. + /// + void DeleteSessionDiagnostics(ServerSystemContext systemContext, NodeId nodeId); + + /// + /// Delete the diagnostics node for a subscription. + /// + void DeleteSubscriptionDiagnostics(ServerSystemContext systemContext, NodeId nodeId); + + /// + /// Finds the specified and checks if it is of the expected type. + /// + /// Returns null if not found or not of the correct type. + NodeState FindPredefinedNode(NodeId nodeId, Type expectedType); + + /// + /// Force out of band diagnostics update after a change of diagnostics variables. + /// + void ForceDiagnosticsScan(); + + /// + /// Gets the default history capabilities object. + /// + HistoryServerCapabilitiesState GetDefaultHistoryCapabilities(); + + /// + /// Sets the flag controlling whether diagnostics is enabled for the server. + /// + void SetDiagnosticsEnabled(ServerSystemContext context, bool enabled); + + /// + /// Updates the Server object EventNotifier based on history capabilities. + /// + /// + /// This method can be overridden to customize the Server EventNotifier based on + /// history capabilities settings. + /// + void UpdateServerEventNotifier(); + } +} diff --git a/Libraries/Opc.Ua.Server/NodeManager/CoreNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/CoreNodeManager.cs index 0679ffda4..5749a1967 100644 --- a/Libraries/Opc.Ua.Server/NodeManager/CoreNodeManager.cs +++ b/Libraries/Opc.Ua.Server/NodeManager/CoreNodeManager.cs @@ -36,14 +36,8 @@ namespace Opc.Ua.Server { - /// - /// The default node manager for the server. - /// - /// - /// Every Server has one instance of this NodeManager. - /// It stores objects that implement ILocalNode and indexes them by NodeId. - /// - public class CoreNodeManager : INodeManager, IDisposable + /// + public class CoreNodeManager : INodeManager, IDisposable, ICoreNodeManager { /// /// Initializes the object with default values. @@ -122,18 +116,14 @@ protected virtual void Dispose(bool disposing) /// public object DataLock { get; } = new object(); - /// - /// Imports the nodes from a dictionary of NodeState objects. - /// + /// public void ImportNodes(ISystemContext context, IEnumerable predefinedNodes) { ImportNodes(context, predefinedNodes, false); } - /// - /// Imports the nodes from a dictionary of NodeState objects. - /// - internal void ImportNodes( + /// + public void ImportNodes( ISystemContext context, IEnumerable predefinedNodes, bool isInternal) @@ -148,11 +138,11 @@ internal void ImportNodes( node.Export(context, nodesToExport); } - lock (Server.CoreNodeManager.DataLock) + lock (DataLock) { foreach (ILocalNode nodeToExport in nodesToExport.OfType()) { - Server.CoreNodeManager.AttachNode(nodeToExport, isInternal); + AttachNode(nodeToExport, isInternal); } } } @@ -791,7 +781,7 @@ public void Read( { value.SourceTimestamp = DateTime.UtcNow; } - + // Set ServerTimestamp to match SourceTimestamp for Value attributes // This ensures ServerTimestamp and SourceTimestamp are equal, // which is important for nodes like ServerStatus children where @@ -2898,7 +2888,7 @@ public void DeleteNode(NodeId nodeId, bool deleteChildren, bool silent) if (referencesToDelete.Count > 0) { - Task.Run(() => OnDeleteReferences(referencesToDelete)); + Task.Run(() => OnDeleteReferencesAsync(referencesToDelete)); } } @@ -2980,20 +2970,14 @@ private void DeleteNode( /// /// Deletes the external references to a node in a background thread. /// - private void OnDeleteReferences(object state) + private async ValueTask OnDeleteReferencesAsync(Dictionary> referencesToDelete) { - var referencesToDelete = state as Dictionary>; - - if (state == null) - { - return; - } - foreach (KeyValuePair> current in referencesToDelete) { try { - Server.NodeManager.DeleteReferences(current.Key, current.Value); + await Server.NodeManager.DeleteReferencesAsync(current.Key, current.Value) + .ConfigureAwait(false); } catch (Exception e) { @@ -3169,7 +3153,7 @@ private void AddReferenceToLocalNode( if (!isInternal && source.NodeId.NamespaceIndex == 0) { - lock (Server.DiagnosticsNodeManager.Lock) + lock (Server.DiagnosticsLock) { NodeState state = Server.DiagnosticsNodeManager .FindPredefinedNode(source.NodeId, null); diff --git a/Libraries/Opc.Ua.Server/NodeManager/ICoreNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/ICoreNodeManager.cs new file mode 100644 index 000000000..4192d5651 --- /dev/null +++ b/Libraries/Opc.Ua.Server/NodeManager/ICoreNodeManager.cs @@ -0,0 +1,56 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Collections.Generic; + +namespace Opc.Ua.Server +{ + /// + /// The default node manager for the server. + /// + /// + /// Every Server has one instance of this NodeManager. + /// It stores objects that implement ILocalNode and indexes them by NodeId. + /// + public interface ICoreNodeManager : INodeManager + { + /// + /// Imports the nodes from a dictionary of NodeState objects. + /// + void ImportNodes(ISystemContext context, IEnumerable predefinedNodes); + + /// + /// Imports the nodes from a dictionary of NodeState objects. + /// + void ImportNodes( + ISystemContext context, + IEnumerable predefinedNodes, + bool isInternal); + } +} diff --git a/Libraries/Opc.Ua.Server/NodeManager/IMainNodeManagerFactory.cs b/Libraries/Opc.Ua.Server/NodeManager/IMainNodeManagerFactory.cs new file mode 100644 index 000000000..2db435c3e --- /dev/null +++ b/Libraries/Opc.Ua.Server/NodeManager/IMainNodeManagerFactory.cs @@ -0,0 +1,51 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.Server +{ + /// + /// Interface of the main node manager factory which helps creating main + /// node managers used by the server. + /// + public interface IMainNodeManagerFactory + { + /// + /// Creates the configuration node manager. + /// + /// The configuration node manager. + IConfigurationNodeManager CreateConfigurationNodeManager(); + + /// + /// Creates the core node manager. + /// + /// The namespace index of the dynamic namespace. + /// The core node manager + ICoreNodeManager CreateCoreNodeManager(ushort dynamicNamespaceIndex); + } +} diff --git a/Libraries/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs new file mode 100644 index 000000000..8543d45c2 --- /dev/null +++ b/Libraries/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs @@ -0,0 +1,356 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Server +{ + /// + /// The master node manager for the server. + /// + public interface IMasterNodeManager + { + /// + /// The node managers being managed. + /// + IReadOnlyList AsyncNodeManagers { get; } + + /// + /// Returns the configuration node manager. + /// + IConfigurationNodeManager ConfigurationNodeManager { get; } + + /// + /// Returns the core node manager. + /// + ICoreNodeManager CoreNodeManager { get; } + + /// + /// Returns the diagnostics node manager. + /// + IDiagnosticsNodeManager DiagnosticsNodeManager { get; } + + /// + /// The node managers being managed. + /// + IReadOnlyList NodeManagers { get; } + + /// + /// Adds the references to the target. + /// + ValueTask AddReferencesAsync(NodeId sourceId, IList references, CancellationToken cancellationToken = default); + + /// + /// Returns the set of references that meet the filter criteria. + /// + /// is null. + /// + ValueTask<(BrowseResultCollection results, DiagnosticInfoCollection diagnosticInfos)> BrowseAsync( + OperationContext context, + ViewDescription view, + uint maxReferencesPerNode, + BrowseDescriptionCollection nodesToBrowse, + CancellationToken cancellationToken = default); + + /// + /// Continues a browse operation that was previously halted. + /// + /// is null. + /// + ValueTask<(BrowseResultCollection results, DiagnosticInfoCollection diagnosticInfos)> BrowseNextAsync( + OperationContext context, + bool releaseContinuationPoints, + ByteStringCollection continuationPoints, + CancellationToken cancellationToken = default); + + /// + /// Calls a method defined on an object. + /// + /// + /// is null. + ValueTask<(CallMethodResultCollection results, DiagnosticInfoCollection diagnosticInfos)> CallAsync( + OperationContext context, + CallMethodRequestCollection methodsToCall, + CancellationToken cancellationToken = default); + + /// + /// Handles condition refresh request. + /// + ValueTask ConditionRefreshAsync(OperationContext context, IList monitoredItems, CancellationToken cancellationToken = default); + + /// + /// Creates a set of monitored items. + /// + /// is null. + /// + /// + ValueTask CreateMonitoredItemsAsync( + OperationContext context, + uint subscriptionId, + double publishingInterval, + TimestampsToReturn timestampsToReturn, + IList itemsToCreate, + IList errors, + IList filterResults, + IList monitoredItems, + bool createDurable, + CancellationToken cancellationToken = default); + + /// + /// Deletes a set of monitored items. + /// + /// is null. + ValueTask DeleteMonitoredItemsAsync( + OperationContext context, + uint subscriptionId, + IList itemsToDelete, + IList errors, + CancellationToken cancellationToken = default); + + /// + /// Deletes the references to the target. + /// + ValueTask DeleteReferencesAsync(NodeId targetId, IList references, CancellationToken cancellationToken = default); + + /// + /// Returns node handle and its node manager. + /// + [Obsolete("Use GetManagerHandleAsync instead.")] + object GetManagerHandle(NodeId nodeId, out IAsyncNodeManager nodeManager); + + /// + /// Returns node handle and its node manager. + /// + object GetManagerHandle(NodeId nodeId, out INodeManager nodeManager); + + /// + /// Returns node handle and its node manager. + /// + ValueTask<(object handle, IAsyncNodeManager nodeManager)> GetManagerHandleAsync(NodeId nodeId, CancellationToken cancellationToken = default); + + /// + /// Reads the history of a set of items. + /// + /// + ValueTask<(HistoryReadResultCollection values, DiagnosticInfoCollection diagnosticInfos)> HistoryReadAsync( + OperationContext context, + ExtensionObject historyReadDetails, + TimestampsToReturn timestampsToReturn, + bool releaseContinuationPoints, + HistoryReadValueIdCollection nodesToRead, + CancellationToken cancellationToken = default); + + /// + /// Updates the history for a set of nodes. + /// + ValueTask<(HistoryUpdateResultCollection results, DiagnosticInfoCollection diagnosticInfos)> HistoryUpdateAsync( + OperationContext context, + ExtensionObjectCollection historyUpdateDetails, + CancellationToken cancellationToken = default); + + /// + /// Modifies a set of monitored items. + /// + /// is null. + /// + ValueTask ModifyMonitoredItemsAsync( + OperationContext context, + TimestampsToReturn timestampsToReturn, + IList monitoredItems, + IList itemsToModify, + IList errors, + IList filterResults, + CancellationToken cancellationToken = default); + + /// + /// Reads a set of nodes. + /// + /// is null. + /// + ValueTask<(DataValueCollection values, DiagnosticInfoCollection diagnosticInfos)> ReadAsync( + OperationContext context, + double maxAge, + TimestampsToReturn timestampsToReturn, + ReadValueIdCollection nodesToRead, + CancellationToken cancellationToken = default); + + /// + /// Registers the node manager as the node manager for Nodes in the specified namespace. + /// + /// The URI of the namespace. + /// The NodeManager which owns node in the namespace. + /// + /// + /// Multiple NodeManagers may register interest in a Namespace. + /// The order in which this method is called determines the precedence if multiple NodeManagers exist. + /// This method adds the namespaceUri to the Server's Namespace table if it does not already exist. + /// + /// This method is thread safe and can be called at anytime. + /// + /// This method does not have to be called for any namespaces that were in the NodeManager's + /// NamespaceUri property when the MasterNodeManager was created. + /// + /// + /// Throw if the namespaceUri or the nodeManager are null. + + void RegisterNamespaceManager(string namespaceUri, IAsyncNodeManager nodeManager); + + /// + /// Registers the node manager as the node manager for Nodes in the specified namespace. + /// + /// The URI of the namespace. + /// The NodeManager which owns node in the namespace. + /// + /// + /// Multiple NodeManagers may register interest in a Namespace. + /// The order in which this method is called determines the precedence if multiple NodeManagers exist. + /// This method adds the namespaceUri to the Server's Namespace table if it does not already exist. + /// + /// This method is thread safe and can be called at anytime. + /// + /// This method does not have to be called for any namespaces that were in the NodeManager's + /// NamespaceUri property when the MasterNodeManager was created. + /// + /// + /// Throw if the namespaceUri or the nodeManager are null. + void RegisterNamespaceManager(string namespaceUri, INodeManager nodeManager); + + /// + /// Registers a set of node ids. + /// + /// is null. + + void RegisterNodes(OperationContext context, NodeIdCollection nodesToRegister, out NodeIdCollection registeredNodeIds); + + /// + /// Deletes the specified references. + /// + void RemoveReferences(List referencesToRemove); + + /// + /// Deletes the specified references. + /// + ValueTask RemoveReferencesAsync(List referencesToRemove, CancellationToken cancellationToken = default); + + /// + /// Restore a set of monitored items after a Server Restart. + /// + /// is null. + /// + ValueTask RestoreMonitoredItemsAsync( + IList itemsToRestore, + IList monitoredItems, + IUserIdentity savedOwnerIdentity, + CancellationToken cancellationToken = default); + + /// + /// Signals that a session is closing. + /// + ValueTask SessionClosingAsync(OperationContext context, NodeId sessionId, bool deleteSubscriptions, CancellationToken cancellationToken = default); + + /// + /// Changes the monitoring mode for a set of items. + /// + /// is null. + ValueTask SetMonitoringModeAsync( + OperationContext context, + MonitoringMode monitoringMode, + IList itemsToModify, + IList errors, + CancellationToken cancellationToken = default); + + /// + /// Shuts down the node managers. + /// + ValueTask ShutdownAsync(CancellationToken cancellationToken = default); + + /// + /// Creates the node managers and start them + /// + ValueTask StartupAsync(CancellationToken cancellationToken = default); + + /// + /// Transfers a set of monitored items. + /// + /// is null. + + ValueTask TransferMonitoredItemsAsync( + OperationContext context, + bool sendInitialValues, + IList monitoredItems, + IList errors, + CancellationToken cancellationToken = default); + + /// + /// Translates a start node id plus a relative paths into a node id. + /// + /// is null. + /// + ValueTask<(BrowsePathResultCollection results, DiagnosticInfoCollection diagnosticInfos)> TranslateBrowsePathsToNodeIdsAsync( + OperationContext context, + BrowsePathCollection browsePaths, + CancellationToken cancellationToken = default); + + /// + /// Unregisters the node manager as the node manager for Nodes in the specified namespace. + /// + /// The URI of the namespace. + /// The NodeManager which no longer owns nodes in the namespace. + /// A value indicating whether the node manager was successfully unregistered. + /// Throw if the namespaceUri or the nodeManager are null. + bool UnregisterNamespaceManager(string namespaceUri, IAsyncNodeManager nodeManager); + + /// + /// Unregisters the node manager as the node manager for Nodes in the specified namespace. + /// + /// The URI of the namespace. + /// The NodeManager which no longer owns nodes in the namespace. + /// A value indicating whether the node manager was successfully unregistered. + /// Throw if the namespaceUri or the nodeManager are null. + bool UnregisterNamespaceManager(string namespaceUri, INodeManager nodeManager); + + /// + /// Unregisters a set of node ids. + /// + /// is null. + void UnregisterNodes(OperationContext context, NodeIdCollection nodesToUnregister); + + /// + /// Writes a set of values. + /// + /// is null. + ValueTask<(StatusCodeCollection results, DiagnosticInfoCollection diagnosticInfos)> WriteAsync( + OperationContext context, + WriteValueCollection nodesToWrite, + CancellationToken cancellationToken = default); + } +} diff --git a/Libraries/Opc.Ua.Server/NodeManager/MainNodeManagerFactory.cs b/Libraries/Opc.Ua.Server/NodeManager/MainNodeManagerFactory.cs new file mode 100644 index 000000000..118a8dd1a --- /dev/null +++ b/Libraries/Opc.Ua.Server/NodeManager/MainNodeManagerFactory.cs @@ -0,0 +1,66 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using Opc.Ua.Configuration; + +namespace Opc.Ua.Server +{ + /// + /// The factory that creates the main node managers of the server. The main + /// node managers are the one always present when creating a server. + /// + public class MainNodeManagerFactory : IMainNodeManagerFactory + { + /// + /// Initializes the object with default values. + /// + public MainNodeManagerFactory( + ApplicationConfiguration applicationConfiguration, + IServerInternal server) + { + m_applicationConfiguration = applicationConfiguration; + m_server = server; + } + + /// + public IConfigurationNodeManager CreateConfigurationNodeManager() + { + return new ConfigurationNodeManager(m_server, m_applicationConfiguration); + } + + /// + public ICoreNodeManager CreateCoreNodeManager(ushort dynamicNamespaceIndex) + { + return new CoreNodeManager(m_server, m_applicationConfiguration, dynamicNamespaceIndex); + } + + private readonly ApplicationConfiguration m_applicationConfiguration; + private readonly IServerInternal m_server; + } +} diff --git a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs index 0a672abc9..7c3eb42f2 100644 --- a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs +++ b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs @@ -38,10 +38,8 @@ namespace Opc.Ua.Server { - /// - /// The master node manager for the server. - /// - public class MasterNodeManager : IDisposable + /// + public class MasterNodeManager : IDisposable, IMasterNodeManager { /// /// Initializes the object with default values. @@ -111,9 +109,9 @@ public MasterNodeManager( }; // always add the diagnostics and configuration node manager to the start of the list. - var configurationAndDiagnosticsManager = new ConfigurationNodeManager( - server, - configuration); + IConfigurationNodeManager configurationAndDiagnosticsManager + = server.MainNodeManagerFactory.CreateConfigurationNodeManager(); + RegisterNodeManager( configurationAndDiagnosticsManager.ToAsyncNodeManager(), registeredManagers, @@ -121,7 +119,8 @@ public MasterNodeManager( // add the core node manager second because the diagnostics node manager takes priority. // always add the core node manager to the second of the list. - var coreNodeManager = new CoreNodeManager(Server, configuration, (ushort)dynamicNamespaceIndex); + ICoreNodeManager coreNodeManager = server.MainNodeManagerFactory.CreateCoreNodeManager((ushort)dynamicNamespaceIndex); + m_nodeManagers.Add(coreNodeManager.ToAsyncNodeManager()); // register core node manager for default UA namespace. @@ -303,26 +302,18 @@ protected static PermissionType GetHistoryPermissionType(PerformUpdateType updat } } - /// - /// Returns the core node manager. - /// - public CoreNodeManager CoreNodeManager => m_nodeManagers[1].SyncNodeManager as CoreNodeManager; + /// + public ICoreNodeManager CoreNodeManager => m_nodeManagers[1].SyncNodeManager as ICoreNodeManager; - /// - /// Returns the diagnostics node manager. - /// - public DiagnosticsNodeManager DiagnosticsNodeManager - => m_nodeManagers[0].SyncNodeManager as DiagnosticsNodeManager; + /// + public IDiagnosticsNodeManager DiagnosticsNodeManager + => m_nodeManagers[0].SyncNodeManager as IDiagnosticsNodeManager; - /// - /// Returns the configuration node manager. - /// - public ConfigurationNodeManager ConfigurationNodeManager - => m_nodeManagers[0].SyncNodeManager as ConfigurationNodeManager; + /// + public IConfigurationNodeManager ConfigurationNodeManager + => m_nodeManagers[0].SyncNodeManager as IConfigurationNodeManager; - /// - /// Creates the node managers and start them - /// + /// public virtual async ValueTask StartupAsync(CancellationToken cancellationToken = default) { await m_startupShutdownSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -377,9 +368,7 @@ await nodeManager.AddReferencesAsync(externalReferences, cancellationToken) } } - /// - /// Signals that a session is closing. - /// + /// public virtual async ValueTask SessionClosingAsync( OperationContext context, NodeId sessionId, @@ -411,9 +400,7 @@ await nodeManager.SessionClosingAsync(context, sessionId, deleteSubscriptions, c } } - /// - /// Shuts down the node managers. - /// + /// public virtual async ValueTask ShutdownAsync(CancellationToken cancellationToken = default) { await m_startupShutdownSemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -437,47 +424,13 @@ await nodeManager.DeleteAddressSpaceAsync(cancellationToken) } } - /// - /// Registers the node manager as the node manager for Nodes in the specified namespace. - /// - /// The URI of the namespace. - /// The NodeManager which owns node in the namespace. - /// - /// - /// Multiple NodeManagers may register interest in a Namespace. - /// The order in which this method is called determines the precedence if multiple NodeManagers exist. - /// This method adds the namespaceUri to the Server's Namespace table if it does not already exist. - /// - /// This method is thread safe and can be called at anytime. - /// - /// This method does not have to be called for any namespaces that were in the NodeManager's - /// NamespaceUri property when the MasterNodeManager was created. - /// - /// - /// Throw if the namespaceUri or the nodeManager are null. + /// public void RegisterNamespaceManager(string namespaceUri, INodeManager nodeManager) { RegisterNamespaceManager(namespaceUri, nodeManager.ToAsyncNodeManager()); } - /// - /// Registers the node manager as the node manager for Nodes in the specified namespace. - /// - /// The URI of the namespace. - /// The NodeManager which owns node in the namespace. - /// - /// - /// Multiple NodeManagers may register interest in a Namespace. - /// The order in which this method is called determines the precedence if multiple NodeManagers exist. - /// This method adds the namespaceUri to the Server's Namespace table if it does not already exist. - /// - /// This method is thread safe and can be called at anytime. - /// - /// This method does not have to be called for any namespaces that were in the NodeManager's - /// NamespaceUri property when the MasterNodeManager was created. - /// - /// - /// Throw if the namespaceUri or the nodeManager are null. + /// public void RegisterNamespaceManager(string namespaceUri, IAsyncNodeManager nodeManager) { if (string.IsNullOrEmpty(namespaceUri)) @@ -519,25 +472,13 @@ public void RegisterNamespaceManager(string namespaceUri, IAsyncNodeManager node } } - /// - /// Unregisters the node manager as the node manager for Nodes in the specified namespace. - /// - /// The URI of the namespace. - /// The NodeManager which no longer owns nodes in the namespace. - /// A value indicating whether the node manager was successfully unregistered. - /// Throw if the namespaceUri or the nodeManager are null. + /// public bool UnregisterNamespaceManager(string namespaceUri, INodeManager nodeManager) { return UnregisterNamespaceManager(namespaceUri, null, nodeManager); } - /// - /// Unregisters the node manager as the node manager for Nodes in the specified namespace. - /// - /// The URI of the namespace. - /// The NodeManager which no longer owns nodes in the namespace. - /// A value indicating whether the node manager was successfully unregistered. - /// Throw if the namespaceUri or the nodeManager are null. + /// public bool UnregisterNamespaceManager(string namespaceUri, IAsyncNodeManager nodeManager) { return UnregisterNamespaceManager(namespaceUri, nodeManager, null); @@ -600,9 +541,7 @@ private bool UnregisterNamespaceManager(string namespaceUri, IAsyncNodeManager a } } - /// - /// Returns node handle and its node manager. - /// + /// public virtual object GetManagerHandle(NodeId nodeId, out INodeManager nodeManager) { object handle; @@ -645,9 +584,7 @@ public virtual object GetManagerHandle(NodeId nodeId, out INodeManager nodeManag return null; } - /// - /// Returns node handle and its node manager. - /// + /// [Obsolete("Use GetManagerHandleAsync instead.")] public virtual object GetManagerHandle(NodeId nodeId, out IAsyncNodeManager nodeManager) { @@ -659,9 +596,7 @@ public virtual object GetManagerHandle(NodeId nodeId, out IAsyncNodeManager node return result.handle; } - /// - /// Returns node handle and its node manager. - /// + /// public virtual async ValueTask<(object handle, IAsyncNodeManager nodeManager)> GetManagerHandleAsync(NodeId nodeId, CancellationToken cancellationToken = default) { @@ -713,9 +648,7 @@ public virtual void AddReferences(NodeId sourceId, IList references) AddReferencesAsync(sourceId, references).AsTask().GetAwaiter().GetResult(); } - /// - /// Adds the references to the target. - /// + /// public virtual async ValueTask AddReferencesAsync(NodeId sourceId, IList references, CancellationToken cancellationToken = default) @@ -733,17 +666,7 @@ await nodeManager.AddReferencesAsync(map, cancellationToken) .ConfigureAwait(false); } - /// - /// Deletes the references to the target. - /// - public virtual void DeleteReferences(NodeId targetId, IList references) - { - DeleteReferencesAsync(targetId, references).AsTask().GetAwaiter().GetResult(); - } - - /// - /// Deletes the references to the target. - /// + /// public virtual async ValueTask DeleteReferencesAsync(NodeId targetId, IList references, CancellationToken cancellationToken = default) @@ -773,17 +696,13 @@ await nodeManager.DeleteReferenceAsync( } } - /// - /// Deletes the specified references. - /// + /// public void RemoveReferences(List referencesToRemove) { RemoveReferencesAsync(referencesToRemove).AsTask().GetAwaiter().GetResult(); } - /// - /// Deletes the specified references. - /// + /// public async ValueTask RemoveReferencesAsync(List referencesToRemove, CancellationToken cancellationToken = default) { for (int ii = 0; ii < referencesToRemove.Count; ii++) @@ -813,10 +732,7 @@ await nodeManager.DeleteReferenceAsync( } } - /// - /// Registers a set of node ids. - /// - /// is null. + /// public virtual void RegisterNodes( OperationContext context, NodeIdCollection nodesToRegister, @@ -855,10 +771,7 @@ public virtual void RegisterNodes( */ } - /// - /// Unregisters a set of node ids. - /// - /// is null. + /// public virtual void UnregisterNodes( OperationContext context, NodeIdCollection nodesToUnregister) @@ -904,11 +817,7 @@ public virtual void TranslateBrowsePathsToNodeIds( browsePaths).AsTask().GetAwaiter().GetResult(); } - /// - /// Translates a start node id plus a relative paths into a node id. - /// - /// is null. - /// + /// public virtual async ValueTask<(BrowsePathResultCollection results, DiagnosticInfoCollection diagnosticInfos)> TranslateBrowsePathsToNodeIdsAsync( OperationContext context, @@ -1286,11 +1195,7 @@ await TranslateBrowsePathAsync( } } - /// - /// Returns the set of references that meet the filter criteria. - /// - /// is null. - /// + /// public virtual async ValueTask<(BrowseResultCollection results, DiagnosticInfoCollection diagnosticInfos)> BrowseAsync( OperationContext context, ViewDescription view, @@ -1474,11 +1379,7 @@ private static void PrepareValidationCache( } } - /// - /// Continues a browse operation that was previously halted. - /// - /// is null. - /// + /// public virtual async ValueTask<(BrowseResultCollection results, DiagnosticInfoCollection diagnosticInfos)> BrowseNextAsync( OperationContext context, @@ -1885,11 +1786,7 @@ private async ValueTask UpdateReferenceDescriptionAsync( return true; } - /// - /// Reads a set of nodes. - /// - /// is null. - /// + /// public virtual async ValueTask<(DataValueCollection values, DiagnosticInfoCollection diagnosticInfos)> ReadAsync( OperationContext context, double maxAge, @@ -2029,10 +1926,7 @@ await nodeManager.ReadAsync( return (values, diagnosticInfos); } - /// - /// Reads the history of a set of items. - /// - /// + /// public virtual async ValueTask<(HistoryReadResultCollection values, DiagnosticInfoCollection diagnosticInfos)> HistoryReadAsync( OperationContext context, ExtensionObject historyReadDetails, @@ -2160,10 +2054,7 @@ await nodeManager.HistoryReadAsync( return (results, diagnosticInfos); } - /// - /// Writes a set of values. - /// - /// is null. + /// public virtual async ValueTask<(StatusCodeCollection results, DiagnosticInfoCollection diagnosticInfos)> WriteAsync( OperationContext context, WriteValueCollection nodesToWrite, @@ -2272,9 +2163,7 @@ await nodeManager.WriteAsync( return (results, diagnosticInfos); } - /// - /// Updates the history for a set of nodes. - /// + /// public virtual async ValueTask<(HistoryUpdateResultCollection results, DiagnosticInfoCollection diagnosticInfos)> HistoryUpdateAsync( OperationContext context, @@ -2409,11 +2298,7 @@ await nodeManager.HistoryUpdateAsync( return (results, diagnosticInfos); } - /// - /// Calls a method defined on an object. - /// - /// - /// is null. + /// public virtual async ValueTask<(CallMethodResultCollection results, DiagnosticInfoCollection diagnosticInfos)> CallAsync( OperationContext context, @@ -2526,9 +2411,7 @@ await nodeManager.CallAsync( return (results, diagnosticInfos); } - /// - /// Handles condition refresh request. - /// + /// public virtual async ValueTask ConditionRefreshAsync( OperationContext context, IList monitoredItems, @@ -2548,12 +2431,7 @@ await nodeManager.ConditionRefreshAsync(context, monitoredItems, cancellationTok } } - /// - /// Creates a set of monitored items. - /// - /// is null. - /// - /// + /// public virtual async ValueTask CreateMonitoredItemsAsync( OperationContext context, uint subscriptionId, @@ -2828,11 +2706,7 @@ await manager.SubscribeToAllEventsAsync( } } - /// - /// Restore a set of monitored items after a Server Restart. - /// - /// is null. - /// + /// public virtual async ValueTask RestoreMonitoredItemsAsync( IList itemsToRestore, IList monitoredItems, @@ -2957,11 +2831,7 @@ await manager.SubscribeToAllEventsAsync( } } - /// - /// Modifies a set of monitored items. - /// - /// is null. - /// + /// public virtual async ValueTask ModifyMonitoredItemsAsync( OperationContext context, TimestampsToReturn timestampsToReturn, @@ -3155,10 +3025,7 @@ await nodeManager.SubscribeToAllEventsAsync( } } - /// - /// Transfers a set of monitored items. - /// - /// is null. + /// public virtual async ValueTask TransferMonitoredItemsAsync( OperationContext context, bool sendInitialValues, @@ -3204,10 +3071,7 @@ await nodeManager.TransferMonitoredItemsAsync( } } - /// - /// Deletes a set of monitored items. - /// - /// is null. + /// public virtual async ValueTask DeleteMonitoredItemsAsync( OperationContext context, uint subscriptionId, @@ -3324,10 +3188,7 @@ await nodeManager.SubscribeToAllEventsAsync( } } - /// - /// Changes the monitoring mode for a set of items. - /// - /// is null. + /// public virtual async ValueTask SetMonitoringModeAsync( OperationContext context, MonitoringMode monitoringMode, @@ -3422,14 +3283,10 @@ private static void SetMonitoringModeForEvents( /// protected IServerInternal Server { get; } - /// - /// The node managers being managed. - /// + /// public IReadOnlyList AsyncNodeManagers => m_nodeManagers; - /// - /// The node managers being managed. - /// + /// public IReadOnlyList NodeManagers => m_nodeManagers.ConvertAll(m => m.SyncNodeManager); /// diff --git a/Libraries/Opc.Ua.Server/Server/IServerInternal.cs b/Libraries/Opc.Ua.Server/Server/IServerInternal.cs index 18f6d626f..33f49e96e 100644 --- a/Libraries/Opc.Ua.Server/Server/IServerInternal.cs +++ b/Libraries/Opc.Ua.Server/Server/IServerInternal.cs @@ -86,23 +86,35 @@ public interface IServerInternal : IAuditEventServer, IDisposable /// TypeTable TypeTree { get; } + /// + /// The factory which helps creating main + /// node managers used by the server. + /// + IMainNodeManagerFactory MainNodeManagerFactory { get; } + /// /// The master node manager for the server. /// /// The node manager. - MasterNodeManager NodeManager { get; } + IMasterNodeManager NodeManager { get; } /// /// The internal node manager for the servers. /// /// The core node manager. - CoreNodeManager CoreNodeManager { get; } + ICoreNodeManager CoreNodeManager { get; } /// /// Returns the node manager that managers the server diagnostics. /// /// The diagnostics node manager. - DiagnosticsNodeManager DiagnosticsNodeManager { get; } + IDiagnosticsNodeManager DiagnosticsNodeManager { get; } + + /// + /// Returns the node manager that managers the server configuration. + /// + /// The configuration node manager. + IConfigurationNodeManager ConfigurationNodeManager { get; } /// /// The manager for events that all components use to queue events that occur. @@ -286,7 +298,13 @@ void CreateServerObject( /// Stores the MasterNodeManager and the CoreNodeManager /// /// The node manager. - void SetNodeManager(MasterNodeManager nodeManager); + void SetNodeManager(IMasterNodeManager nodeManager); + + /// + /// Stores the MainNodeManagerFactory + /// + /// The main node manager factory. + void SetMainNodeManagerFactory(IMainNodeManagerFactory mainNodeManagerFactory); /// /// Stores the SessionManager, the SubscriptionManager in the datastore. diff --git a/Libraries/Opc.Ua.Server/Server/IStandardServer.cs b/Libraries/Opc.Ua.Server/Server/IStandardServer.cs new file mode 100644 index 000000000..cf851d423 --- /dev/null +++ b/Libraries/Opc.Ua.Server/Server/IStandardServer.cs @@ -0,0 +1,100 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Server +{ + /// + /// The standard implementation of a UA server. + /// + public interface IStandardServer: ISessionServer + { + /// + /// The async node manager factories that are used on startup of the server. + /// + IEnumerable AsyncNodeManagerFactories { get; } + + /// + /// The state object associated with the server. + /// It provides the shared components for the Server. + /// + /// The current instance. + /// + IServerInternal CurrentInstance { get; } + + /// + /// The current state of the Server + /// + ServerState CurrentState { get; } + /// + /// The node manager factories that are used on startup of the server. + /// + IEnumerable NodeManagerFactories { get; } + + /// + /// Add a node manager factory which is used on server start + /// to instantiate the node manager in the server. + /// + /// The node manager factory used to create the NodeManager. + void AddNodeManager(IAsyncNodeManagerFactory nodeManagerFactory); + + /// + /// Add a node manager factory which is used on server start + /// to instantiate the node manager in the server. + /// + /// The node manager factory used to create the NodeManager. + + void AddNodeManager(INodeManagerFactory nodeManagerFactory); + + /// + /// Registers the server with the discovery server. + /// + /// Boolean value. + ValueTask RegisterWithDiscoveryServerAsync(CancellationToken ct = default); + + /// + /// Remove a node manager factory from the list of node managers. + /// Does not remove a NodeManager from a running server, + /// only removes the factory before the server starts. + /// + /// The node manager factory to remove. + void RemoveNodeManager(IAsyncNodeManagerFactory nodeManagerFactory); + + /// + /// Remove a node manager factory from the list of node managers. + /// Does not remove a NodeManager from a running server, + /// only removes the factory before the server starts. + /// + /// The node manager factory to remove. + void RemoveNodeManager(INodeManagerFactory nodeManagerFactory); + } +} diff --git a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs index d6096626c..04eacc333 100644 --- a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs +++ b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs @@ -145,16 +145,26 @@ protected virtual void Dispose(bool disposing) public ISubscriptionManager SubscriptionManager { get; private set; } /// - /// Stores the MasterNodeManager and the CoreNodeManager + /// Stores the MasterNodeManager, the DiagnosticsNodeManager and the CoreNodeManager /// /// The node manager. - public void SetNodeManager(MasterNodeManager nodeManager) + public void SetNodeManager(IMasterNodeManager nodeManager) { NodeManager = nodeManager; DiagnosticsNodeManager = nodeManager.DiagnosticsNodeManager; + ConfigurationNodeManager = nodeManager.ConfigurationNodeManager; CoreNodeManager = nodeManager.CoreNodeManager; } + /// + /// Stores the MainNodeManagerFactory + /// + /// The main node manager factory. + public void SetMainNodeManagerFactory(IMainNodeManagerFactory mainNodeManagerFactory) + { + MainNodeManagerFactory = mainNodeManagerFactory; + } + /// /// Sets the EventManager, the ResourceManager, the RequestManager and the AggregateManager. /// @@ -275,19 +285,25 @@ public void SetModellingRulesManager(ModellingRulesManager modellingRulesManager /// The master node manager for the server. /// /// The node manager. - public MasterNodeManager NodeManager { get; private set; } + public IMasterNodeManager NodeManager { get; private set; } + + /// + public IMainNodeManagerFactory MainNodeManagerFactory { get; private set; } /// /// The internal node manager for the servers. /// /// The core node manager. - public CoreNodeManager CoreNodeManager { get; private set; } + public ICoreNodeManager CoreNodeManager { get; private set; } /// /// Returns the node manager that managers the server diagnostics. /// /// The diagnostics node manager. - public DiagnosticsNodeManager DiagnosticsNodeManager { get; private set; } + public IDiagnosticsNodeManager DiagnosticsNodeManager { get; private set; } + + /// + public IConfigurationNodeManager ConfigurationNodeManager { get; private set; } /// /// The manager for events that all components use to queue events that occur. @@ -599,7 +615,7 @@ public void ReportAuditEvent(ISystemContext context, AuditEventState e) /// private void CreateServerObject() { - lock (DiagnosticsNodeManager.Lock) + lock (DiagnosticsLock) { // get the server object. ServerObjectState serverObject = ServerObject = (ServerObjectState) diff --git a/Libraries/Opc.Ua.Server/Server/StandardServer.cs b/Libraries/Opc.Ua.Server/Server/StandardServer.cs index 817a217e4..d9d1e6ddd 100644 --- a/Libraries/Opc.Ua.Server/Server/StandardServer.cs +++ b/Libraries/Opc.Ua.Server/Server/StandardServer.cs @@ -41,10 +41,8 @@ namespace Opc.Ua.Server { - /// - /// The standard implementation of a UA server. - /// - public class StandardServer : SessionServerBase + /// + public class StandardServer : SessionServerBase, IStandardServer { /// /// An overrideable version of the Dispose. @@ -2272,12 +2270,7 @@ await m_serverInternal.NodeManager.CallAsync(context, methodsToCall, ct) } } - /// - /// The state object associated with the server. - /// It provides the shared components for the Server. - /// - /// The current instance. - /// + /// public IServerInternal CurrentInstance { get @@ -2331,10 +2324,7 @@ public bool RegisterWithDiscoveryServer() return RegisterWithDiscoveryServerAsync().AsTask().GetAwaiter().GetResult(); } - /// - /// Registers the server with the discovery server. - /// - /// Boolean value. + /// public async ValueTask RegisterWithDiscoveryServerAsync(CancellationToken ct = default) { var configuration = new ApplicationConfiguration(Configuration); @@ -3047,9 +3037,14 @@ await base.StartApplicationAsync(configuration, cancellationToken) m_serverInternal, configuration); + //create the main node manager factory + IMainNodeManagerFactory mainNodeManagerFactory = CreateMainNodeManagerFactory(m_serverInternal, configuration); + + m_serverInternal.SetMainNodeManagerFactory(mainNodeManagerFactory); + // create the master node manager. m_logger.LogInformation(Utils.TraceMasks.StartStop, "Server - CreateMasterNodeManager."); - MasterNodeManager masterNodeManager = CreateMasterNodeManager( + IMasterNodeManager masterNodeManager = CreateMasterNodeManager( m_serverInternal, configuration); @@ -3631,7 +3626,7 @@ protected virtual ResourceManager CreateResourceManager( /// The server. /// The configuration. /// Returns the master node manager for the server, the return type is . - protected virtual MasterNodeManager CreateMasterNodeManager( + protected virtual IMasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) { @@ -3652,6 +3647,19 @@ protected virtual MasterNodeManager CreateMasterNodeManager( return new MasterNodeManager(server, configuration, null, asyncNodeManagers, nodeManagers); } + /// + /// Creates the master node manager for the server. + /// + /// The server. + /// The configuration. + /// Returns the master node manager for the server, the return type is . + protected virtual IMainNodeManagerFactory CreateMainNodeManagerFactory( + IServerInternal server, + ApplicationConfiguration configuration) + { + return new MainNodeManagerFactory(configuration, server); + } + /// /// Creates the event manager for the server. /// @@ -3738,54 +3746,32 @@ protected virtual void OnServerStarted(IServerInternal server) // may be overridden by the subclass. } - /// - /// The node manager factories that are used on startup of the server. - /// + /// public IEnumerable NodeManagerFactories => m_nodeManagerFactories; - /// - /// The async node manager factories that are used on startup of the server. - /// + /// public IEnumerable AsyncNodeManagerFactories => m_asyncNodeManagerFactories; - /// - /// Add a node manager factory which is used on server start - /// to instantiate the node manager in the server. - /// - /// The node manager factory used to create the NodeManager. + /// public virtual void AddNodeManager(INodeManagerFactory nodeManagerFactory) { m_nodeManagerFactories.Add(nodeManagerFactory); } - /// - /// Add a node manager factory which is used on server start - /// to instantiate the node manager in the server. - /// - /// The node manager factory used to create the NodeManager. + /// public virtual void AddNodeManager(IAsyncNodeManagerFactory nodeManagerFactory) { m_asyncNodeManagerFactories.Add(nodeManagerFactory); } - /// - /// Remove a node manager factory from the list of node managers. - /// Does not remove a NodeManager from a running server, - /// only removes the factory before the server starts. - /// - /// The node manager factory to remove. + /// public virtual void RemoveNodeManager(INodeManagerFactory nodeManagerFactory) { m_nodeManagerFactories.Remove(nodeManagerFactory); } - /// - /// Remove a node manager factory from the list of node managers. - /// Does not remove a NodeManager from a running server, - /// only removes the factory before the server starts. - /// - /// The node manager factory to remove. + /// public virtual void RemoveNodeManager(IAsyncNodeManagerFactory nodeManagerFactory) { m_asyncNodeManagerFactories.Remove(nodeManagerFactory); diff --git a/Tests/Opc.Ua.Client.Tests/ReferenceServerWithLimits.cs b/Tests/Opc.Ua.Client.Tests/ReferenceServerWithLimits.cs index 6b8a333ca..13c0a1f25 100644 --- a/Tests/Opc.Ua.Client.Tests/ReferenceServerWithLimits.cs +++ b/Tests/Opc.Ua.Client.Tests/ReferenceServerWithLimits.cs @@ -97,7 +97,7 @@ public void SetMaxNumberOfContinuationPoints(uint maxNumberOfContinuationPoints) } } - protected override MasterNodeManager CreateMasterNodeManager( + protected override IMasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) { diff --git a/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs b/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs index f368e58c2..070ce96aa 100644 --- a/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs +++ b/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs @@ -43,7 +43,7 @@ namespace Opc.Ua.Gds.Tests public class GlobalDiscoveryTestServer { public GlobalDiscoverySampleServer Server { get; private set; } - public ApplicationInstance Application { get; private set; } + public IApplicationInstance Application { get; private set; } public ApplicationConfiguration Config { get; private set; } public int BasePort { get; private set; } @@ -237,7 +237,7 @@ private static void RegisterDefaultUsers(IUserDatabase userDatabase) } private static async Task LoadAsync( - ApplicationInstance application, + IApplicationInstance application, int basePort, int maxTrustListSize) { diff --git a/Tests/Opc.Ua.Server.Tests/ServerFixture.cs b/Tests/Opc.Ua.Server.Tests/ServerFixture.cs index 4a9020baa..555bda63a 100644 --- a/Tests/Opc.Ua.Server.Tests/ServerFixture.cs +++ b/Tests/Opc.Ua.Server.Tests/ServerFixture.cs @@ -45,7 +45,7 @@ namespace Opc.Ua.Server.Tests public class ServerFixture where T : ServerBase, new() { - public ApplicationInstance Application { get; private set; } + public IApplicationInstance Application { get; private set; } public ApplicationConfiguration Config { get; private set; } public T Server { get; private set; } public bool AutoAccept { get; set; } From 9e192c66713f731daba0033baacd7f548c4b1e22 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 08:07:33 +0100 Subject: [PATCH 18/42] Fix ObjectDisposedException in SubscriptionManager background tasks on shutdown (#3456) * Initial plan * Fix ObjectDisposedException in SubscriptionManager background tasks Add ObjectDisposedException handling in PublishSubscriptionsAsync and ConditionRefreshWorkerAsync to gracefully handle shutdown race condition where semaphore/events are disposed while background tasks are still running. Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Update log messages to differentiate normal and disposed shutdown paths Added clarification in log messages to indicate when tasks exit due to ObjectDisposedException during shutdown, making it easier to distinguish from normal shutdown in logs. Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- .../Subscription/SubscriptionManager.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Libraries/Opc.Ua.Server/Subscription/SubscriptionManager.cs b/Libraries/Opc.Ua.Server/Subscription/SubscriptionManager.cs index 3a55c687d..3f5a565cb 100644 --- a/Libraries/Opc.Ua.Server/Subscription/SubscriptionManager.cs +++ b/Libraries/Opc.Ua.Server/Subscription/SubscriptionManager.cs @@ -2155,6 +2155,12 @@ private async ValueTask PublishSubscriptionsAsync(int sleepCycle, CancellationTo await Task.Delay(timeToWait, cancellationToken).ConfigureAwait(false); } } + catch (ObjectDisposedException) + { + m_logger.LogInformation( + "Subscription - Publish Task {TaskId:X8} Exited Normally (disposed during shutdown).", + Task.CurrentId); + } catch (Exception e) { m_logger.LogError( @@ -2218,6 +2224,12 @@ await DoConditionRefresh2Async( } } } + catch (ObjectDisposedException) + { + m_logger.LogInformation( + "Subscription - ConditionRefresh Task {TaskId:X8} Exited Normally (disposed during shutdown).", + Task.CurrentId); + } catch (Exception e) { m_logger.LogError( From f7c3c4e18fb063a468df66bbf713b0d4bfc40941 Mon Sep 17 00:00:00 2001 From: romanett Date: Mon, 12 Jan 2026 13:09:15 +0100 Subject: [PATCH 19/42] Fix Server/ServerStatus/State SourceTimestamp. Fix Reference Server config, not loading OperationLimits due to wrong order. (#3454) * fix ServerSourceTimestamp * fix OperationLimits not being set for ReferenceServer --- .../Quickstarts.ReferenceServer.Config.xml | 11 +++++++---- Libraries/Opc.Ua.Server/Server/ServerInternalData.cs | 1 + Stack/Opc.Ua.Core/Schema/ApplicationConfiguration.cs | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml b/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml index 16fd703f6..a6f250313 100644 --- a/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml +++ b/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml @@ -265,10 +265,6 @@ 20 100 10000 - true - 10000 - 10000 - 10 http://opcfoundation.org/UA-Profile/Server/StandardUA2017 @@ -305,7 +301,10 @@ 2500 1000 + 1000 2500 + 1000 + 1000 2500 2500 2500 @@ -315,6 +314,10 @@ true true + true + 10000 + 10000 + 10 diff --git a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs index 04eacc333..15c9ea5f9 100644 --- a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs +++ b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs @@ -861,6 +861,7 @@ private void OnReadServerStatus( { serverStatusState.Timestamp = now; serverStatusState.CurrentTime.Timestamp = now; + serverStatusState.State.Timestamp = now; } } } diff --git a/Stack/Opc.Ua.Core/Schema/ApplicationConfiguration.cs b/Stack/Opc.Ua.Core/Schema/ApplicationConfiguration.cs index dca79c278..a8b0a6876 100644 --- a/Stack/Opc.Ua.Core/Schema/ApplicationConfiguration.cs +++ b/Stack/Opc.Ua.Core/Schema/ApplicationConfiguration.cs @@ -1341,6 +1341,7 @@ private void Initialize() m_supportedPrivateKeyFormats = new string[] { "PFX", "PEM" }; MaxTrustListSize = 0; MultiCastDnsEnabled = false; + OperationLimits = new OperationLimits(); AuditingEnabled = false; HttpsMutualTls = true; DurableSubscriptionsEnabled = false; From 78a54705ce2e0f084e9815aaaee95d8bb283757b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:09:46 +0100 Subject: [PATCH 20/42] Handle BadRequestTimeout gracefully in Publish error handling (#3460) * Initial plan * Handle BadRequestTimeout gracefully in publish error handling Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- Libraries/Opc.Ua.Client/Session/Session.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Libraries/Opc.Ua.Client/Session/Session.cs b/Libraries/Opc.Ua.Client/Session/Session.cs index 14c8a25b5..08b62df1c 100644 --- a/Libraries/Opc.Ua.Client/Session/Session.cs +++ b/Libraries/Opc.Ua.Client/Session/Session.cs @@ -3626,6 +3626,7 @@ private void OnPublishComplete( }); return; case StatusCodes.BadTimeout: + case StatusCodes.BadRequestTimeout: break; default: m_logger.LogError( From 0713e8fcc43989716a802052037e59f34c37ff27 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:25:53 +0100 Subject: [PATCH 21/42] Suppress expected keepalive errors during test shutdown (#3458) * Initial plan * Suppress BadServerHalted/BadNoCommunication keepalive errors in test logger Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Add null-conditional operators for safer null handling Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- Tests/Opc.Ua.Client.Tests/ClientFixture.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Tests/Opc.Ua.Client.Tests/ClientFixture.cs b/Tests/Opc.Ua.Client.Tests/ClientFixture.cs index d89ac0e92..aab9e7a20 100644 --- a/Tests/Opc.Ua.Client.Tests/ClientFixture.cs +++ b/Tests/Opc.Ua.Client.Tests/ClientFixture.cs @@ -515,10 +515,17 @@ private void Session_KeepAlive(ISession session, KeepAliveEventArgs e) { if (ServiceResult.IsBad(e.Status)) { + // Ignore expected errors during test shutdown to reduce noise in CI logs + if (e.Status?.StatusCode == StatusCodes.BadServerHalted || + e.Status?.StatusCode == StatusCodes.BadNoCommunication) + { + return; + } + m_logger.LogError( "Session '{SessionName}' keep alive error: {StatusCode}", session.SessionName, - e.Status.ToLongString()); + e.Status?.ToLongString()); } } } From bdb8969c06a1bc293d7cc497268a05f99208c9bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:57:14 +0100 Subject: [PATCH 22/42] Bump Microsoft.AspNetCore.Http from 2.3.0 to 2.3.9 (#3462) --- updated-dependencies: - dependency-name: Microsoft.AspNetCore.Http dependency-version: 2.3.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e672dc64c..41dfe0560 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + From 643fe13555bb0ee4e45e275423b75e6da720794a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:47:12 +0100 Subject: [PATCH 23/42] Bump NUnit3TestAdapter from 6.0.1 to 6.1.0 (#3467) --- updated-dependencies: - dependency-name: NUnit3TestAdapter dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 41dfe0560..576aea465 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,7 @@ - + From 21949f8627c3fa9013bf49ea30c1b0bf5d344201 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:47:29 +0100 Subject: [PATCH 24/42] Bump Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets from 2.3.0 to 2.3.9 (#3466) --- updated-dependencies: - dependency-name: Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets dependency-version: 2.3.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 576aea465..91a4c9930 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + From 288f02f20a3e7e97e4329c3d54659bfe339195f0 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 14 Jan 2026 06:12:00 +0100 Subject: [PATCH 25/42] Allow status code variant creation from uint (#3472) * Allow status code variant creation from uint * Initial plan * Add comprehensive tests for StatusCode variant creation from uint Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> * Remove redundant assertions in StatusCode variant tests Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> --- Stack/Opc.Ua.Types/BuiltIn/Variant.cs | 21 +++++ .../Types/BuiltIn/BuiltInTests.cs | 85 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/Stack/Opc.Ua.Types/BuiltIn/Variant.cs b/Stack/Opc.Ua.Types/BuiltIn/Variant.cs index 30fc7ccee..549d6b331 100644 --- a/Stack/Opc.Ua.Types/BuiltIn/Variant.cs +++ b/Stack/Opc.Ua.Types/BuiltIn/Variant.cs @@ -2354,6 +2354,14 @@ private void SetScalar(object value, TypeInfo typeInfo) m_value = ((Variant)value).Value; TypeInfo = TypeInfo.Construct(m_value); return; + case BuiltInType.StatusCode: + if (value is uint code) + { + m_value = new StatusCode(code); + return; + } + m_value = value; + return; // just save the value. case >= BuiltInType.Null and <= BuiltInType.Enumeration: m_value = value; @@ -2408,6 +2416,19 @@ private void SetArray(Array array, TypeInfo typeInfo) return; } + m_value = array; + return; + case BuiltInType.StatusCode: + if (array is uint[] codes) + { + var statusCodes = new StatusCode[codes.Length]; + for (int ii = 0; ii < codes.Length; ii++) + { + statusCodes[ii] = new StatusCode(codes[ii]); + } + m_value = statusCodes; + return; + } m_value = array; return; // convert encodeables to extension objects. diff --git a/Tests/Opc.Ua.Core.Tests/Types/BuiltIn/BuiltInTests.cs b/Tests/Opc.Ua.Core.Tests/Types/BuiltIn/BuiltInTests.cs index 6d3a6f3ff..049711f45 100644 --- a/Tests/Opc.Ua.Core.Tests/Types/BuiltIn/BuiltInTests.cs +++ b/Tests/Opc.Ua.Core.Tests/Types/BuiltIn/BuiltInTests.cs @@ -204,6 +204,91 @@ public void VariantFromEnumArray() // Variant variant6 = new Variant(daysdays); } + /// + /// Initialize Variant from uint with StatusCode TypeInfo. + /// Tests that a Variant created from uint with StatusCode TypeInfo + /// can be properly cast to StatusCode. + /// + [Test] + public void VariantFromUIntWithStatusCodeTypeInfo() + { + // Test scalar StatusCode creation from uint + uint statusCodeValue = StatusCodes.Good; + var variant = new Variant(statusCodeValue, TypeInfo.Scalars.StatusCode); + + Assert.AreEqual(BuiltInType.StatusCode, variant.TypeInfo.BuiltInType); + Assert.NotNull(variant.Value); + + // Cast the Value to StatusCode + StatusCode statusCode = (StatusCode)variant.Value; + Assert.AreEqual(StatusCodes.Good, statusCode.Code); + + // Test with different status code values + uint badNodeIdValue = StatusCodes.BadNodeIdInvalid; + var variant2 = new Variant(badNodeIdValue, TypeInfo.Scalars.StatusCode); + + Assert.AreEqual(BuiltInType.StatusCode, variant2.TypeInfo.BuiltInType); + StatusCode statusCode2 = (StatusCode)variant2.Value; + Assert.AreEqual(StatusCodes.BadNodeIdInvalid, statusCode2.Code); + + // Test with custom status code value + uint customValue = 0x80AB0000; + var variant3 = new Variant(customValue, TypeInfo.Scalars.StatusCode); + + Assert.AreEqual(BuiltInType.StatusCode, variant3.TypeInfo.BuiltInType); + StatusCode statusCode3 = (StatusCode)variant3.Value; + Assert.AreEqual(customValue, statusCode3.Code); + } + + /// + /// Initialize Variant from uint array with StatusCode TypeInfo. + /// Tests that a Variant created from uint[] with StatusCode TypeInfo + /// can be properly cast to StatusCode[]. + /// + [Test] + public void VariantFromUIntArrayWithStatusCodeTypeInfo() + { + // Test array StatusCode creation from uint[] + uint[] statusCodeValues = [ + StatusCodes.Good, + StatusCodes.BadNodeIdInvalid, + StatusCodes.BadUnexpectedError, + StatusCodes.BadInternalError + ]; + + var variant = new Variant(statusCodeValues, TypeInfo.Arrays.StatusCode); + + Assert.AreEqual(BuiltInType.StatusCode, variant.TypeInfo.BuiltInType); + Assert.NotNull(variant.Value); + Assert.IsTrue(variant.Value is StatusCode[]); + + // Cast the Value to StatusCode array + StatusCode[] statusCodes = (StatusCode[])variant.Value; + Assert.AreEqual(statusCodeValues.Length, statusCodes.Length); + + for (int i = 0; i < statusCodeValues.Length; i++) + { + Assert.AreEqual(statusCodeValues[i], statusCodes[i].Code); + } + + // Test empty array + uint[] emptyArray = []; + var variant2 = new Variant(emptyArray, TypeInfo.Arrays.StatusCode); + + Assert.AreEqual(BuiltInType.StatusCode, variant2.TypeInfo.BuiltInType); + StatusCode[] emptyStatusCodes = (StatusCode[])variant2.Value; + Assert.AreEqual(0, emptyStatusCodes.Length); + + // Test single element array + uint[] singleElement = [StatusCodes.BadTimeout]; + var variant3 = new Variant(singleElement, TypeInfo.Arrays.StatusCode); + + Assert.AreEqual(BuiltInType.StatusCode, variant3.TypeInfo.BuiltInType); + StatusCode[] singleStatusCode = (StatusCode[])variant3.Value; + Assert.AreEqual(1, singleStatusCode.Length); + Assert.AreEqual(StatusCodes.BadTimeout, singleStatusCode[0].Code); + } + /// /// Validate ExtensionObject special cases and constructors. /// From 773af55ffd8c93cbb918b74687aba2ae96aff7a0 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 14 Jan 2026 07:35:04 +0100 Subject: [PATCH 26/42] Fix session reconnect handler (#3471) * Fix session reconnect handler * Update Libraries/Opc.Ua.Client/Session/SessionReconnectHandler.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Session/SessionReconnectHandler.cs | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/Libraries/Opc.Ua.Client/Session/SessionReconnectHandler.cs b/Libraries/Opc.Ua.Client/Session/SessionReconnectHandler.cs index fd56489c3..6f9129f80 100644 --- a/Libraries/Opc.Ua.Client/Session/SessionReconnectHandler.cs +++ b/Libraries/Opc.Ua.Client/Session/SessionReconnectHandler.cs @@ -512,23 +512,22 @@ or StatusCodes.BadNoCommunication try { ISession session; - if (transportChannel == null) - { - throw ServiceResultException.Unexpected( - "Transport channel is null for reverse connect session recreation."); - } if (m_reverseConnectManager != null) { ITransportWaitingConnection? connection; do { - EndpointDescription endpointDescription = - current.Endpoint ?? transportChannel.EndpointDescription; - + EndpointDescription? endpointDescription = + current.Endpoint ?? transportChannel?.EndpointDescription; + if (endpointDescription == null) + { + throw ServiceResultException.Unexpected( + "EndpointDescription is null for reverse connect session recreation."); + } connection = await m_reverseConnectManager .WaitForConnectionAsync( new Uri(endpointDescription.EndpointUrl), - endpointDescription.Server.ApplicationUri) + endpointDescription.Server?.ApplicationUri) .ConfigureAwait(false); if (m_updateFromServer) @@ -564,10 +563,13 @@ await endpoint .ConfigureAwait(false); m_updateFromServer = false; } - - session = await current - .SessionFactory.RecreateAsync(current, transportChannel) - .ConfigureAwait(false); + session = transportChannel == null + ? await current + .SessionFactory.RecreateAsync(current) + .ConfigureAwait(false) + : await current + .SessionFactory.RecreateAsync(current, transportChannel) + .ConfigureAwait(false); } // note: the template session is not connected at this point // and must be disposed by the owner From 78a9ba2ffa35bf6e1db3ec664afd8ea0b849fbd0 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 14 Jan 2026 16:21:59 +0100 Subject: [PATCH 27/42] Set correct target framework for long running test (#3474) * Set correct target framework * Split long and short haul tests but maintain most of the current code --- .github/workflows/stability-test.yml | 5 +- .../ConnectionStabilityTest.cs | 170 +++++++++--------- 2 files changed, 84 insertions(+), 91 deletions(-) diff --git a/.github/workflows/stability-test.yml b/.github/workflows/stability-test.yml index 9ade56ae2..2d996ebc9 100644 --- a/.github/workflows/stability-test.yml +++ b/.github/workflows/stability-test.yml @@ -17,12 +17,13 @@ jobs: name: Connection Stability Test runs-on: ubuntu-latest timeout-minutes: 120 # Allow extra time beyond test duration for setup/teardown - + permissions: contents: read env: DOTNET_VERSION: '10.0.x' + TARGET_FRAMEWORK: 'net10.0' CONFIGURATION: 'Release' TEST_DURATION_MINUTES: ${{ github.event.inputs.duration || '90' }} @@ -53,7 +54,7 @@ jobs: dotnet test ./Tests/Opc.Ua.Client.Tests/Opc.Ua.Client.Tests.csproj \ --configuration ${{ env.CONFIGURATION }} \ --no-build \ - --framework ${{ env.DOTNET_VERSION }} + --framework ${{ env.TARGET_FRAMEWORK }} \ --filter "Category=ConnectionStability" \ --logger "console;verbosity=detailed" \ --results-directory ./TestResults diff --git a/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs b/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs index a61ac135a..f159f2a3c 100644 --- a/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs +++ b/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs @@ -43,69 +43,80 @@ namespace Opc.Ua.Client.Tests /// Long-running connection stability test. /// [TestFixture] - [Category("ConnectionStability")] [SetCulture("en-us")] [SetUICulture("en-us")] + [Category("Client")] public class ConnectionStabilityTest : ClientTestFramework { - private const int SecurityTokenLifetimeCIMs = 5 * 60 * 1000; // 5 minutes for CI - private const int SecurityTokenLifetimeLocalMs = 10 * 1000; // 10 seconds for local testing - private const int StatusReportIntervalSeconds = 60; // Report status every 60 seconds - private const double NotificationToleranceRatio = 0.95; // Accept 95% of expected notifications (5% tolerance) - - public ConnectionStabilityTest() - : base(Utils.UriSchemeOpcTcp) - { - SingleSession = false; - } - /// - /// Set up a Server and a Client instance. + /// 5 minutes for CI /// - [OneTimeSetUp] - public override async Task OneTimeSetUpAsync() - { - SupportsExternalServerUrl = true; - - // Check if running in CI environment - bool isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + private const int kSecurityTokenLifetimeCIMs = 5 * 60 * 1000; - // Configure security token lifetime based on environment - // CI: 5 minutes to force 18 renewals in 90 minute test - // Local: 10 seconds to force 6 renewals in 1 minute test - int tokenLifetime = isCI ? SecurityTokenLifetimeCIMs : SecurityTokenLifetimeLocalMs; - - SecurityTokenLifetime = tokenLifetime; + /// + /// 10 seconds for local testing + /// + private const int kSecurityTokenLifetimeLocalMs = 10 * 1000; - await base.OneTimeSetUpAsync().ConfigureAwait(false); - } + /// + /// Report status every 60 seconds + /// + private const int kStatusReportIntervalSeconds = 60; /// - /// Tear down the Server and the Client. + /// Accept 95% of expected notifications (5% tolerance) /// - [OneTimeTearDown] - public override Task OneTimeTearDownAsync() + private const double kNotificationToleranceRatio = 0.95; + + public ConnectionStabilityTest() + : base(Utils.UriSchemeOpcTcp) { - return base.OneTimeTearDownAsync(); + SupportsExternalServerUrl = true; } - /// - /// Test setup. - /// - [SetUp] - public override Task SetUpAsync() + [Test] + [Order(100)] + public async Task ShortHaulStabilityTestAsync() { - return base.SetUpAsync(); + try + { + SecurityTokenLifetime = kSecurityTokenLifetimeLocalMs; + await OneTimeSetUpAsync().ConfigureAwait(false); + + // 2 minutes for local testing + await RunStabilityTestAsync(2).ConfigureAwait(false); + } + finally + { + await OneTimeTearDownAsync().ConfigureAwait(false); + } } - /// - /// Test teardown. - /// - [TearDown] - public override Task TearDownAsync() + [Test] + [Order(100)] + [Explicit] + [Category("ConnectionStability")] + public async Task LongHaulStabilityTestAsync() { - return base.TearDownAsync(); + try + { + SecurityTokenLifetime = kSecurityTokenLifetimeCIMs; + await OneTimeSetUpAsync().ConfigureAwait(false); + + // Configurable duration for CI testing + string envValue = Environment.GetEnvironmentVariable("TEST_DURATION_MINUTES"); + if (string.IsNullOrEmpty(envValue) || + !int.TryParse(envValue, out int minutes) || + minutes <= 0) + { + minutes = 90; // Default to 90 minutes for CI + } + await RunStabilityTestAsync(minutes).ConfigureAwait(false); + } + finally + { + await OneTimeTearDownAsync().ConfigureAwait(false); + } } /// @@ -113,24 +124,18 @@ public override Task TearDownAsync() /// Tests that: /// - Connection remains stable over extended period /// - Subscriptions deliver all expected values (no message loss) - /// - Security token renewals happen correctly (every 5 minutes in CI, every 10 seconds locally) - /// Duration can be configured via TEST_DURATION_MINUTES environment variable (default: 90 minutes CI, 1 minute local) + /// - Security token renewals happen correctly /// - [Test] - [Order(100)] - public async Task LongRunningStabilityTestAsync() + private async Task RunStabilityTestAsync(int testDurationMinutes) { // Get test duration from environment variable or use default - int testDurationMinutes = GetTestDurationMinutes(); int testDurationSeconds = testDurationMinutes * 60; + int tokenLifetimeMs = SecurityTokenLifetime; - // Determine token lifetime based on environment - bool isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); - int tokenLifetimeMs = isCI ? SecurityTokenLifetimeCIMs : SecurityTokenLifetimeLocalMs; - - TestContext.Out.WriteLine($"Starting connection stability test for {testDurationMinutes} minutes ({testDurationSeconds} seconds)"); - TestContext.Out.WriteLine($"Security token lifetime: {tokenLifetimeMs / 1000} seconds ({tokenLifetimeMs / 60000.0:F1} minutes)"); + TestContext.Out.WriteLine( + $"Starting connection stability test for {testDurationMinutes} minutes ({testDurationSeconds} seconds)"); + TestContext.Out.WriteLine( + $"Security token lifetime: {tokenLifetimeMs / 1000} seconds ({tokenLifetimeMs / 60000.0:F1} minutes)"); const int publishingInterval = 1000; // 1 second const int writerInterval = 2000; // 2 seconds @@ -155,7 +160,9 @@ public async Task LongRunningStabilityTestAsync() TestContext.Out.WriteLine($"Subscribing to {nodeIds.Count} nodes."); // Create session - session = await ClientFixture.ConnectAsync(ServerUrl, SecurityPolicies.Basic256Sha256).ConfigureAwait(false); + session = await ClientFixture.ConnectAsync( + ServerUrl, + SecurityPolicies.Basic256Sha256).ConfigureAwait(false); Assert.NotNull(session, "Failed to create session"); // Create subscription @@ -221,7 +228,9 @@ public async Task LongRunningStabilityTestAsync() TestContext.Out.WriteLine($"Subscription created with {subscription.MonitoredItemCount} monitored items"); // Create writer session - ISession writerSession = await ClientFixture.ConnectAsync(ServerUrl, SecurityPolicies.Basic256Sha256).ConfigureAwait(false); + ISession writerSession = await ClientFixture.ConnectAsync( + ServerUrl, + SecurityPolicies.Basic256Sha256).ConfigureAwait(false); Assert.NotNull(writerSession, "Failed to create writer session"); // Writer task - continuously write values @@ -282,7 +291,9 @@ public async Task LongRunningStabilityTestAsync() { try { - await Task.Delay(TimeSpan.FromSeconds(StatusReportIntervalSeconds), statusReportingCts.Token).ConfigureAwait(false); + await Task.Delay( + TimeSpan.FromSeconds(kStatusReportIntervalSeconds), + statusReportingCts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -291,7 +302,7 @@ public async Task LongRunningStabilityTestAsync() reportCount++; int totalNotifications = valueChanges.Values.Sum(); - int elapsedMinutes = reportCount * StatusReportIntervalSeconds / 60; + int elapsedMinutes = reportCount * kStatusReportIntervalSeconds / 60; TestContext.Out.WriteLine( $"[Status Report {reportCount}] Elapsed: {elapsedMinutes} minutes, " + @@ -302,7 +313,7 @@ public async Task LongRunningStabilityTestAsync() if (reportCount % 5 == 0) // Every 5 minutes { TestContext.Out.WriteLine("Per-node notification counts:"); - foreach (var kvp in valueChanges.OrderBy(x => x.Key.ToString())) + foreach (KeyValuePair kvp in valueChanges.OrderBy(x => x.Key.ToString())) { TestContext.Out.WriteLine($" {kvp.Key}: {kvp.Value} notifications"); } @@ -346,7 +357,7 @@ public async Task LongRunningStabilityTestAsync() TestContext.Out.WriteLine("=== Final Results ==="); TestContext.Out.WriteLine($"Test duration: {testDurationMinutes} minutes"); TestContext.Out.WriteLine($"Security token lifetime: {tokenLifetimeMs / 1000} seconds ({tokenLifetimeMs / 60000.0:F1} minutes)"); - TestContext.Out.WriteLine($"Expected token renewals: ~{(testDurationMinutes * 60000) / tokenLifetimeMs} times"); + TestContext.Out.WriteLine($"Expected token renewals: ~{testDurationMinutes * 60000 / tokenLifetimeMs} times"); TestContext.Out.WriteLine($"Total write operations: {writeCount}"); TestContext.Out.WriteLine($"Total errors: {errors.Count}"); @@ -367,10 +378,10 @@ public async Task LongRunningStabilityTestAsync() #if DEBUG TestContext.Out.WriteLine($" {nodeId}: {changes} notifications"); #endif - if (changes < (writeCount * NotificationToleranceRatio)) + if (changes < (writeCount * kNotificationToleranceRatio)) { allNodesReceivedData = false; - TestContext.Out.WriteLine($" WARNING: Expected at least {writeCount * NotificationToleranceRatio:F0} notifications"); + TestContext.Out.WriteLine($" WARNING: Expected at least {writeCount * kNotificationToleranceRatio:F0} notifications"); } } else @@ -408,7 +419,10 @@ public async Task LongRunningStabilityTestAsync() // Assertions Assert.IsTrue(allNodesReceivedData, "Not all nodes received expected data"); Assert.AreEqual(0, errors.Count, $"Test encountered {errors.Count} errors"); - Assert.GreaterOrEqual(totalNotifications, expectedMinNotifications, "Total notifications received is less than expected minimum"); + Assert.GreaterOrEqual( + totalNotifications, + expectedMinNotifications, + "Total notifications received is less than expected minimum"); TestContext.Out.WriteLine("Connection stability test PASSED"); } @@ -441,27 +455,5 @@ public async Task LongRunningStabilityTestAsync() } } } - - /// - /// Gets the test duration in minutes from environment variable or returns default. - /// - private int GetTestDurationMinutes() - { - string envValue = Environment.GetEnvironmentVariable("TEST_DURATION_MINUTES"); - - if (!string.IsNullOrEmpty(envValue) && int.TryParse(envValue, out int minutes) && minutes > 0) - { - return minutes; - } - - // Default to 90 minutes for nightly runs, but use 1 minute for manual/local testing - // CI: 90 minutes with 5-minute token lifetime = 18 renewals - // Local: 1 minute with 10-second token lifetime = 6 renewals - // Check if running in CI environment - bool isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); - - return isCI ? 90 : 1; // 90 minutes for CI (18 renewals), 1 minute for local (6 renewals) - } } } From 0497953af223783c6fdbdc3b25c4ad21d21e9561 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Thu, 15 Jan 2026 18:16:21 +0100 Subject: [PATCH 28/42] Change license reference in copilot agents file (#3479) --- .github/copilot-instructions.md | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 591cfe681..b65985ae0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,7 +5,8 @@ This is the official OPC UA .NET Standard Stack from the OPC Foundation. It provides a reference implementation of OPC UA (Open Platform Communications Unified Architecture) targeting .NET Standard, allowing development of applications that run on multiple platforms including Windows, Linux, iOS, and Android. ### Key Technologies -- **Language**: C# with LangVersion 13.0 +- **SDK**: .net 10 SDK +- **Language**: C# with LangVersion 14.0 - **Target Frameworks**: .NET Standard 2.0/2.1, .NET Framework 4.8, .NET 8.0 (LTS), .NET 9.0, .NET 10.0 (LTS) - **Project Type**: Class libraries, console applications, and reference implementations - **Architecture**: OPC UA Stack with Client, Server, Configuration, Complex Types, GDS, and PubSub components @@ -13,15 +14,14 @@ This is the official OPC UA .NET Standard Stack from the OPC Foundation. It prov ## Code Style and Standards ### General Guidelines -- Follow the `.editorconfig` settings strictly +- Follow the `.editorconfig` settings *strictly*. Fix all warnings, errors and informational messages before proposing a fix. - Use 4 spaces for indentation in C# files - Maximum line length: 120 characters - End-of-line: CRLF - Always insert final newline - Trim trailing whitespace - Use UTF-8 encoding -- Defined in .editorconfig -- Add the OPC Foundation MIT license header to all source files. Exception: All files in Opc.Ua.Core project which have a dual license header. +- Add the OPC Foundation MIT license header to all source files. ### C# Conventions - **Braces**: Use Allman style (braces on new line for control blocks, types, and methods) @@ -55,19 +55,22 @@ This is the official OPC UA .NET Standard Stack from the OPC Foundation. It prov ## Testing Standards ### Test Requirements -- All new features must include unit tests +- All new features must include unit tests. Tests should be simple and cover positive and negative scenarios. +- Maintain or improve code coverage; critical components should have at least 80% coverage. - Use NUnit framework (match existing test projects) - Test projects follow naming convention: `.Tests` - Tests must be deterministic and pass in CI/CD environment -- Code coverage is monitored via Coverlet -- Run tests with `dotnet test` from solution root -- Use NUnit asserts methods, no other library or classic Nunit asserts. +- Code coverage is monitored via Coverlet and MUST NOT decrease +- Integration tests should be included for critical components +- Run all tests with `dotnet test` from solution root on UA.slnx +- Use NUnit asserts methods (Assert.That), no other library. DO NOT USE the classic Nunit asserts (E.g. Assert.AreEquals). +- When updating tests fix above for the test only. ### Test Organization -- Place tests in the `Tests/` directory +- Place tests in a project that corresponds to the component package under the `Tests/` directory. - Mirror the structure of the code being tested - Use descriptive test method names that explain what is being tested -- Do not use _ in test method names; use PascalCase +- DO NOT use _ in test method names; use PascalCase ## Build and Development @@ -126,7 +129,7 @@ This is the official OPC UA .NET Standard Stack from the OPC Foundation. It prov ### Package Guidelines - Use centralized package management (Directory.Packages.props) - Audit packages for security vulnerabilities (NuGetAudit is enabled) -- Only add necessary dependencies +- Only add necessary dependencies and ask for approval for new dependencies - Prefer stable, well-maintained packages - Check compatibility with all target frameworks @@ -161,7 +164,7 @@ This is the official OPC UA .NET Standard Stack from the OPC Foundation. It prov 5. Consider backward compatibility ### Certificate Management -- Never commit certificates to the repository +- NEVER commit certificates or secrets of any kind to the repository - Use the certificate store APIs - Follow guidelines in `Docs/Certificates.md` - Test with different certificate configurations @@ -178,7 +181,7 @@ This is the official OPC UA .NET Standard Stack from the OPC Foundation. It prov - OPC UA Specification: https://reference.opcfoundation.org/ - Documentation: See `Docs/` directory - Samples Repository: https://github.com/OPCFoundation/UA-.NETStandard-Samples -- NuGet Packages: +- NuGet Packages: - Types: https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Types/ - Core: https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Core/ - Client: https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.Client/ From e314cf53f7ed9b15b94ef1c82b1b2df97c243a23 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:18:28 +0100 Subject: [PATCH 29/42] Fix SourceTimestamp for variables without explicit timestamps in BaseVariableState (#3451) * Initial plan * Add OnReadValue callback to update timestamps on variable reads Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Add test to verify timestamp updates on variable reads Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * fix timestamp for scalar values set loglevel in session publish when no subscriptions ignore more keepalive errors in Client Fixture * Fix timestamp updates for all variables (scalars and arrays) Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * create a proper fix * Add Order parameter to test to check the timestamp before an explicit write was done --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> Co-authored-by: Roman Ettlinger --- .../ReferenceServer/ReferenceNodeManager.cs | 6 - Libraries/Opc.Ua.Client/Session/Session.cs | 6 +- Stack/Opc.Ua.Types/State/BaseVariableState.cs | 7 +- Tests/Opc.Ua.Client.Tests/ClientFixture.cs | 4 +- .../ReferenceServerTest.cs | 151 +++++++++++++++++- 5 files changed, 163 insertions(+), 11 deletions(-) diff --git a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs index 32b5d09e9..2c3610c39 100644 --- a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs +++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs @@ -4044,7 +4044,6 @@ private DataItemState CreateDataItemVariable( variable.Historizing = false; variable.Value = TypeInfo.GetDefaultValue((uint)dataType, valueRank, Server.TypeTree); variable.StatusCode = StatusCodes.Good; - variable.Timestamp = DateTime.UtcNow; if (valueRank == ValueRanks.OneDimension) { @@ -4176,7 +4175,6 @@ private AnalogItemState CreateAnalogItemVariable( TypeInfo.GetDefaultValue(dataType, valueRank, Server.TypeTree); variable.StatusCode = StatusCodes.Good; - variable.Timestamp = DateTime.UtcNow; // The latest UNECE version (Rev 11, published in 2015) is available here: // http://www.opcfoundation.org/UA/EngineeringUnits/UNECE/rec20_latest_08052015.zip variable.EngineeringUnits.Value = new EUInformation( @@ -4233,7 +4231,6 @@ private TwoStateDiscreteState CreateTwoStateDiscreteItemVariable( variable.Historizing = false; variable.Value = (bool)GetNewValue(variable); variable.StatusCode = StatusCodes.Good; - variable.Timestamp = DateTime.UtcNow; variable.TrueState.Value = trueState; variable.TrueState.AccessLevel = AccessLevels.CurrentReadOrWrite; @@ -4277,7 +4274,6 @@ private MultiStateDiscreteState CreateMultiStateDiscreteItemVariable( variable.Historizing = false; variable.Value = (uint)0; variable.StatusCode = StatusCodes.Good; - variable.Timestamp = DateTime.UtcNow; variable.OnWriteValue = OnWriteDiscrete; var strings = new LocalizedText[values.Length]; @@ -4338,7 +4334,6 @@ private MultiStateValueDiscreteState CreateMultiStateValueDiscreteItemVariable( variable.Historizing = false; variable.Value = (uint)0; variable.StatusCode = StatusCodes.Good; - variable.Timestamp = DateTime.UtcNow; variable.OnWriteValue = OnWriteValueDiscrete; // there are two enumerations for this type: @@ -4600,7 +4595,6 @@ private BaseDataVariableState CreateVariable( }; variable.Value = GetNewValue(variable); variable.StatusCode = StatusCodes.Good; - variable.Timestamp = DateTime.UtcNow; if (valueRank == ValueRanks.OneDimension) { diff --git a/Libraries/Opc.Ua.Client/Session/Session.cs b/Libraries/Opc.Ua.Client/Session/Session.cs index 08b62df1c..06c3c5ceb 100644 --- a/Libraries/Opc.Ua.Client/Session/Session.cs +++ b/Libraries/Opc.Ua.Client/Session/Session.cs @@ -3064,6 +3064,10 @@ private async Task OnSendKeepAliveAsync( { // This should not happen, but we fail gracefully anyway } + catch (TaskCanceledException) + { + //expected exception type + } catch (Exception e) { m_logger.LogError( @@ -3512,7 +3516,7 @@ private void OnPublishComplete( if (m_subscriptions.Count == 0) { // Publish responses with error should occur after deleting the last subscription. - m_logger.LogError( + m_logger.LogWarning( "Publish #{RequestHandle}, Subscription count = 0, Error: {Message}", requestHeader.RequestHandle, e.Message); diff --git a/Stack/Opc.Ua.Types/State/BaseVariableState.cs b/Stack/Opc.Ua.Types/State/BaseVariableState.cs index 227cb9aa1..81d1151bb 100644 --- a/Stack/Opc.Ua.Types/State/BaseVariableState.cs +++ b/Stack/Opc.Ua.Types/State/BaseVariableState.cs @@ -1544,11 +1544,14 @@ protected override ServiceResult ReadValueAttribute( // ensure a value timestamp exists. if (m_timestamp == DateTime.MinValue) { - m_timestamp = DateTime.UtcNow; + sourceTimestamp = DateTime.UtcNow; + } + else + { + sourceTimestamp = m_timestamp; } value = m_value; - sourceTimestamp = m_timestamp; StatusCode statusCode = m_statusCode; ServiceResult result = null; diff --git a/Tests/Opc.Ua.Client.Tests/ClientFixture.cs b/Tests/Opc.Ua.Client.Tests/ClientFixture.cs index aab9e7a20..b772e2271 100644 --- a/Tests/Opc.Ua.Client.Tests/ClientFixture.cs +++ b/Tests/Opc.Ua.Client.Tests/ClientFixture.cs @@ -517,7 +517,9 @@ private void Session_KeepAlive(ISession session, KeepAliveEventArgs e) { // Ignore expected errors during test shutdown to reduce noise in CI logs if (e.Status?.StatusCode == StatusCodes.BadServerHalted || - e.Status?.StatusCode == StatusCodes.BadNoCommunication) + e.Status?.StatusCode == StatusCodes.BadNoCommunication|| + e.Status?.StatusCode == StatusCodes.BadSecureChannelClosed || + e.Status?.StatusCode == StatusCodes.BadRequestInterrupted) { return; } diff --git a/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs b/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs index 9d7cf5a36..aa43b7821 100644 --- a/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs +++ b/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs @@ -394,6 +394,154 @@ public async Task WriteAsync() logger); } + /// + /// Test that ReferenceNodeManager variables update their SourceTimestamp on read. + /// + [Test] + [Order(340)] + public async Task ReferenceNodeManagerVariablesUpdateTimestampOnReadAsync() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + ILogger logger = telemetry.CreateLogger(); + + // Read a variable from the ReferenceNodeManager (namespace index 2) + var nodeId = new NodeId("Scalar_Static_Byte", 2); + var nodesToRead = new ReadValueIdCollection + { + new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value } + }; + + // First read + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTime.UtcNow; + DateTime timeBeforeFirstRead = DateTime.UtcNow; + ReadResponse firstReadResponse = await m_server.ReadAsync( + m_secureChannelContext, + requestHeader, + kMaxAge, + TimestampsToReturn.Both, + nodesToRead, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(firstReadResponse); + Assert.IsNotNull(firstReadResponse.Results); + Assert.AreEqual(1, firstReadResponse.Results.Count); + DataValue firstValue = firstReadResponse.Results[0]; + Assert.AreEqual(StatusCodes.Good, firstValue.StatusCode); + Assert.IsNotNull(firstValue.SourceTimestamp); + logger.LogInformation("First read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}", + firstValue.SourceTimestamp, firstValue.ServerTimestamp); + + // Verify the timestamp is recent (not startup time) + Assert.GreaterOrEqual(firstValue.SourceTimestamp, timeBeforeFirstRead.AddSeconds(-1), + "SourceTimestamp should be close to the read time, not the server startup time"); + + // Wait a bit to ensure time difference + await Task.Delay(1500).ConfigureAwait(false); + + // Second read + requestHeader.Timestamp = DateTime.UtcNow; + DateTime timeBeforeSecondRead = DateTime.UtcNow; + ReadResponse secondReadResponse = await m_server.ReadAsync( + m_secureChannelContext, + requestHeader, + kMaxAge, + TimestampsToReturn.Both, + nodesToRead, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(secondReadResponse); + Assert.IsNotNull(secondReadResponse.Results); + Assert.AreEqual(1, secondReadResponse.Results.Count); + DataValue secondValue = secondReadResponse.Results[0]; + Assert.AreEqual(StatusCodes.Good, secondValue.StatusCode); + Assert.IsNotNull(secondValue.SourceTimestamp); + logger.LogInformation("Second read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}", + secondValue.SourceTimestamp, secondValue.ServerTimestamp); + + // Verify the second timestamp is more recent than the first + Assert.Greater(secondValue.SourceTimestamp, firstValue.SourceTimestamp, + "SourceTimestamp should be updated on each read"); + + // Verify the second timestamp is recent + Assert.GreaterOrEqual(secondValue.SourceTimestamp, timeBeforeSecondRead.AddSeconds(-1), + "SourceTimestamp should be close to the second read time"); + } + + /// + /// Test that ReferenceNodeManager array variables update their SourceTimestamp on read. + /// + [Test] + [NonParallelizable] + public async Task ReferenceNodeManagerArrayVariablesUpdateTimestampOnReadAsync() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + ILogger logger = telemetry.CreateLogger(); + + // Read an array variable from the ReferenceNodeManager (namespace index 2) + var nodeId = new NodeId("Scalar_Static_Arrays_Byte", 2); + var nodesToRead = new ReadValueIdCollection + { + new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value } + }; + + // First read + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTime.UtcNow; + DateTime timeBeforeFirstRead = DateTime.UtcNow; + ReadResponse firstReadResponse = await m_server.ReadAsync( + m_secureChannelContext, + requestHeader, + kMaxAge, + TimestampsToReturn.Both, + nodesToRead, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(firstReadResponse); + Assert.IsNotNull(firstReadResponse.Results); + Assert.AreEqual(1, firstReadResponse.Results.Count); + DataValue firstValue = firstReadResponse.Results[0]; + Assert.AreEqual(StatusCodes.Good, firstValue.StatusCode); + Assert.IsNotNull(firstValue.SourceTimestamp); + logger.LogInformation("Array First read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}", + firstValue.SourceTimestamp, firstValue.ServerTimestamp); + + // Verify the timestamp is recent (not startup time) + Assert.GreaterOrEqual(firstValue.SourceTimestamp, timeBeforeFirstRead.AddSeconds(-1), + "Array SourceTimestamp should be close to the read time, not the server startup time"); + + // Wait a bit to ensure time difference + await Task.Delay(1500).ConfigureAwait(false); + + // Second read + requestHeader.Timestamp = DateTime.UtcNow; + DateTime timeBeforeSecondRead = DateTime.UtcNow; + ReadResponse secondReadResponse = await m_server.ReadAsync( + m_secureChannelContext, + requestHeader, + kMaxAge, + TimestampsToReturn.Both, + nodesToRead, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(secondReadResponse); + Assert.IsNotNull(secondReadResponse.Results); + Assert.AreEqual(1, secondReadResponse.Results.Count); + DataValue secondValue = secondReadResponse.Results[0]; + Assert.AreEqual(StatusCodes.Good, secondValue.StatusCode); + Assert.IsNotNull(secondValue.SourceTimestamp); + logger.LogInformation("Array Second read - SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}", + secondValue.SourceTimestamp, secondValue.ServerTimestamp); + + // Verify the second timestamp is more recent than the first + Assert.Greater(secondValue.SourceTimestamp, firstValue.SourceTimestamp, + "Array SourceTimestamp should be updated on each read"); + + // Verify the second timestamp is recent + Assert.GreaterOrEqual(secondValue.SourceTimestamp, timeBeforeSecondRead.AddSeconds(-1), + "Array SourceTimestamp should be close to the second read time"); + } + /// /// Update static Nodes, read modify write. /// @@ -746,7 +894,8 @@ public async Task ResendDataAsync(bool updateValues, uint queueSize) MonitoredItemNotificationCollection monitoredItemsCollection = ( (DataChangeNotification)items.Body ).MonitoredItems; - Assert.AreEqual(testSet.Length, monitoredItemsCollection.Count); + Assert.AreEqual(testSet.Length, monitoredItemsCollection.Count, + "One MonitoredItemNotification should be returned for each Node present in the TestSet"); Thread.Sleep(1000); From 3561c687849b4d448292bfd63aeb8b5c285d5266 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:44:11 +0100 Subject: [PATCH 30/42] Document certificate validation workflow with chain building and configuration details (#3481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Document certificate validation workflow in Certificates.md - Add comprehensive Certificate Validation section - Document validation workflow and steps - List all configuration settings with defaults and examples - Enumerate suppressible validation errors - Provide examples for registering validation callbacks - Show how to implement custom certificate validators - Include best practices for certificate validation Addresses the issue by documenting the CertificateValidator.cs implementation including config settings and suppressible errors. Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Fix trailing whitespace in Certificates.md per .editorconfig Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Add detailed chain building technical documentation - Add comprehensive Chain Building Process section with diagram - Include ASCII flowchart showing complete validation flow - Add pseudocode for all chain building steps: - Initialization and loop structure - Three-tier issuer search (Trusted → Issuer → Untrusted) - Issuer matching algorithm with criteria - CRL checking logic with revocation handling - X509Chain integration and validation - Document search priority and trust anchoring behavior - Add key behaviors section explaining design decisions - Link from validation workflow to detailed section Addresses feedback requesting more technical detail, diagram, and pseudocode visualization of the chain building process. Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Add certificate list configuration documentation - Add Certificate List Configuration section (~190 lines) - Document configuration sources (XML config and runtime API) - Provide XML configuration structure with examples - Explain certificate store types (Directory and X509Store) - Detail certificate list population process: - Initialization via ApplicationConfiguration - Internal update process - Certificate search behavior and priority - Add runtime certificate management examples - Document certificate store management (file operations) - Explain dual-mode operation (explicit list + store) - Include configuration best practices - Link from Trust Check step to configuration section Addresses feedback requesting details on where certificate lists are configured and populated. Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- Docs/Certificates.md | 774 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 766 insertions(+), 8 deletions(-) diff --git a/Docs/Certificates.md b/Docs/Certificates.md index 23c38a502..4ba33a9a2 100644 --- a/Docs/Certificates.md +++ b/Docs/Certificates.md @@ -6,19 +6,19 @@ The UA stack allows also for using CA issued application certificates and remote ### Certificate stores -The layout of the certificate stores for sample applications which store the certificates in the file system follow the recommended layout in the [specification](https://reference.opcfoundation.org/v104/GDS/docs/F.1/), where certificates are stored in a `certs` folder, private keys under a `private` folder and revocation lists under a `crl` folder with a `` folder called `pki`. +The layout of the certificate stores for sample applications which store the certificates in the file system follow the recommended layout in the [specification](https://reference.opcfoundation.org/v104/GDS/docs/F.1/), where certificates are stored in a `certs` folder, private keys under a `private` folder and revocation lists under a `crl` folder with a `` folder called `pki`. The UA .NET Standard stack supports the following certificate stores: - The **Application** store `/own`which contains private keys used by the application. -- The **Issuer** store `/issuer`which contains certificates which are needed for validation, for example to complete the validation of a certificate chain. A certificate in the *Issuer* store is *not* trusted! +- The **Issuer** store `/issuer`which contains certificates which are needed for validation, for example to complete the validation of a certificate chain. A certificate in the *Issuer* store is *not* trusted! -- The **Trusted** store `/trusted`which contains certificates which are trusted by the application. The certificates in this store can either be self signed, leaf, root CA or sub CA certificates. - The most common use case is to add a self signed application certificate to the *Trusted* store to establish trust with that application. +- The **Trusted** store `/trusted`which contains certificates which are trusted by the application. The certificates in this store can either be self signed, leaf, root CA or sub CA certificates. + The most common use case is to add a self signed application certificate to the *Trusted* store to establish trust with that application. If the application certificate is the leaf of a chain, the trust can be established by adding the root CA, a sub CA or the leaf certificate itself to the *Trusted* store. Each of the options enables a different set of trusted certificates. A trusted Root CA or Sub CA certificate is used as the trust anchor for the certificate chain, which means any leaf certificate with a chain which contains the Root CA and Sub CA certificate is trusted, but the specification still mandates the validation of the whole chain. For the chain validation any certificate in the chain except the leaf certificate must be available from the *Issuer* store. - If only the leaf certificate is in the *Trusted* store and the rest of the chain is stored in the *Issuer* store, then only the leaf certificate is trusted. + If only the leaf certificate is in the *Trusted* store and the rest of the chain is stored in the *Issuer* store, then only the leaf certificate is trusted. As an example, to trust an application certificate that is issued by a Root CA, only the Root CA certificate is required in the *Trusted* store to establish trust to all application certificates issued by the CA. This option can greatly simplify the management of OPC UA Clients and Servers because only one certificate needs to be distributed across all systems. - The **Rejected** store `/rejected` which contains certificates which have been rejected. This store is provided as a convenience for the administrator of an application to allow to copy an untrusted certificate from the *Rejected* to the *Trusted* store to establish trust with that application. @@ -36,14 +36,772 @@ Starting with Version 1.5.xx of the UA .NET Standard Stack the X509Store support This enables the usage of the X509Store instead of the Directory Store for stores requiring the use of crls, e.g. the issuer or the directory Store. ### Windows .NET applications -By default the self signed certificates are stored in a **X509Store** called **CurrentUser\\UA_MachineDefault**. The certificates can be viewed or deleted with the Windows Certificate Management Console (certmgr.msc). The *trusted*, *issuer* and *rejected* stores remain in a folder called **OPC Foundation\pki** with a root folder which is specified by the `SpecialFolder` variable **%CommonApplicationData%**. On Windows 7/8/8.1/10 this is usually the invisible folder **C:\ProgramData**. +By default the self signed certificates are stored in a **X509Store** called **CurrentUser\\UA_MachineDefault**. The certificates can be viewed or deleted with the Windows Certificate Management Console (certmgr.msc). The *trusted*, *issuer* and *rejected* stores remain in a folder called **OPC Foundation\pki** with a root folder which is specified by the `SpecialFolder` variable **%CommonApplicationData%**. On Windows 7/8/8.1/10 this is usually the invisible folder **C:\ProgramData**. ### Windows UWP applications -By default the self signed certificates are stored in a **X509Store** called **CurrentUser\\UA_MachineDefault**. The certificates can be viewed or deleted with the Windows Certificate Management Console (certmgr.msc). +By default the self signed certificates are stored in a **X509Store** called **CurrentUser\\UA_MachineDefault**. The certificates can be viewed or deleted with the Windows Certificate Management Console (certmgr.msc). The *trusted*, *issuer* and *rejected* stores remain in a folder called **OPC Foundation\pki** in the **LocalState** folder of the installed universal windows package. Deleting the application state also deletes the certificate stores. ### .NET Core applications on Windows, Linux, iOS etc. The self signed certificates are stored in a folder called **OPC Foundation/pki/own** with a root folder which is specified by the `SpecialFolder` variable **%LocalApplicationData%** or in a **X509Store** called **CurrentUser\\My**, depending on the configuration. For best cross platform support the personal store **CurrentUser\\My** was chosen to support all platforms with the same configuration. Some platforms, like macOS, do not support arbitrary certificate stores. -The *trusted*, *issuer* and *rejected* stores remain in a shared folder called **OPC Foundation\pki** with a root folder specified by the `SpecialFolder` variable **%LocalApplicationData%**. Depending on the target platform, this folder maps to a hidden locations under the user home directory. \ No newline at end of file +The *trusted*, *issuer* and *rejected* stores remain in a shared folder called **OPC Foundation\pki** with a root folder specified by the `SpecialFolder` variable **%LocalApplicationData%**. Depending on the target platform, this folder maps to a hidden locations under the user home directory. + +## Certificate Validation + +The OPC UA .NET Standard Stack uses the `CertificateValidator` class to validate certificates according to the OPC UA specification. This section describes the certificate validation workflow, configuration settings, and how to customize the validation process. + +### Validation Workflow + +The certificate validation process follows these steps: + +1. **Pre-validation Check**: If the certificate was previously validated and `UseValidatedCertificates` is enabled, the validation is skipped. + +2. **Trust Check**: The validator checks if the certificate is explicitly trusted by searching in: + - The **trusted certificate list** (`TrustedPeerCertificates.TrustedCertificates`) - An in-memory collection of explicitly trusted certificates + - The **trusted certificate store** (`TrustedPeerCertificates.StorePath`) - A file system directory or X509Store containing trusted certificates + - The **application's own certificate collection** (`ApplicationCertificates`) - The certificates used by the application itself + + See [Certificate List Configuration](#certificate-list-configuration) for details on how these lists are populated. + +3. **Issuer Chain Validation**: For certificates issued by a CA, the validator builds and validates the complete certificate chain. See [Chain Building Process](#chain-building-process) for detailed technical documentation. + +4. **Certificate Properties Validation**: The validator checks: + - Certificate expiration dates (NotBefore/NotAfter) + - Key usage flags (DigitalSignature for ECDSA, DataEncipherment for RSA) + - Minimum key size requirements + - Signature algorithm strength (e.g., rejecting SHA-1 if configured) + - Certificate signature validity + +5. **Domain Validation**: If an endpoint is provided, the validator checks that the certificate contains the endpoint's domain name in its Subject Alternative Names. + +6. **Application URI Validation**: Verifies that the certificate contains the expected Application URI in the Subject Alternative Name extension. + +7. **Error Handling**: If validation errors occur, they are classified as either: + - **Suppressible errors**: Can be accepted via the `CertificateValidation` event callback + - **Non-suppressible errors**: Always cause validation to fail + +8. **Rejected Certificate Storage**: Failed certificates are saved to the rejected certificate store for administrator review. + +### Chain Building Process + +This section provides detailed technical documentation of the certificate chain building and validation algorithm implemented in `CertificateValidator.GetIssuersNoExceptionsOnGetIssuerAsync()`. + +#### Overview + +The chain building process constructs a complete certificate chain from a leaf certificate up to a self-signed root CA certificate. The algorithm searches through multiple certificate stores in a specific priority order and performs revocation checking at each step. + +#### Chain Building Algorithm Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Start Chain Building │ +│ Input: Certificate Chain │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────┐ + │ Current = certificates[0] │ + │ (Leaf Certificate) │ + └────────────┬───────────────┘ + │ + ▼ + ┌────────────────────────────┐ + │ Create untrusted list │ + │ from certificates[1..n] │ + └────────────┬───────────────┘ + │ + ┌────────────▼───────────────┐ + │ Loop: Find Issuer Chain │ + └────────────┬───────────────┘ + │ + ┌────────────▼───────────────────────────┐ + │ Is Current certificate self-signed? │ + └─┬─────────────────────────────────┬────┘ + │ YES │ NO + │ │ + ▼ ▼ + ┌────────────────┐ ┌─────────────────────────────┐ + │ Chain Complete │ │ Search for Issuer (Step 1) │ + │ Return Result │ │ Location: Trusted Store │ + └────────────────┘ └──────────┬──────────────────┘ + │ + ┌───────────▼──────────────┐ + │ Issuer Found in Trusted? │ + └─┬─────────────────────┬──┘ + │ YES │ NO + │ │ + ▼ ▼ + ┌────────────────────┐ ┌──────────────────────────┐ + │ Mark as Trusted │ │ Search for Issuer (Step 2)│ + │ Check Revocation │ │ Location: Issuer Store │ + └──────┬─────────────┘ └────────┬─────────────────┘ + │ │ + │ ┌───────────▼──────────────┐ + │ │ Issuer Found in Issuer? │ + │ └─┬─────────────────────┬──┘ + │ │ YES │ NO + │ │ │ + │ ▼ ▼ + │ ┌──────────────────┐ ┌──────────────────────────┐ + │ │ Check Revocation │ │ Search for Issuer (Step 3)│ + │ └────────┬─────────┘ │ Location: Untrusted Certs │ + │ │ └────────┬─────────────────┘ + │ │ │ + │ │ ┌───────────▼──────────────┐ + │ │ │ Issuer Found in Chain? │ + │ │ └─┬─────────────────────┬──┘ + │ │ │ YES │ NO + │ │ │ │ + │ │ ▼ ▼ + │ │ ┌──────────────────┐ ┌──────────────┐ + │ │ │ Check Revocation │ │ Chain Broken │ + │ │ └────────┬─────────┘ │ Return Result│ + │ │ │ └──────────────┘ + │ │ │ + └─────────────┴───────────┘ + │ + ┌─────────────▼──────────────┐ + │ Check for Circular Chain │ + │ (Duplicate Thumbprint) │ + └─┬────────────────────────┬─┘ + │ DUPLICATE │ UNIQUE + │ │ + ▼ ▼ + ┌────────────────┐ ┌──────────────────────┐ + │ Chain Complete │ │ Add Issuer to List │ + │ Return Result │ │ Current = Issuer Cert│ + └────────────────┘ └──────────┬───────────┘ + │ + │ + ┌───────────────────┘ + │ Loop Back to Top + └──────────────────────┐ + │ + ▼ + (Continue Building Chain) +``` + +#### Detailed Algorithm Steps + +##### Step 1: Initialization + +``` +INPUT: + - certificates: X509Certificate2Collection (certificate chain from peer) + - issuers: List (output list, initially empty) + - validationErrors: Dictionary (output) + +INITIALIZE: + - isTrusted ← false + - current ← certificates[0] // Leaf certificate + - untrustedCollection ← certificates[1..n] // Additional certs from peer +``` + +##### Step 2: Iterative Chain Building Loop + +``` +WHILE (issuer is found) DO: + + // Exit condition: Self-signed certificate reached + IF (IsSelfSigned(current)) THEN + BREAK // Chain is complete + END IF + + // Step 2.1: Search in Trusted Certificate Store + issuer ← FindIssuer( + certificate: current, + location: TrustedCertificateList + TrustedCertificateStore, + checkRevocation: true + ) + + IF (issuer != null) THEN + isTrusted ← true // Chain ends in trusted store + revocationStatus ← CheckCRL(issuer, current) + validationErrors[current] ← revocationStatus + GOTO Step_2.4 + END IF + + // Step 2.2: Search in Issuer Certificate Store + issuer ← FindIssuer( + certificate: current, + location: IssuerCertificateList + IssuerCertificateStore, + checkRevocation: true + ) + + IF (issuer != null) THEN + revocationStatus ← CheckCRL(issuer, current) + validationErrors[current] ← revocationStatus + GOTO Step_2.4 + END IF + + // Step 2.3: Search in Untrusted Certificates (from peer) + issuer ← FindIssuer( + certificate: current, + location: untrustedCollection, + checkRevocation: true + ) + + IF (issuer == null) THEN + BREAK // Chain building failed - no issuer found + END IF + + // Step 2.4: Circular Chain Detection + FOR EACH existingIssuer IN issuers DO + IF (existingIssuer.Thumbprint == issuer.Thumbprint) THEN + BREAK LOOP // Circular chain detected + END IF + END FOR + + // Step 2.5: Add Issuer to Chain + issuers.Add(issuer) + current ← LoadCertificate(issuer) + +END WHILE + +RETURN isTrusted +``` + +##### Step 3: Issuer Matching Algorithm + +The `FindIssuer()` function uses the following matching criteria: + +``` +FUNCTION FindIssuer(certificate, location, checkRevocation): + + // Extract issuer information from certificate + subjectName ← certificate.IssuerName + authorityKeyId ← ExtractAuthorityKeyIdentifier(certificate) + serialNumber ← ExtractSerialNumber(certificate) + + // Search all certificates in location + FOR EACH candidate IN location DO + + // Basic matching criteria + IF (candidate.SubjectName != subjectName) THEN + CONTINUE // Subject name must match issuer name + END IF + + // Check if issuer is allowed (not end-entity cert) + IF (NOT IsIssuerAllowed(candidate)) THEN + CONTINUE // Must have CA capabilities + END IF + + // Optional: Match by serial number + IF (serialNumber != null AND candidate.SerialNumber != serialNumber) THEN + CONTINUE + END IF + + // Optional: Match by Authority Key Identifier + IF (authorityKeyId != null) THEN + subjectKeyId ← candidate.SubjectKeyIdentifier + IF (subjectKeyId != authorityKeyId) THEN + CONTINUE + END IF + END IF + + // Candidate matches - perform revocation check + IF (checkRevocation) THEN + revocationStatus ← CheckCRL(candidate, certificate) + IF (revocationStatus == Revoked) THEN + RETURN (candidate, RevocationError) + END IF + END IF + + RETURN (candidate, revocationStatus) + END FOR + + RETURN (null, null) // No matching issuer found +END FUNCTION +``` + +##### Step 4: Certificate Revocation List (CRL) Checking + +``` +FUNCTION CheckCRL(issuer, certificate): + + // Only check if store supports CRL operations + IF (store.SupportsCRL()) THEN + + crlStatus ← store.IsRevoked(issuer, certificate) + + CASE crlStatus OF + StatusCodes.Good: + RETURN null // Not revoked + + StatusCodes.BadCertificateRevoked: + IF (IsCertificateAuthority(certificate)) THEN + RETURN BadCertificateIssuerRevoked + ELSE + RETURN BadCertificateRevoked + END IF + + StatusCodes.BadCertificateRevocationUnknown: + IF (IsCertificateAuthority(certificate)) THEN + statusCode ← BadCertificateIssuerRevocationUnknown + ELSE + statusCode ← BadCertificateRevocationUnknown + END IF + + // Check if error should be suppressed + IF (RejectUnknownRevocationStatus AND + NOT HasValidationOption(SuppressRevocationStatusUnknown)) THEN + RETURN statusCode + END IF + RETURN null // Suppressed + + StatusCodes.BadNotSupported: + RETURN null // CRL not supported by store + END CASE + END IF + + RETURN null +END FUNCTION +``` + +#### X509Chain Validation + +After building the issuer chain, the validator uses .NET's `X509Chain` to verify cryptographic signatures and certificate properties: + +``` +// Configure chain policy +policy ← new X509ChainPolicy() +policy.RevocationMode ← NoCheck // Already checked via CRL +policy.RevocationFlag ← EntireChain +policy.VerificationFlags ← ConfigureFromValidationOptions(issuers) + +// Add all found issuers to extra store +FOR EACH issuer IN issuers DO + policy.ExtraStore.Add(issuer.Certificate) +END FOR + +// Build and validate chain +chain ← new X509Chain() +chain.ChainPolicy ← policy +chain.Build(certificate) + +// Process chain status +FOR EACH chainStatus IN chain.ChainStatus DO + ProcessChainStatus(chainStatus) +END FOR + +// Verify chain matches issuers +IF (chain.ChainElements.Count != issuers.Count + 1) THEN + chainIncomplete ← true +END IF + +// Validate each chain element +FOR EACH element IN chain.ChainElements DO + ValidateChainElement(element) +END FOR +``` + +#### Chain Validation Results + +The chain building process returns: + +1. **isTrusted**: `true` if any issuer was found in the trusted store, `false` otherwise +2. **issuers**: List of all issuer certificates in the chain (leaf to root, excluding the leaf itself) +3. **validationErrors**: Dictionary mapping certificates to their revocation status errors + +These results are then used by the main validation logic to determine if the certificate should be accepted or rejected. + +#### Key Behaviors + +1. **Search Priority**: Trusted store → Issuer store → Untrusted collection (from peer) +2. **Trust Anchoring**: If any issuer is found in the trusted store, the entire chain is considered to have a trusted anchor +3. **CRL Checking**: Performed during chain building if the store supports it +4. **Circular Chain Detection**: Prevents infinite loops by checking for duplicate thumbprints +5. **Partial Chains**: If no issuer is found, the chain is marked incomplete but processing continues +6. **Self-Signed Detection**: Chain building stops when a self-signed certificate is encountered + +### Certificate List Configuration + +This section describes how certificate lists and stores are configured and populated in the OPC UA .NET Standard Stack. + +#### Configuration Sources + +Certificate lists are populated from two primary sources: + +1. **Configuration File** (XML): The `ApplicationConfiguration` file defines certificate store locations and optional explicit certificate lists +2. **Runtime API**: Applications can programmatically add certificates to trust lists using the `SecurityConfiguration` API + +#### Configuration File Structure + +The `SecurityConfiguration` section in the application configuration file (`*.Config.xml`) defines certificate stores: + +```xml + + + + + Directory + %LocalApplicationData%/OPC Foundation/pki/own + CN=MyApplication, O=MyOrganization + RsaSha256 + + + + + + Directory + %LocalApplicationData%/OPC Foundation/pki/issuer + + + + 1234567890ABCDEF... + + + + + + + Directory + %LocalApplicationData%/OPC Foundation/pki/trusted + + + + FEDCBA0987654321... + + + + + + + Directory + %LocalApplicationData%/OPC Foundation/pki/rejected + + +``` + +#### Certificate Store Types + +Two store types are supported: + +1. **Directory**: File system-based certificate store + - Certificates stored as `.der` or `.crt` files in `certs/` subdirectory + - Private keys stored as `.pfx` or `.pem` files in `private/` subdirectory + - CRLs stored in `crl/` subdirectory + - Example: `%LocalApplicationData%/OPC Foundation/pki/trusted` + +2. **X509Store**: Windows certificate store (on Windows platforms) + - Uses Windows Certificate Store API + - Example: `CurrentUser\My` or `LocalMachine\Root` + - Supports CRL operations on Windows only + +#### Certificate List Population + +The `CertificateValidator` is populated through the following process: + +##### 1. Initialization via ApplicationConfiguration + +```csharp +// Load configuration from file +ApplicationConfiguration config = await ApplicationConfiguration + .Load(new FileInfo("MyApp.Config.xml"), ApplicationType.Client, null) + .ConfigureAwait(false); + +// Update validator with configuration +await config.CertificateValidator.UpdateAsync(config).ConfigureAwait(false); +``` + +##### 2. Internal Update Process + +When `UpdateAsync()` is called, the validator performs these steps: + +``` +// From SecurityConfiguration +trustedStore = config.SecurityConfiguration.TrustedPeerCertificates +issuerStore = config.SecurityConfiguration.TrustedIssuerCertificates + +// Populate internal structures +m_trustedCertificateStore = trustedStore.StorePath +m_trustedCertificateList = trustedStore.TrustedCertificates (if specified) +m_issuerCertificateStore = issuerStore.StorePath +m_issuerCertificateList = issuerStore.TrustedCertificates (if specified) +m_applicationCertificates = config.SecurityConfiguration.ApplicationCertificates +``` + +##### 3. Certificate Search Behavior + +During validation, certificates are searched in the following order: + +**For Trusted Certificates:** +1. Search `m_trustedCertificateList` (explicit list) - if populated +2. Search `m_trustedCertificateStore` (file system or X509Store) +3. Search `m_applicationCertificates` (application's own certificates) + +**For Issuer Certificates:** +1. Search `m_issuerCertificateList` (explicit list) - if populated +2. Search `m_issuerCertificateStore` (file system or X509Store) + +#### Runtime Certificate Management + +Applications can dynamically add certificates to trust lists: + +```csharp +// Add a trusted peer certificate programmatically +byte[] certificateData = File.ReadAllBytes("peer-cert.der"); +config.SecurityConfiguration.AddTrustedPeer(certificateData); + +// Or add to the explicit list +var certId = new CertificateIdentifier(certificateData); +config.SecurityConfiguration.TrustedPeerCertificates.TrustedCertificates.Add(certId); + +// Update the validator to apply changes +await config.CertificateValidator.UpdateAsync(config).ConfigureAwait(false); +``` + +#### Certificate Store Management + +Certificates in file system stores are managed as follows: + +1. **Adding Certificates**: Copy certificate files to the `certs/` subdirectory of the store path + - Format: `[Thumbprint].der` or `[Thumbprint].crt` + - Example: `%LocalApplicationData%/OPC Foundation/pki/trusted/certs/1234567890ABCDEF.der` + +2. **Adding CRLs**: Copy CRL files to the `crl/` subdirectory + - Format: `[IssuerThumbprint].crl` + - Example: `%LocalApplicationData%/OPC Foundation/pki/issuer/crl/FEDCBA0987654321.crl` + +3. **Rejected Certificates**: Automatically added by the validator when validation fails + - Stored in the rejected certificate store + - Can be manually moved to trusted store to establish trust + +#### Dual-Mode Operation + +The validator supports both explicit lists and certificate stores: + +- **Explicit List Only**: Specify certificates in `` without a store path +- **Store Only**: Specify store path without explicit certificates (most common) +- **Combined Mode**: Use both explicit list and store for maximum flexibility + - Explicit list is searched first for performance + - Store is searched if not found in list + +#### Configuration Best Practices + +1. **Store Path**: Use environment variables for platform independence: + - `%LocalApplicationData%` - Per-user application data + - `%CommonApplicationData%` - Machine-wide application data + - Relative paths - Relative to application directory + +2. **Explicit Lists**: Use for: + - Small, fixed set of trusted certificates + - Performance optimization (faster than store enumeration) + - Pre-deployment certificate distribution + +3. **Certificate Stores**: Use for: + - Dynamic trust management + - Administrator-managed certificate stores + - Integration with OS certificate infrastructure + +4. **Separation**: Keep different certificate types in separate stores: + - Application certificates: `pki/own` + - Trusted peers: `pki/trusted` + - Trusted CAs: `pki/issuer` + - Rejected: `pki/rejected` + +### Configuration Settings + +The certificate validation behavior is controlled by several configuration settings in the `SecurityConfiguration` class: + +#### AutoAcceptUntrustedCertificates +- **Type**: `bool` +- **Default**: `false` +- **Description**: When `true`, automatically accepts certificates that have the `BadCertificateUntrusted` status. This is useful for development environments but should not be used in production. +- **Example**: +```csharp +configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates = true; +``` + +#### RejectSHA1SignedCertificates +- **Type**: `bool` +- **Default**: `true` (when default hash size >= 256) +- **Description**: When `true`, rejects certificates signed with SHA-1 algorithms as they are considered cryptographically weak. +- **Example**: +```csharp +configuration.SecurityConfiguration.RejectSHA1SignedCertificates = true; +``` + +#### RejectUnknownRevocationStatus +- **Type**: `bool` +- **Default**: `false` +- **Description**: When `true`, rejects certificates when the revocation status cannot be determined (e.g., CRL is not available). +- **Example**: +```csharp +configuration.SecurityConfiguration.RejectUnknownRevocationStatus = true; +``` + +#### MinimumCertificateKeySize +- **Type**: `ushort` +- **Default**: `2048` (CertificateFactory.DefaultKeySize) +- **Description**: The minimum RSA key size in bits that will be accepted. Common values are 2048, 3072, or 4096. +- **Example**: +```csharp +configuration.SecurityConfiguration.MinimumCertificateKeySize = 2048; +``` + +#### UseValidatedCertificates +- **Type**: `bool` +- **Default**: `false` +- **Description**: When `true`, skips validation for certificates that have already been successfully validated in the current session. This improves performance by caching validation results. +- **Example**: +```csharp +configuration.SecurityConfiguration.UseValidatedCertificates = true; +``` + +#### MaxRejectedCertificates +- **Type**: `int` +- **Default**: `5` +- **Description**: Limits the number of rejected certificates kept in history. A value of 0 means all rejected certificates are kept. A negative value means no history is kept. +- **Example**: +```csharp +configuration.SecurityConfiguration.MaxRejectedCertificates = 10; +``` + +### Suppressible Validation Errors + +The following validation errors can be suppressed by handling the `CertificateValidation` event and setting `e.Accept = true`: + +- **BadCertificateUntrusted**: The certificate is not trusted (not in the trusted store or chain). +- **BadCertificateHostNameInvalid**: The domain name in the endpoint URL does not match any domain in the certificate. +- **BadCertificateIssuerRevocationUnknown**: The revocation status of the issuer cannot be determined. +- **BadCertificateChainIncomplete**: The certificate chain is incomplete (missing issuer certificates). +- **BadCertificateIssuerTimeInvalid**: The issuer certificate has expired or is not yet valid. +- **BadCertificateIssuerUseNotAllowed**: The issuer certificate is not valid for the intended use. +- **BadCertificateRevocationUnknown**: The revocation status of the certificate cannot be determined. +- **BadCertificateTimeInvalid**: The certificate has expired or is not yet valid. +- **BadCertificatePolicyCheckFailed**: The certificate does not meet policy requirements (e.g., key size, signature algorithm). +- **BadCertificateUseNotAllowed**: The certificate is not valid for the intended use (missing key usage flags). + +All other validation errors are **non-suppressible** and will always cause the validation to fail. + +### Registering a Certificate Validation Callback + +To handle certificate validation errors and decide whether to accept or reject certificates, register a callback handler: + +```csharp +// Register the callback +configuration.CertificateValidator.CertificateValidation += CertificateValidationCallback; + +// Implement the callback +private void CertificateValidationCallback( + CertificateValidator sender, + CertificateValidationEventArgs e) +{ + // Log the validation error + Console.WriteLine($"Certificate validation error: {e.Error}"); + Console.WriteLine($"Certificate Subject: {e.Certificate.Subject}"); + + // Decide whether to accept the certificate + // For example, auto-accept BadCertificateUntrusted in development + if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted) + { + Console.WriteLine("Auto-accepting untrusted certificate in development mode."); + e.Accept = true; // Accept this specific error + } + + // To accept all errors for this certificate (use with caution): + // e.AcceptAll = true; + + // To provide a custom error message: + // e.ApplicationErrorMsg = "Custom error message"; +} + +// Don't forget to unregister when disposing +configuration.CertificateValidator.CertificateValidation -= CertificateValidationCallback; +``` + +### Configuring a Custom Certificate Validator + +To use a custom certificate validator instead of the default `CertificateValidator`, implement the `ICertificateValidator` interface: + +```csharp +public class CustomCertificateValidator : ICertificateValidator +{ + public Task ValidateAsync(X509Certificate2 certificate, CancellationToken ct) + { + return ValidateAsync(new X509Certificate2Collection { certificate }, ct); + } + + public Task ValidateAsync(X509Certificate2Collection certificateChain, CancellationToken ct) + { + // Implement your custom validation logic + X509Certificate2 certificate = certificateChain[0]; + + // Example: Check custom requirements + if (!MeetsCustomRequirements(certificate)) + { + throw new ServiceResultException( + StatusCodes.BadCertificateInvalid, + "Certificate does not meet custom requirements."); + } + + return Task.CompletedTask; + } + + private bool MeetsCustomRequirements(X509Certificate2 certificate) + { + // Implement your custom validation logic + return true; + } +} + +// To use the custom validator: +var customValidator = new CustomCertificateValidator(); +configuration.CertificateValidator = customValidator; +``` + +Alternatively, you can extend the default `CertificateValidator` class to customize specific aspects: + +```csharp +public class ExtendedCertificateValidator : CertificateValidator +{ + public ExtendedCertificateValidator(ITelemetryContext telemetry) + : base(telemetry) + { + } + + protected override async Task InternalValidateAsync( + X509Certificate2Collection certificates, + ConfiguredEndpoint endpoint, + CancellationToken ct = default) + { + // Call base validation first + await base.InternalValidateAsync(certificates, endpoint, ct); + + // Add your custom validation logic + X509Certificate2 certificate = certificates[0]; + + if (!CustomValidationCheck(certificate)) + { + throw new ServiceResultException( + StatusCodes.BadCertificateInvalid, + "Custom validation failed."); + } + } + + private bool CustomValidationCheck(X509Certificate2 certificate) + { + // Implement additional validation logic + return true; + } +} +``` + +### Best Practices + +1. **Production vs Development**: Never use `AutoAcceptUntrustedCertificates = true` in production environments. + +2. **Certificate Store Management**: Regularly review rejected certificates in the rejected store and move trusted certificates to the appropriate trust store. + +3. **Revocation Checking**: Enable `RejectUnknownRevocationStatus` for high-security environments where CRL checking is critical. + +4. **Minimum Key Size**: Use at least 2048 bits for RSA keys. Consider 3072 or 4096 bits for long-term security. + +5. **SHA-1 Deprecation**: Keep `RejectSHA1SignedCertificates = true` to ensure only certificates with strong signature algorithms are accepted. + +6. **Validation Callback**: Always log certificate validation events for security auditing purposes. + +7. **Custom Validators**: When implementing a custom validator, ensure it complies with OPC UA security requirements and thoroughly test edge cases. \ No newline at end of file From f2ac68f10f7c1034924879c87594e57be0bbdf62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 08:15:38 +0100 Subject: [PATCH 31/42] Bump Microsoft.Extensions.Configuration and Microsoft.Extensions.Configuration.EnvironmentVariables (#3487) Bumps Microsoft.Extensions.Configuration from 10.0.1 to 10.0.2 Bumps Microsoft.Extensions.Configuration.EnvironmentVariables from 10.0.1 to 10.0.2 --- updated-dependencies: - dependency-name: Microsoft.Extensions.Configuration dependency-version: 10.0.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Microsoft.Extensions.Configuration.EnvironmentVariables dependency-version: 10.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 91a4c9930..0af87871c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,8 +14,8 @@ - - + + From 12704afa68a03c71abd1690741e0f15cc2ee9f8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 08:16:07 +0100 Subject: [PATCH 32/42] Bump Microsoft.AspNetCore.Server.Kestrel and Microsoft.AspNetCore.Server.Kestrel.Core (#3485) Bumps Microsoft.AspNetCore.Server.Kestrel from 2.3.0 to 2.3.9 Bumps Microsoft.AspNetCore.Server.Kestrel.Core from 2.3.6 to 2.3.8 --- updated-dependencies: - dependency-name: Microsoft.AspNetCore.Server.Kestrel dependency-version: 2.3.9 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: Microsoft.AspNetCore.Server.Kestrel.Core dependency-version: 2.3.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0af87871c..344d80eb2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,8 +9,8 @@ - - + + From 4f089ba86bce08491f861dda6406f6c78ce48302 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 09:58:19 +0100 Subject: [PATCH 33/42] Bump System.Collections.Immutable from 10.0.1 to 10.0.2 (#3490) --- updated-dependencies: - dependency-name: System.Collections.Immutable dependency-version: 10.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 344d80eb2..7eb077224 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,7 @@ - + From 424f53fd98e825ec7871a6514a0651b076d81382 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 13:17:26 +0100 Subject: [PATCH 34/42] Bump Microsoft.Extensions.Logging from 10.0.1 to 10.0.2 (#3488) * Bump Microsoft.Extensions.Logging from 10.0.1 to 10.0.2 --- updated-dependencies: - dependency-name: Microsoft.Extensions.Logging dependency-version: 10.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Update --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marc Schier --- Directory.Packages.props | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7eb077224..73cfadf4c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,14 +10,14 @@ - - + + - - + + @@ -40,15 +40,15 @@ - + - + - + - + \ No newline at end of file From d71ee2a13a1989c0073205c5a6ee217ad8c816be Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:04:41 +0100 Subject: [PATCH 35/42] Add async callback support to TrustList implementation (#3483) * Initial plan * Add async callback support to TrustList implementation Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Fix redundant security check in OpenAsync Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Fix audit logging parameters in RemoveCertificateAsync Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Refactor TrustList to use async core implementation with sync wrappers Based on feedback, changed approach: - Make internal implementation async (OpenCoreAsync, etc.) - Sync methods now wrap async using .AsTask().GetAwaiter().GetResult() - Both OnCall and OnCallAsync handlers registered - Eliminates .GetAwaiter().GetResult() in async paths Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Add ConfigureAwait(false) to sync wrapper methods Prevents potential deadlocks by adding ConfigureAwait(false) before GetAwaiter().GetResult() in all sync wrapper methods. Also made the pattern consistent across all methods. Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Add CancellationToken support to helper methods Updated UpdateStoreCertificatesAsync and UpdateStoreCrlsAsync to properly propagate CancellationToken through all async operations. Ensures consistent async patterns throughout the implementation. Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- .../Opc.Ua.Server/Configuration/TrustList.cs | 684 ++++++++++++------ 1 file changed, 473 insertions(+), 211 deletions(-) diff --git a/Libraries/Opc.Ua.Server/Configuration/TrustList.cs b/Libraries/Opc.Ua.Server/Configuration/TrustList.cs index 481eb8bcf..57a8d1967 100644 --- a/Libraries/Opc.Ua.Server/Configuration/TrustList.cs +++ b/Libraries/Opc.Ua.Server/Configuration/TrustList.cs @@ -73,17 +73,29 @@ public TrustList( m_maxTrustListSize = maxTrustListSize > 0 ? maxTrustListSize : kDefaultMaxTrustListSize; node.Open.OnCall = new OpenMethodStateMethodCallHandler(Open); + node.Open.OnCallAsync = new OpenMethodStateMethodAsyncCallHandler(OpenAsync); node.OpenWithMasks.OnCall = new OpenWithMasksMethodStateMethodCallHandler(OpenWithMasks); + node.OpenWithMasks.OnCallAsync + = new OpenWithMasksMethodStateMethodAsyncCallHandler(OpenWithMasksAsync); node.Read.OnCall = new ReadMethodStateMethodCallHandler(Read); + node.Read.OnCallAsync = new ReadMethodStateMethodAsyncCallHandler(ReadAsync); node.Write.OnCall = new WriteMethodStateMethodCallHandler(Write); + node.Write.OnCallAsync = new WriteMethodStateMethodAsyncCallHandler(WriteAsync); node.Close.OnCall = new CloseMethodStateMethodCallHandler(Close); + node.Close.OnCallAsync = new CloseMethodStateMethodAsyncCallHandler(CloseAsync); node.CloseAndUpdate.OnCall = new CloseAndUpdateMethodStateMethodCallHandler(CloseAndUpdate); + node.CloseAndUpdate.OnCallAsync + = new CloseAndUpdateMethodStateMethodAsyncCallHandler(CloseAndUpdateAsync); node.AddCertificate.OnCall = new AddCertificateMethodStateMethodCallHandler(AddCertificate); + node.AddCertificate.OnCallAsync + = new AddCertificateMethodStateMethodAsyncCallHandler(AddCertificateAsync); node.RemoveCertificate.OnCall = new RemoveCertificateMethodStateMethodCallHandler(RemoveCertificate); + node.RemoveCertificate.OnCallAsync + = new RemoveCertificateMethodStateMethodAsyncCallHandler(RemoveCertificateAsync); } /// @@ -102,13 +114,30 @@ private ServiceResult Open( byte mode, ref uint fileHandle) { - return Open( + var result = OpenAsync( + context, + method, + objectId, + mode, + CancellationToken.None).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + fileHandle = result.FileHandle; + return result.ServiceResult; + } + + private ValueTask OpenAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + byte mode, + CancellationToken cancellationToken) + { + return OpenCoreAsync( context, method, objectId, (OpenFileMode)mode, TrustListMasks.All, - ref fileHandle); + cancellationToken); } private ServiceResult OpenWithMasks( @@ -118,25 +147,46 @@ private ServiceResult OpenWithMasks( uint masks, ref uint fileHandle) { - return Open( + var result = OpenWithMasksAsync( + context, + method, + objectId, + masks, + CancellationToken.None).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + fileHandle = result.FileHandle; + return result.ServiceResult; + } + + private async ValueTask OpenWithMasksAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + uint masks, + CancellationToken cancellationToken) + { + var result = await OpenCoreAsync( context, method, objectId, OpenFileMode.Read, (TrustListMasks)masks, - ref fileHandle); + cancellationToken).ConfigureAwait(false); + + return new OpenWithMasksMethodStateResult + { + ServiceResult = result.ServiceResult, + FileHandle = result.FileHandle + }; } - private ServiceResult Open( + private async ValueTask OpenCoreAsync( ISystemContext context, MethodState method, NodeId objectId, OpenFileMode mode, TrustListMasks masks, - ref uint fileHandle) + CancellationToken cancellationToken) { - HasSecureReadAccess(context); - if (mode == OpenFileMode.Read) { HasSecureReadAccess(context); @@ -147,24 +197,18 @@ private ServiceResult Open( } else { - return StatusCodes.BadNotWritable; - } - - lock (m_lock) - { - if (m_sessionId != null) + return new OpenMethodStateResult { - // to avoid deadlocks, last open always wins - m_sessionId = null; - m_strm = null; - m_node.OpenCount.Value = 0; - } + ServiceResult = StatusCodes.BadNotWritable, + FileHandle = 0 + }; + } - m_readMode = mode == OpenFileMode.Read; - m_sessionId = (context as ISessionSystemContext)?.SessionId; - fileHandle = ++m_fileHandle; - m_totalBytesProcessed = 0; // Reset counter for new file operation + uint fileHandle = 0; + MemoryStream strm = null; + try + { var trustList = new TrustListDataType { SpecifiedLists = (uint)masks }; ICertificateStore store = m_trustedStore.OpenStore(m_telemetry); @@ -179,8 +223,9 @@ private ServiceResult Open( if (((int)masks & (int)TrustListMasks.TrustedCertificates) != 0) { - foreach (X509Certificate2 certificate in store.EnumerateAsync().GetAwaiter() - .GetResult()) + X509Certificate2Collection certificates = await store.EnumerateAsync(cancellationToken) + .ConfigureAwait(false); + foreach (X509Certificate2 certificate in certificates) { trustList.TrustedCertificates.Add(certificate.RawData); } @@ -188,7 +233,9 @@ private ServiceResult Open( if (((int)masks & (int)TrustListMasks.TrustedCrls) != 0) { - foreach (X509CRL crl in store.EnumerateCRLsAsync().GetAwaiter().GetResult()) + X509CRLCollection crls = await store.EnumerateCRLsAsync(cancellationToken) + .ConfigureAwait(false); + foreach (X509CRL crl in crls) { trustList.TrustedCrls.Add(crl.RawData); } @@ -211,8 +258,9 @@ private ServiceResult Open( if (((int)masks & (int)TrustListMasks.IssuerCertificates) != 0) { - foreach (X509Certificate2 certificate in store.EnumerateAsync().GetAwaiter() - .GetResult()) + X509Certificate2Collection certificates = await store.EnumerateAsync(cancellationToken) + .ConfigureAwait(false); + foreach (X509Certificate2 certificate in certificates) { trustList.IssuerCertificates.Add(certificate.RawData); } @@ -220,7 +268,9 @@ private ServiceResult Open( if (((int)masks & (int)TrustListMasks.IssuerCrls) != 0) { - foreach (X509CRL crl in store.EnumerateCRLsAsync().GetAwaiter().GetResult()) + X509CRLCollection crls = await store.EnumerateCRLsAsync(cancellationToken) + .ConfigureAwait(false); + foreach (X509CRL crl in crls) { trustList.IssuerCrls.Add(crl.RawData); } @@ -231,19 +281,44 @@ private ServiceResult Open( store.Close(); } - if (m_readMode) + if (mode == OpenFileMode.Read) { - m_strm = EncodeTrustListData(context, trustList); + strm = EncodeTrustListData(context, trustList); } else { - m_strm = new MemoryStream(kDefaultTrustListCapacity); + strm = new MemoryStream(kDefaultTrustListCapacity); } - m_node.OpenCount.Value = 1; + lock (m_lock) + { + if (m_sessionId != null) + { + // to avoid deadlocks, last open always wins + m_sessionId = null; + m_strm = null; + m_node.OpenCount.Value = 0; + } + + m_readMode = mode == OpenFileMode.Read; + m_sessionId = (context as ISessionSystemContext)?.SessionId; + fileHandle = ++m_fileHandle; + m_totalBytesProcessed = 0; // Reset counter for new file operation + m_strm = strm; + m_node.OpenCount.Value = 1; + } + } + catch + { + strm?.Dispose(); + throw; } - return ServiceResult.Good; + return new OpenMethodStateResult + { + ServiceResult = ServiceResult.Good, + FileHandle = fileHandle + }; } private ServiceResult Read( @@ -253,33 +328,66 @@ private ServiceResult Read( uint fileHandle, int length, ref byte[] data) + { + var result = ReadAsync( + context, + method, + objectId, + fileHandle, + length, + CancellationToken.None).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + data = result.Data; + return result.ServiceResult; + } + + private ValueTask ReadAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + uint fileHandle, + int length, + CancellationToken cancellationToken) { HasSecureReadAccess(context); + byte[] data; + lock (m_lock) { if (context is ISessionSystemContext session && m_sessionId != session.SessionId) { - return ServiceResult.Create( - StatusCodes.BadUserAccessDenied, - "Session not authorized"); + return new ValueTask(new ReadMethodStateResult + { + ServiceResult = ServiceResult.Create( + StatusCodes.BadUserAccessDenied, + "Session not authorized"), + Data = null + }); } if (m_fileHandle != fileHandle) { - return ServiceResult.Create( - StatusCodes.BadInvalidArgument, - "Invalid file handle"); + return new ValueTask(new ReadMethodStateResult + { + ServiceResult = ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "Invalid file handle"), + Data = null + }); } // Check if we would exceed the maximum trust list size if (m_totalBytesProcessed + length > m_maxTrustListSize) { - return ServiceResult.Create( - StatusCodes.BadEncodingLimitsExceeded, - "Trust list size exceeds maximum allowed size of {0} bytes", - m_maxTrustListSize); + return new ValueTask(new ReadMethodStateResult + { + ServiceResult = ServiceResult.Create( + StatusCodes.BadEncodingLimitsExceeded, + "Trust list size exceeds maximum allowed size of {0} bytes", + m_maxTrustListSize), + Data = null + }); } data = new byte[length]; @@ -297,7 +405,11 @@ private ServiceResult Read( } } - return ServiceResult.Good; + return new ValueTask(new ReadMethodStateResult + { + ServiceResult = ServiceResult.Good, + Data = data + }); } private ServiceResult Write( @@ -306,6 +418,24 @@ private ServiceResult Write( NodeId objectId, uint fileHandle, byte[] data) + { + var result = WriteAsync( + context, + method, + objectId, + fileHandle, + data, + CancellationToken.None).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + return result.ServiceResult; + } + + private ValueTask WriteAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + uint fileHandle, + byte[] data, + CancellationToken cancellationToken) { HasSecureWriteAccess(context); @@ -314,28 +444,40 @@ private ServiceResult Write( if (context is ISessionSystemContext session && m_sessionId != session.SessionId) { - return StatusCodes.BadUserAccessDenied; + return new ValueTask(new WriteMethodStateResult + { + ServiceResult = StatusCodes.BadUserAccessDenied + }); } if (m_fileHandle != fileHandle) { - return StatusCodes.BadInvalidArgument; + return new ValueTask(new WriteMethodStateResult + { + ServiceResult = StatusCodes.BadInvalidArgument + }); } // Check if we would exceed the maximum trust list size if (m_totalBytesProcessed + data.Length > m_maxTrustListSize) { - return ServiceResult.Create( - StatusCodes.BadEncodingLimitsExceeded, - "Trust list size exceeds maximum allowed size of {0} bytes", - m_maxTrustListSize); + return new ValueTask(new WriteMethodStateResult + { + ServiceResult = ServiceResult.Create( + StatusCodes.BadEncodingLimitsExceeded, + "Trust list size exceeds maximum allowed size of {0} bytes", + m_maxTrustListSize) + }); } m_strm.Write(data, 0, data.Length); m_totalBytesProcessed += data.Length; } - return ServiceResult.Good; + return new ValueTask(new WriteMethodStateResult + { + ServiceResult = ServiceResult.Good + }); } private ServiceResult Close( @@ -343,6 +485,22 @@ private ServiceResult Close( MethodState method, NodeId objectId, uint fileHandle) + { + var result = CloseAsync( + context, + method, + objectId, + fileHandle, + CancellationToken.None).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + return result.ServiceResult; + } + + private ValueTask CloseAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + uint fileHandle, + CancellationToken cancellationToken) { HasSecureReadAccess(context); @@ -351,12 +509,18 @@ private ServiceResult Close( if (context is ISessionSystemContext session && m_sessionId != session.SessionId) { - return StatusCodes.BadUserAccessDenied; + return new ValueTask(new CloseMethodStateResult + { + ServiceResult = StatusCodes.BadUserAccessDenied + }); } if (m_fileHandle != fileHandle) { - return StatusCodes.BadInvalidArgument; + return new ValueTask(new CloseMethodStateResult + { + ServiceResult = StatusCodes.BadInvalidArgument + }); } m_sessionId = null; @@ -364,7 +528,10 @@ private ServiceResult Close( m_node.OpenCount.Value = 0; } - return ServiceResult.Good; + return new ValueTask(new CloseMethodStateResult + { + ServiceResult = ServiceResult.Good + }); } private ServiceResult CloseAndUpdate( @@ -373,6 +540,23 @@ private ServiceResult CloseAndUpdate( NodeId objectId, uint fileHandle, ref bool restartRequired) + { + var result = CloseAndUpdateAsync( + context, + method, + objectId, + fileHandle, + CancellationToken.None).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + restartRequired = result.ApplyChangesRequired; + return result.ServiceResult; + } + + private async ValueTask CloseAndUpdateAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + uint fileHandle, + CancellationToken cancellationToken) { object[] inputParameters = [fileHandle]; m_node.ReportTrustListUpdateRequestedAuditEvent( @@ -386,100 +570,113 @@ private ServiceResult CloseAndUpdate( ServiceResult result = StatusCodes.Good; + MemoryStream strm = null; + lock (m_lock) { if (context is ISessionSystemContext session && m_sessionId != session.SessionId) { - return StatusCodes.BadUserAccessDenied; + return new CloseAndUpdateMethodStateResult + { + ServiceResult = StatusCodes.BadUserAccessDenied, + ApplyChangesRequired = false + }; } if (m_fileHandle != fileHandle) { - return StatusCodes.BadInvalidArgument; + return new CloseAndUpdateMethodStateResult + { + ServiceResult = StatusCodes.BadInvalidArgument, + ApplyChangesRequired = false + }; } - try - { - TrustListDataType trustList = DecodeTrustListData(context, m_strm); - int masks = (int)trustList.SpecifiedLists; + strm = m_strm; + } - X509Certificate2Collection issuerCertificates = null; - X509CRLCollection issuerCrls = null; - X509Certificate2Collection trustedCertificates = null; - X509CRLCollection trustedCrls = null; + try + { + TrustListDataType trustList = DecodeTrustListData(context, strm); + int masks = (int)trustList.SpecifiedLists; - // test integrity of all CRLs - if ((masks & (int)TrustListMasks.IssuerCertificates) != 0) - { - issuerCertificates = []; - foreach (byte[] cert in trustList.IssuerCertificates) - { - issuerCertificates.Add(X509CertificateLoader.LoadCertificate(cert)); - } - } - if ((masks & (int)TrustListMasks.IssuerCrls) != 0) - { - issuerCrls = []; - foreach (byte[] crl in trustList.IssuerCrls) - { - issuerCrls.Add(new X509CRL(crl)); - } - } - if ((masks & (int)TrustListMasks.TrustedCertificates) != 0) - { - trustedCertificates = []; - foreach (byte[] cert in trustList.TrustedCertificates) - { - trustedCertificates.Add(CertificateFactory.Create(cert)); - } - } - if ((masks & (int)TrustListMasks.TrustedCrls) != 0) - { - trustedCrls = []; - foreach (byte[] crl in trustList.TrustedCrls) - { - trustedCrls.Add(new X509CRL(crl)); - } - } + X509Certificate2Collection issuerCertificates = null; + X509CRLCollection issuerCrls = null; + X509Certificate2Collection trustedCertificates = null; + X509CRLCollection trustedCrls = null; - // update store - // test integrity of all CRLs - int updateMasks = (int)TrustListMasks.None; - if ((masks & (int)TrustListMasks.IssuerCertificates) != 0 && - UpdateStoreCertificatesAsync(m_issuerStore, issuerCertificates).GetAwaiter() - .GetResult()) + // test integrity of all CRLs + if ((masks & (int)TrustListMasks.IssuerCertificates) != 0) + { + issuerCertificates = []; + foreach (byte[] cert in trustList.IssuerCertificates) { - updateMasks |= (int)TrustListMasks.IssuerCertificates; + issuerCertificates.Add(X509CertificateLoader.LoadCertificate(cert)); } - if ((masks & (int)TrustListMasks.IssuerCrls) != 0 && - UpdateStoreCrlsAsync(m_issuerStore, issuerCrls).GetAwaiter().GetResult()) + } + if ((masks & (int)TrustListMasks.IssuerCrls) != 0) + { + issuerCrls = []; + foreach (byte[] crl in trustList.IssuerCrls) { - updateMasks |= (int)TrustListMasks.IssuerCrls; + issuerCrls.Add(new X509CRL(crl)); } - if ((masks & (int)TrustListMasks.TrustedCertificates) != 0 && - UpdateStoreCertificatesAsync(m_trustedStore, trustedCertificates) - .GetAwaiter() - .GetResult()) + } + if ((masks & (int)TrustListMasks.TrustedCertificates) != 0) + { + trustedCertificates = []; + foreach (byte[] cert in trustList.TrustedCertificates) { - updateMasks |= (int)TrustListMasks.TrustedCertificates; + trustedCertificates.Add(CertificateFactory.Create(cert)); } - if ((masks & (int)TrustListMasks.TrustedCrls) != 0 && - UpdateStoreCrlsAsync(m_trustedStore, trustedCrls).GetAwaiter().GetResult()) + } + if ((masks & (int)TrustListMasks.TrustedCrls) != 0) + { + trustedCrls = []; + foreach (byte[] crl in trustList.TrustedCrls) { - updateMasks |= (int)TrustListMasks.TrustedCrls; + trustedCrls.Add(new X509CRL(crl)); } + } - if (masks != updateMasks) - { - result = StatusCodes.BadCertificateInvalid; - } + // update store + int updateMasks = (int)TrustListMasks.None; + if ((masks & (int)TrustListMasks.IssuerCertificates) != 0 && + await UpdateStoreCertificatesAsync(m_issuerStore, issuerCertificates, cancellationToken) + .ConfigureAwait(false)) + { + updateMasks |= (int)TrustListMasks.IssuerCertificates; } - catch + if ((masks & (int)TrustListMasks.IssuerCrls) != 0 && + await UpdateStoreCrlsAsync(m_issuerStore, issuerCrls, cancellationToken).ConfigureAwait(false)) + { + updateMasks |= (int)TrustListMasks.IssuerCrls; + } + if ((masks & (int)TrustListMasks.TrustedCertificates) != 0 && + await UpdateStoreCertificatesAsync(m_trustedStore, trustedCertificates, cancellationToken) + .ConfigureAwait(false)) + { + updateMasks |= (int)TrustListMasks.TrustedCertificates; + } + if ((masks & (int)TrustListMasks.TrustedCrls) != 0 && + await UpdateStoreCrlsAsync(m_trustedStore, trustedCrls, cancellationToken).ConfigureAwait(false)) + { + updateMasks |= (int)TrustListMasks.TrustedCrls; + } + + if (masks != updateMasks) { result = StatusCodes.BadCertificateInvalid; } - finally + } + catch + { + result = StatusCodes.BadCertificateInvalid; + } + finally + { + lock (m_lock) { m_sessionId = null; m_strm = null; @@ -488,8 +685,6 @@ private ServiceResult CloseAndUpdate( } } - restartRequired = false; - // report the TrustListUpdatedAuditEvent m_node.ReportTrustListUpdatedAuditEvent( context, @@ -500,7 +695,11 @@ private ServiceResult CloseAndUpdate( result.StatusCode, m_logger); - return result; + return new CloseAndUpdateMethodStateResult + { + ServiceResult = result, + ApplyChangesRequired = false + }; } private ServiceResult AddCertificate( @@ -509,6 +708,24 @@ private ServiceResult AddCertificate( NodeId objectId, byte[] certificate, bool isTrustedCertificate) + { + var result = AddCertificateAsync( + context, + method, + objectId, + certificate, + isTrustedCertificate, + CancellationToken.None).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + return result.ServiceResult; + } + + private async ValueTask AddCertificateAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + byte[] certificate, + bool isTrustedCertificate, + CancellationToken cancellationToken) { object[] inputParameters = [certificate, isTrustedCertificate]; m_node.ReportTrustListUpdateRequestedAuditEvent( @@ -521,40 +738,47 @@ private ServiceResult AddCertificate( HasSecureWriteAccess(context); ServiceResult result = StatusCodes.Good; + + bool isSessionOpen; lock (m_lock) { - if (m_sessionId != null) + isSessionOpen = m_sessionId != null; + } + + if (isSessionOpen) + { + result = StatusCodes.BadInvalidState; + } + else if (certificate == null) + { + result = StatusCodes.BadInvalidArgument; + } + else + { + X509Certificate2 cert = null; + try { - result = StatusCodes.BadInvalidState; + cert = CertificateFactory.Create(certificate); } - else if (certificate == null) + catch { - result = StatusCodes.BadInvalidArgument; + // note: a previous version of the sample code accepted also CRL, + // but the behaviour was not as specified and removed + // https://mantis.opcfoundation.org/view.php?id=6342 + result = StatusCodes.BadCertificateInvalid; } - else - { - X509Certificate2 cert = null; - try - { - cert = CertificateFactory.Create(certificate); - } - catch - { - // note: a previous version of the sample code accepted also CRL, - // but the behaviour was not as specified and removed - // https://mantis.opcfoundation.org/view.php?id=6342 - result = StatusCodes.BadCertificateInvalid; - } + if (cert != null) + { CertificateStoreIdentifier storeIdentifier = isTrustedCertificate ? m_trustedStore : m_issuerStore; ICertificateStore store = storeIdentifier.OpenStore(m_telemetry); try { - if (cert != null && store != null) + if (store != null) { - store.AddAsync(cert).GetAwaiter().GetResult(); + await store.AddAsync(cert, null, cancellationToken).ConfigureAwait(false); } } finally @@ -562,7 +786,10 @@ private ServiceResult AddCertificate( store?.Close(); } - m_node.LastUpdateTime.Value = DateTime.UtcNow; + lock (m_lock) + { + m_node.LastUpdateTime.Value = DateTime.UtcNow; + } } } @@ -576,7 +803,10 @@ private ServiceResult AddCertificate( result.StatusCode, m_logger); - return result; + return new AddCertificateMethodStateResult + { + ServiceResult = result + }; } private ServiceResult RemoveCertificate( @@ -586,7 +816,25 @@ private ServiceResult RemoveCertificate( string thumbprint, bool isTrustedCertificate) { - object[] inputParameters = [thumbprint]; + var result = RemoveCertificateAsync( + context, + method, + objectId, + thumbprint, + isTrustedCertificate, + CancellationToken.None).AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + return result.ServiceResult; + } + + private async ValueTask RemoveCertificateAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + string thumbprint, + bool isTrustedCertificate, + CancellationToken cancellationToken) + { + object[] inputParameters = [thumbprint, isTrustedCertificate]; m_node.ReportTrustListUpdateRequestedAuditEvent( context, objectId, @@ -597,79 +845,88 @@ private ServiceResult RemoveCertificate( HasSecureWriteAccess(context); ServiceResult result = StatusCodes.Good; + + bool isSessionOpen; lock (m_lock) { - if (m_sessionId != null) - { - result = StatusCodes.BadInvalidState; - } - else if (string.IsNullOrEmpty(thumbprint)) - { - result = StatusCodes.BadInvalidArgument; - } - else + isSessionOpen = m_sessionId != null; + } + + if (isSessionOpen) + { + result = StatusCodes.BadInvalidState; + } + else if (string.IsNullOrEmpty(thumbprint)) + { + result = StatusCodes.BadInvalidArgument; + } + else + { + CertificateStoreIdentifier storeIdentifier = isTrustedCertificate + ? m_trustedStore + : m_issuerStore; + using (ICertificateStore store = storeIdentifier.OpenStore(m_telemetry)) { - CertificateStoreIdentifier storeIdentifier = isTrustedCertificate - ? m_trustedStore - : m_issuerStore; - using (ICertificateStore store = storeIdentifier.OpenStore(m_telemetry)) + if (store == null) { - if (store == null) - { - throw new ServiceResultException( - StatusCodes.BadConfigurationError, - "Failed to open certificate store."); - } + throw new ServiceResultException( + StatusCodes.BadConfigurationError, + "Failed to open certificate store."); + } - X509Certificate2Collection certCollection = store - .FindByThumbprintAsync(thumbprint) - .GetAwaiter() - .GetResult(); + X509Certificate2Collection certCollection = await store + .FindByThumbprintAsync(thumbprint, cancellationToken) + .ConfigureAwait(false); - if (certCollection.Count == 0) - { - result = StatusCodes.BadInvalidArgument; - } - else + if (certCollection.Count == 0) + { + result = StatusCodes.BadInvalidArgument; + } + else + { + // delete all CRLs signed by cert + var crlsToDelete = new X509CRLCollection(); + X509CRLCollection crls = await store.EnumerateCRLsAsync(cancellationToken) + .ConfigureAwait(false); + foreach (X509CRL crl in crls) { - // delete all CRLs signed by cert - var crlsToDelete = new X509CRLCollection(); - foreach (X509CRL crl in store.EnumerateCRLsAsync().GetAwaiter() - .GetResult()) + foreach (X509Certificate2 cert in certCollection) { - foreach (X509Certificate2 cert in certCollection) + if (X509Utils.CompareDistinguishedName( + cert.SubjectName, + crl.IssuerName) && + crl.VerifySignature(cert, false)) { - if (X509Utils.CompareDistinguishedName( - cert.SubjectName, - crl.IssuerName) && - crl.VerifySignature(cert, false)) - { - crlsToDelete.Add(crl); - break; - } + crlsToDelete.Add(crl); + break; } } + } - if (!store.DeleteAsync(thumbprint).GetAwaiter().GetResult()) - { - result = StatusCodes.BadInvalidArgument; - } - else + if (!await store.DeleteAsync(thumbprint, cancellationToken) + .ConfigureAwait(false)) + { + result = StatusCodes.BadInvalidArgument; + } + else + { + foreach (X509CRL crl in crlsToDelete) { - foreach (X509CRL crl in crlsToDelete) + if (!await store.DeleteCRLAsync(crl, cancellationToken) + .ConfigureAwait(false)) { - if (!store.DeleteCRLAsync(crl).GetAwaiter().GetResult()) - { - // intentionally ignore errors, try best effort - m_logger.LogError( - "RemoveCertificate: Failed to delete CRL {Crl}.", - crl.ToString()); - } + // intentionally ignore errors, try best effort + m_logger.LogError( + "RemoveCertificate: Failed to delete CRL {Crl}.", + crl.ToString()); } } } } + } + lock (m_lock) + { m_node.LastUpdateTime.Value = DateTime.UtcNow; } } @@ -684,7 +941,10 @@ private ServiceResult RemoveCertificate( result.StatusCode, m_logger); - return result; + return new RemoveCertificateMethodStateResult + { + ServiceResult = result + }; } private static MemoryStream EncodeTrustListData( @@ -727,7 +987,8 @@ private static TrustListDataType DecodeTrustListData( private async Task UpdateStoreCrlsAsync( CertificateStoreIdentifier storeIdentifier, - X509CRLCollection updatedCrls) + X509CRLCollection updatedCrls, + CancellationToken cancellationToken = default) { bool result = true; try @@ -742,19 +1003,19 @@ private async Task UpdateStoreCrlsAsync( "Failed to open certificate store."); } - X509CRLCollection storeCrls = await store.EnumerateCRLsAsync() + X509CRLCollection storeCrls = await store.EnumerateCRLsAsync(cancellationToken) .ConfigureAwait(false); foreach (X509CRL crl in storeCrls) { if (!updatedCrls.Remove(crl) && - !await store.DeleteCRLAsync(crl).ConfigureAwait(false)) + !await store.DeleteCRLAsync(crl, cancellationToken).ConfigureAwait(false)) { result = false; } } foreach (X509CRL crl in updatedCrls) { - await store.AddCRLAsync(crl).ConfigureAwait(false); + await store.AddCRLAsync(crl, cancellationToken).ConfigureAwait(false); } } finally @@ -771,7 +1032,8 @@ private async Task UpdateStoreCrlsAsync( private async Task UpdateStoreCertificatesAsync( CertificateStoreIdentifier storeIdentifier, - X509Certificate2Collection updatedCerts) + X509Certificate2Collection updatedCerts, + CancellationToken cancellationToken = default) { bool result = true; try @@ -786,13 +1048,13 @@ private async Task UpdateStoreCertificatesAsync( "Failed to open certificate store."); } - X509Certificate2Collection storeCerts = await store.EnumerateAsync() + X509Certificate2Collection storeCerts = await store.EnumerateAsync(cancellationToken) .ConfigureAwait(false); foreach (X509Certificate2 cert in storeCerts) { if (!updatedCerts.Contains(cert)) { - if (!await store.DeleteAsync(cert.Thumbprint).ConfigureAwait(false)) + if (!await store.DeleteAsync(cert.Thumbprint, cancellationToken).ConfigureAwait(false)) { result = false; } @@ -804,7 +1066,7 @@ private async Task UpdateStoreCertificatesAsync( } foreach (X509Certificate2 cert in updatedCerts) { - await store.AddAsync(cert).ConfigureAwait(false); + await store.AddAsync(cert, null, cancellationToken).ConfigureAwait(false); } } finally From d511adc5078dd18c65c9e6d37db8544760b6909a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20K=C3=B6pke?= Date: Thu, 22 Jan 2026 08:39:42 +0100 Subject: [PATCH 36/42] Extend NodeSet Export (#3491) * Add per-namespace NodeSet2 export and CLI option Implemented ExportNodesToNodeSet2PerNamespaceAsync in ClientSamples to export nodes into separate NodeSet2 XML files per namespace, excluding OPC Foundation companion specs. Added a helper for safe filename generation. Introduced a new -e/--export command-line option in Program.cs to trigger this export after node fetching. Also added System.Xml.Linq import for potential XML handling. * Add NodeSetExportOptions for flexible NodeSet2 export Introduce NodeSetExportOptions to control which attributes and values are exported to NodeSet2 XML, with Default, Minimal, and Complete presets. Overload ExportNodesToNodeSet2 to accept these options and update internal logic accordingly. Update CreateNodeState to honor export preferences. Add comprehensive unit tests for all export modes and ensure backward compatibility. Update ClientSamples to use Complete export mode. * Refactor NodeSet export options for minimal default output - Change ExportValues default to false; values only for Complete option - Remove ExportUserAccessLevel and ExportMethodDeclarationId options - Always export MethodDeclarationId; export UserAccessLevel only if different from AccessLevel - Remove Minimal option; Default now produces minimal schema output - Update export logic and tests to reflect new option semantics - Rename and revise unit test to verify new default and complete behaviors * Remove unused BitVector32 import * Add ExportUserContext option to NodeSet2 export Introduce ExportUserContext to NodeSetExportOptions, allowing selective export of user context attributes (UserAccessLevel, UserExecutable, UserWriteMask, UserRolePermissions) in NodeSet2 files. Update export logic to honor this option and add unit tests to verify correct behavior. This enhances flexibility and control over exported user-specific metadata. --------- Co-authored-by: Marc Schier --- .../ConsoleReferenceClient/ClientSamples.cs | 140 +++++++ .../ConsoleReferenceClient/Program.cs | 20 + .../CoreClientUtils.NodeSetExport.cs | 177 +++++++- .../Opc.Ua.Client.Tests/NodeSetExportTest.cs | 385 ++++++++++++++++++ 4 files changed, 705 insertions(+), 17 deletions(-) diff --git a/Applications/ConsoleReferenceClient/ClientSamples.cs b/Applications/ConsoleReferenceClient/ClientSamples.cs index 6f85b169f..5ec8ff807 100644 --- a/Applications/ConsoleReferenceClient/ClientSamples.cs +++ b/Applications/ConsoleReferenceClient/ClientSamples.cs @@ -1617,6 +1617,146 @@ public void ExportNodesToNodeSet2(ISession session, IList nodes, string f stopwatch.ElapsedMilliseconds); } + + /// + /// Exports nodes to separate NodeSet2 XML files, one per namespace. + /// Excludes OPC Foundation companion specifications (namespaces starting with http://opcfoundation.org/UA/). + /// + /// The session to use for exporting. + /// The list of nodes to export. + /// The directory where NodeSet2 XML files will be saved. + /// Optional cancellation token. + /// A dictionary mapping namespace URI to the file path of the exported NodeSet2 file. + /// Thrown when session, nodes, or outputDirectory is null. + /// Thrown when outputDirectory is empty or whitespace. + public async Task> ExportNodesToNodeSet2PerNamespaceAsync( + ISession session, + IList nodes, + string outputDirectory, + CancellationToken cancellationToken = default) + { + if (session == null) + { + throw new ArgumentNullException(nameof(session)); + } + if (nodes == null) + { + throw new ArgumentNullException(nameof(nodes)); + } + if (string.IsNullOrWhiteSpace(outputDirectory)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(outputDirectory)); + } + + m_logger.LogInformation( + "Exporting {Count} nodes to separate NodeSet2 files per namespace in {Directory}...", + nodes.Count, + outputDirectory); + + var stopwatch = Stopwatch.StartNew(); + + // Ensure output directory exists + Directory.CreateDirectory(outputDirectory); + + // Group nodes by namespace, excluding OPC Foundation companion specs + var nodesByNamespace = nodes + .Where(node => node.NodeId.NamespaceIndex > 0) // Skip namespace 0 (OPC UA base) + .GroupBy(node => node.NodeId.NamespaceIndex) + .Where(group => + { + var namespaceUri = session.NamespaceUris.GetString(group.Key); + // Exclude OPC Foundation companion specifications + return !string.IsNullOrEmpty(namespaceUri) && + !namespaceUri.StartsWith("http://opcfoundation.org/UA/", StringComparison.OrdinalIgnoreCase); + }) + .ToDictionary( + group => group.Key, + group => group.ToList()); + + var exportedFiles = new Dictionary(); + + // Export each namespace to its own file + foreach (var kvp in nodesByNamespace) + { + cancellationToken.ThrowIfCancellationRequested(); + + var namespaceUri = session.NamespaceUris.GetString(kvp.Key); + + // Create a safe filename from the namespace URI + + var fileName = CreateSafeFileName(namespaceUri, kvp.Key); + var filePath = Path.Combine(outputDirectory, fileName); + + m_logger.LogInformation( + "Exporting namespace {NamespaceIndex} ({NamespaceUri}): {Count} nodes to {FilePath}", + kvp.Key, + namespaceUri, + kvp.Value.Count, + filePath); + + await Task.Run(() => + { + using var outputStream = new FileStream(filePath, FileMode.Create); + var systemContext = new SystemContext(m_telemetry) + { + NamespaceUris = session.NamespaceUris, + ServerUris = session.ServerUris + }; + + CoreClientUtils.ExportNodesToNodeSet2(systemContext, kvp.Value, outputStream, NodeSetExportOptions.Complete); + }, cancellationToken).ConfigureAwait(false); + + exportedFiles[namespaceUri] = filePath; + } + + stopwatch.Stop(); + + m_logger.LogInformation( + "Exported {NamespaceCount} namespaces ({NodeCount} total nodes) in {Duration}ms", + exportedFiles.Count, + nodes.Count, + stopwatch.ElapsedMilliseconds); + + return exportedFiles; + } + + /// + /// Creates a safe filename from a namespace URI. + /// + /// The namespace URI. + /// The namespace index (used as fallback). + /// A safe filename for the NodeSet2 export. + private static string CreateSafeFileName(string namespaceUri, ushort namespaceIndex) + { + // Extract meaningful part from URI + var fileName = namespaceUri + .Replace("http://", "") + .Replace("https://", "") + .Replace("urn:", ""); + + // Replace invalid filename characters + var invalidChars = Path.GetInvalidFileNameChars(); + foreach (var c in invalidChars) + { + fileName = fileName.Replace(c, '_'); + } + + // Additional cleanup for common URI characters + fileName = fileName + .Replace('/', '_') + .Replace('\\', '_') + .Replace(':', '_') + .TrimEnd('_'); + + // Limit length and ensure uniqueness with namespace index + if (fileName.Length > 200) + { + fileName = fileName[..200]; + } + + return $"{fileName}_ns{namespaceIndex}.xml"; + } + /// /// Create the continuation point collection from the browse result /// collection for the BrowseNext service. diff --git a/Applications/ConsoleReferenceClient/Program.cs b/Applications/ConsoleReferenceClient/Program.cs index 74faa9178..805aa622c 100644 --- a/Applications/ConsoleReferenceClient/Program.cs +++ b/Applications/ConsoleReferenceClient/Program.cs @@ -36,6 +36,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Xml.Linq; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Client; @@ -94,6 +95,7 @@ public static async Task Main(string[] args) bool leakChannels = false; bool forever = false; bool enableDurableSubscriptions = false; + bool exportNodes = false; var options = new Mono.Options.OptionSet { @@ -183,6 +185,17 @@ public static async Task Main(string[] args) } } }, + { + "e|export", + "Export all fetched nodes into Nodeset2 xml per default", + e => + { + if (e != null) + { + exportNodes = true; + } + } + }, { "fa|fetchall", "Fetch all nodes", @@ -560,6 +573,13 @@ r is VariableNode v && r.NodeId, uaClient.Session.NamespaceUris)) ]; + + if (exportNodes) + { + await samples + .ExportNodesToNodeSet2PerNamespaceAsync(uaClient.Session, allNodes, Environment.CurrentDirectory) + .ConfigureAwait(false); + } } if (jsonvalues && variableIds != null) diff --git a/Libraries/Opc.Ua.Client/CoreClientUtils.NodeSetExport.cs b/Libraries/Opc.Ua.Client/CoreClientUtils.NodeSetExport.cs index 4b88ebacb..796f5cefb 100644 --- a/Libraries/Opc.Ua.Client/CoreClientUtils.NodeSetExport.cs +++ b/Libraries/Opc.Ua.Client/CoreClientUtils.NodeSetExport.cs @@ -34,6 +34,55 @@ namespace Opc.Ua.Client { + /// + /// Options for controlling NodeSet export behavior. + /// + public class NodeSetExportOptions + { + /// + /// Whether to export value elements for variables. + /// Default is false (values are only exported for Complete option). + /// + public bool ExportValues { get; set; } = false; + + /// + /// Whether to export the ParentNodeId attribute. + /// Default is false (ParentNodeId is redundant as it can be inferred from references). + /// + public bool ExportParentNodeId { get; set; } = false; + + /// + /// Whether to export user context attributes (UserAccessLevel, UserExecutable, UserWriteMask, UserRolePermissions). + /// Default is false (user context attributes are not exported). + /// When true, UserAccessLevel is only exported if it differs from AccessLevel. + /// + public bool ExportUserContext { get; set; } = false; + + /// + /// Gets the default export options (no values, essential attributes only). + /// This produces minimal file size suitable for schema definitions. + /// MethodDeclarationId is always exported. User context attributes are not exported. + /// + public static NodeSetExportOptions Default => new NodeSetExportOptions + { + ExportValues = false, + ExportParentNodeId = false, + ExportUserContext = false + }; + + /// + /// Gets complete export options with all metadata and values. + /// Use this for full data export including runtime values and user context attributes. + /// MethodDeclarationId is always exported. UserAccessLevel is only exported when different from AccessLevel. + /// + public static NodeSetExportOptions Complete => new NodeSetExportOptions + { + ExportValues = true, + ExportParentNodeId = true, + ExportUserContext = true + }; + } + /// /// Defines numerous re-useable utility functions for clients. /// @@ -49,6 +98,22 @@ public static void ExportNodesToNodeSet2( ISystemContext context, IList nodes, Stream outputStream) + { + ExportNodesToNodeSet2(context, nodes, outputStream, NodeSetExportOptions.Default); + } + + /// + /// Exports a list of nodes from a client session to a NodeSet2 XML file. + /// + /// The system context containing namespace information. + /// The list of nodes to export. + /// The output stream to write the NodeSet2 XML to. + /// Options controlling the export behavior. + public static void ExportNodesToNodeSet2( + ISystemContext context, + IList nodes, + Stream outputStream, + NodeSetExportOptions options) { if (context == null) { @@ -65,11 +130,16 @@ public static void ExportNodesToNodeSet2( throw new ArgumentNullException(nameof(outputStream)); } + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + // Convert INode instances to NodeState instances var nodeStates = new NodeStateCollection(); foreach (INode node in nodes) { - NodeState? nodeState = CreateNodeState(context, node); + NodeState? nodeState = CreateNodeState(context, node, options); if (nodeState != null) { nodeStates.Add(nodeState); @@ -85,8 +155,9 @@ public static void ExportNodesToNodeSet2( /// /// The system context. /// The node to convert. + /// Export options. /// A NodeState representing the node. - private static NodeState? CreateNodeState(ISystemContext context, INode node) + private static NodeState? CreateNodeState(ISystemContext context, INode node, NodeSetExportOptions options) { if (node == null) { @@ -112,7 +183,12 @@ public static void ExportNodesToNodeSet2( { state.Description = localNode.Description; state.WriteMask = (AttributeWriteMask)localNode.WriteMask; - state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + + // Export UserWriteMask only if ExportUserContext is enabled + if (options.ExportUserContext) + { + state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + } } nodeState = state; @@ -130,12 +206,28 @@ public static void ExportNodesToNodeSet2( DataType = variableNode?.DataType ?? DataTypeIds.BaseDataType, ValueRank = variableNode?.ValueRank ?? ValueRanks.Any, AccessLevel = variableNode?.AccessLevel ?? 0, - UserAccessLevel = variableNode?.UserAccessLevel ?? 0, MinimumSamplingInterval = variableNode?.MinimumSamplingInterval ?? 0, - Historizing = variableNode?.Historizing ?? false, - Value = variableNode?.Value + Historizing = variableNode?.Historizing ?? false }; + // Export Value only if requested + if (options.ExportValues && variableNode?.Value != null) + { + state.Value = variableNode.Value; + } + + // Export UserAccessLevel only if ExportUserContext is enabled AND it differs from AccessLevel + if (options.ExportUserContext && variableNode != null) + { + byte userAccessLevel = variableNode.UserAccessLevel; + byte accessLevel = variableNode.AccessLevel; + + if (userAccessLevel != accessLevel) + { + state.UserAccessLevel = userAccessLevel; + } + } + if (variableNode?.ArrayDimensions != null && variableNode.ArrayDimensions.Count > 0) { state.ArrayDimensions = new ReadOnlyList(variableNode.ArrayDimensions); @@ -145,7 +237,12 @@ public static void ExportNodesToNodeSet2( { state.Description = localNode.Description; state.WriteMask = (AttributeWriteMask)localNode.WriteMask; - state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + + // Export UserWriteMask only if ExportUserContext is enabled + if (options.ExportUserContext) + { + state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + } } nodeState = state; @@ -160,15 +257,31 @@ public static void ExportNodesToNodeSet2( NodeId = ExpandedNodeId.ToNodeId(node.NodeId, context.NamespaceUris), BrowseName = node.BrowseName, DisplayName = node.DisplayName, - Executable = methodNode?.Executable ?? false, - UserExecutable = methodNode?.UserExecutable ?? false + Executable = methodNode?.Executable ?? false }; + // Export UserExecutable only if ExportUserContext is enabled + if (options.ExportUserContext && methodNode != null) + { + state.UserExecutable = methodNode.UserExecutable; + } + + // Always export MethodDeclarationId (important type system metadata) + if (node.TypeDefinitionId != null && !NodeId.IsNull(node.TypeDefinitionId)) + { + state.MethodDeclarationId = ExpandedNodeId.ToNodeId(node.TypeDefinitionId, context.NamespaceUris); + } + if (node is ILocalNode localNode) { state.Description = localNode.Description; state.WriteMask = (AttributeWriteMask)localNode.WriteMask; - state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + + // Export UserWriteMask only if ExportUserContext is enabled + if (options.ExportUserContext) + { + state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + } } nodeState = state; @@ -190,7 +303,12 @@ public static void ExportNodesToNodeSet2( { state.Description = localNode.Description; state.WriteMask = (AttributeWriteMask)localNode.WriteMask; - state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + + // Export UserWriteMask only if ExportUserContext is enabled + if (options.ExportUserContext) + { + state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + } } nodeState = state; @@ -207,10 +325,15 @@ public static void ExportNodesToNodeSet2( DisplayName = node.DisplayName, IsAbstract = variableTypeNode?.IsAbstract ?? false, DataType = variableTypeNode?.DataType ?? DataTypeIds.BaseDataType, - ValueRank = variableTypeNode?.ValueRank ?? ValueRanks.Any, - Value = variableTypeNode?.Value + ValueRank = variableTypeNode?.ValueRank ?? ValueRanks.Any }; + // Export Value only if requested + if (options.ExportValues && variableTypeNode?.Value != null) + { + state.Value = variableTypeNode.Value; + } + if (variableTypeNode?.ArrayDimensions != null && variableTypeNode.ArrayDimensions.Count > 0) { state.ArrayDimensions = new ReadOnlyList(variableTypeNode.ArrayDimensions); @@ -220,7 +343,12 @@ public static void ExportNodesToNodeSet2( { state.Description = localNode.Description; state.WriteMask = (AttributeWriteMask)localNode.WriteMask; - state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + + // Export UserWriteMask only if ExportUserContext is enabled + if (options.ExportUserContext) + { + state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + } } nodeState = state; @@ -242,7 +370,12 @@ public static void ExportNodesToNodeSet2( { state.Description = localNode.Description; state.WriteMask = (AttributeWriteMask)localNode.WriteMask; - state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + + // Export UserWriteMask only if ExportUserContext is enabled + if (options.ExportUserContext) + { + state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + } } nodeState = state; @@ -266,7 +399,12 @@ public static void ExportNodesToNodeSet2( { state.Description = localNode.Description; state.WriteMask = (AttributeWriteMask)localNode.WriteMask; - state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + + // Export UserWriteMask only if ExportUserContext is enabled + if (options.ExportUserContext) + { + state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + } } nodeState = state; @@ -289,7 +427,12 @@ public static void ExportNodesToNodeSet2( { state.Description = localNode.Description; state.WriteMask = (AttributeWriteMask)localNode.WriteMask; - state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + + // Export UserWriteMask only if ExportUserContext is enabled + if (options.ExportUserContext) + { + state.UserWriteMask = (AttributeWriteMask)localNode.UserWriteMask; + } } nodeState = state; diff --git a/Tests/Opc.Ua.Client.Tests/NodeSetExportTest.cs b/Tests/Opc.Ua.Client.Tests/NodeSetExportTest.cs index 69df2f04c..79d196d80 100644 --- a/Tests/Opc.Ua.Client.Tests/NodeSetExportTest.cs +++ b/Tests/Opc.Ua.Client.Tests/NodeSetExportTest.cs @@ -311,5 +311,390 @@ public async Task ExportAndReimportNodes() } } } + + /// + /// Test exporting nodes with default options (no values). + /// + [Test] + public async Task ExportNodesToNodeSet2_DefaultOptions() + { + var allNodes = new List(); + + // Get variable node that has a value + INode serverStatusNode = await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false); + if (serverStatusNode != null) + { + allNodes.Add(serverStatusNode); + } + + // Get another variable + INode stateNode = await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus_State).ConfigureAwait(false); + if (stateNode != null) + { + allNodes.Add(stateNode); + } + + Assert.Greater(allNodes.Count, 0, "Should have found at least one node"); + + // Export with default options + string tempFile = Path.GetTempFileName(); + try + { + using (var stream = new FileStream(tempFile, FileMode.Create)) + { + var systemContext = new SystemContext(Telemetry) + { + NamespaceUris = Session.NamespaceUris, + ServerUris = Session.ServerUris + }; + + CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Default); + } + + // Read it back and verify values are not exported + using (var stream = new FileStream(tempFile, FileMode.Open)) + { + var nodeSet = UANodeSet.Read(stream); + Assert.IsNotNull(nodeSet, "Should be able to read the exported NodeSet2"); + Assert.IsNotNull(nodeSet.Items, "NodeSet2 should contain items"); + + // Check that variables don't have values + var variables = nodeSet.Items.OfType().ToList(); + foreach (var variable in variables) + { + Assert.IsNull(variable.Value, "Value should not be exported with Default options"); + } + } + + // Verify default file is smaller or equal to complete + FileInfo defaultFile = new FileInfo(tempFile); + long defaultSize = defaultFile.Length; + + // Export with complete options + string tempFileComplete = Path.GetTempFileName(); + try + { + using (var stream = new FileStream(tempFileComplete, FileMode.Create)) + { + var systemContext = new SystemContext(Telemetry) + { + NamespaceUris = Session.NamespaceUris, + ServerUris = Session.ServerUris + }; + + CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Complete); + } + + FileInfo completeFile = new FileInfo(tempFileComplete); + long completeSize = completeFile.Length; + + // Default should be smaller or equal to complete + // (Equal if nodes don't have values to export) + Assert.LessOrEqual(defaultSize, completeSize, "Default export should not be larger than Complete"); + } + finally + { + if (File.Exists(tempFileComplete)) + { + File.Delete(tempFileComplete); + } + } + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// Test exporting nodes with complete options (all metadata). + /// + [Test] + public async Task ExportNodesToNodeSet2_CompleteOptions() + { + var allNodes = new List(); + + // Get variable node + INode serverStatusNode = await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false); + if (serverStatusNode != null) + { + allNodes.Add(serverStatusNode); + } + + Assert.Greater(allNodes.Count, 0, "Should have found at least one node"); + + // Export with complete options + string tempFile = Path.GetTempFileName(); + try + { + using (var stream = new FileStream(tempFile, FileMode.Create)) + { + var systemContext = new SystemContext(Telemetry) + { + NamespaceUris = Session.NamespaceUris, + ServerUris = Session.ServerUris + }; + + CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Complete); + } + + // Read it back + using (var stream = new FileStream(tempFile, FileMode.Open)) + { + var nodeSet = UANodeSet.Read(stream); + Assert.IsNotNull(nodeSet, "Should be able to read the exported NodeSet2"); + Assert.IsNotNull(nodeSet.Items, "NodeSet2 should contain items"); + } + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// Test exporting nodes with custom options. + /// + [Test] + public async Task ExportNodesToNodeSet2_CustomOptions() + { + var allNodes = new List(); + + // Get variable node + INode serverStatusNode = await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false); + if (serverStatusNode != null) + { + allNodes.Add(serverStatusNode); + } + + Assert.Greater(allNodes.Count, 0, "Should have found at least one node"); + + // Export with custom options - values exported + var customOptions = new NodeSetExportOptions + { + ExportValues = true, + ExportParentNodeId = false + }; + + string tempFile = Path.GetTempFileName(); + try + { + using (var stream = new FileStream(tempFile, FileMode.Create)) + { + var systemContext = new SystemContext(Telemetry) + { + NamespaceUris = Session.NamespaceUris, + ServerUris = Session.ServerUris + }; + + CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, customOptions); + } + + // Verify the file was created and has content + FileInfo fileInfo = new FileInfo(tempFile); + Assert.IsTrue(fileInfo.Exists, "NodeSet2 file should exist"); + Assert.Greater(fileInfo.Length, 0, "NodeSet2 file should not be empty"); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// Test that default export options preserve backward compatibility. + /// + [Test] + public async Task ExportNodesToNodeSet2_BackwardCompatibility() + { + var allNodes = new List(); + + // Get some nodes + INode serverNode = await Session.NodeCache.FindAsync(ObjectIds.Server).ConfigureAwait(false); + if (serverNode != null) + { + allNodes.Add(serverNode); + } + + Assert.Greater(allNodes.Count, 0, "Should have found at least one node"); + + string tempFile1 = Path.GetTempFileName(); + string tempFile2 = Path.GetTempFileName(); + + try + { + var systemContext = new SystemContext(Telemetry) + { + NamespaceUris = Session.NamespaceUris, + ServerUris = Session.ServerUris + }; + + // Export without options (backward compatibility) + using (var stream = new FileStream(tempFile1, FileMode.Create)) + { + CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream); + } + + // Export with default options + using (var stream = new FileStream(tempFile2, FileMode.Create)) + { + CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, NodeSetExportOptions.Default); + } + + // Both should be readable + using (var stream = new FileStream(tempFile1, FileMode.Open)) + { + var nodeSet = UANodeSet.Read(stream); + Assert.IsNotNull(nodeSet, "Should be able to read backward compatible export"); + } + + using (var stream = new FileStream(tempFile2, FileMode.Open)) + { + var nodeSet = UANodeSet.Read(stream); + Assert.IsNotNull(nodeSet, "Should be able to read default options export"); + } + } + finally + { + if (File.Exists(tempFile1)) + { + File.Delete(tempFile1); + } + if (File.Exists(tempFile2)) + { + File.Delete(tempFile2); + } + } + } + + /// + /// Test exporting with user context option. + /// + [Test] + public async Task ExportNodesToNodeSet2_UserContextOptions() + { + var allNodes = new List(); + + // Get method node that has UserExecutable + INode getMonitoredItemsNode = await Session.NodeCache.FindAsync(MethodIds.Server_GetMonitoredItems).ConfigureAwait(false); + if (getMonitoredItemsNode != null) + { + allNodes.Add(getMonitoredItemsNode); + } + + // Get variable node that has UserAccessLevel + INode serverStatusNode = await Session.NodeCache.FindAsync(VariableIds.Server_ServerStatus).ConfigureAwait(false); + if (serverStatusNode != null) + { + allNodes.Add(serverStatusNode); + } + + Assert.Greater(allNodes.Count, 0, "Should have found at least one node"); + + // Export WITHOUT user context + string tempFileNoContext = Path.GetTempFileName(); + try + { + using (var stream = new FileStream(tempFileNoContext, FileMode.Create)) + { + var systemContext = new SystemContext(Telemetry) + { + NamespaceUris = Session.NamespaceUris, + ServerUris = Session.ServerUris + }; + + var optionsNoContext = new NodeSetExportOptions + { + ExportValues = false, + ExportParentNodeId = false, + ExportUserContext = false + }; + + CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, optionsNoContext); + } + + // Export WITH user context + string tempFileWithContext = Path.GetTempFileName(); + try + { + using (var stream = new FileStream(tempFileWithContext, FileMode.Create)) + { + var systemContext = new SystemContext(Telemetry) + { + NamespaceUris = Session.NamespaceUris, + ServerUris = Session.ServerUris + }; + + var optionsWithContext = new NodeSetExportOptions + { + ExportValues = false, + ExportParentNodeId = false, + ExportUserContext = true + }; + + CoreClientUtils.ExportNodesToNodeSet2(systemContext, allNodes, stream, optionsWithContext); + } + + // Read both files and compare + Export.UANodeSet nodeSetNoContext; + using (var stream = new FileStream(tempFileNoContext, FileMode.Open)) + { + nodeSetNoContext = UANodeSet.Read(stream); + Assert.IsNotNull(nodeSetNoContext, "Should be able to read NodeSet without user context"); + } + + Export.UANodeSet nodeSetWithContext; + using (var stream = new FileStream(tempFileWithContext, FileMode.Open)) + { + nodeSetWithContext = UANodeSet.Read(stream); + Assert.IsNotNull(nodeSetWithContext, "Should be able to read NodeSet with user context"); + } + + // Verify that methods in the context version have UserExecutable + var methodsNoContext = nodeSetNoContext.Items?.OfType().ToList() ?? new List(); + var methodsWithContext = nodeSetWithContext.Items?.OfType().ToList() ?? new List(); + + Assert.AreEqual(methodsNoContext.Count, methodsWithContext.Count, "Should have same number of methods"); + + // Variables with context should potentially have UserAccessLevel if different from AccessLevel + var variablesNoContext = nodeSetNoContext.Items?.OfType().ToList() ?? new List(); + var variablesWithContext = nodeSetWithContext.Items?.OfType().ToList() ?? new List(); + + Assert.AreEqual(variablesNoContext.Count, variablesWithContext.Count, "Should have same number of variables"); + + // File with user context should be larger or equal + FileInfo fileNoContext = new FileInfo(tempFileNoContext); + FileInfo fileWithContext = new FileInfo(tempFileWithContext); + + // Note: Sizes might be equal if UserAccessLevel == AccessLevel for all variables + // The important part is that export completes successfully with both options + Assert.IsTrue(fileWithContext.Length > 0, "Export with user context should produce a valid file"); + Assert.IsTrue(fileNoContext.Length > 0, "Export without user context should produce a valid file"); + } + finally + { + if (File.Exists(tempFileWithContext)) + { + File.Delete(tempFileWithContext); + } + } + } + finally + { + if (File.Exists(tempFileNoContext)) + { + File.Delete(tempFileNoContext); + } + } + } } } From 9f335c39d8c0d1597b930a912d9e83554e01dbef Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 05:42:59 +0100 Subject: [PATCH 37/42] Change DebugCheck conditional compilation from DEBUG to CHECKED (#3496) * Initial plan * Change [Conditional("DEBUG")] to [Conditional("CHECKED")] in DebugCheck methods Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> --- Stack/Opc.Ua.Types/Diagnostics/TelemetryExtensions.cs | 2 +- Stack/Opc.Ua.Types/Diagnostics/TelemetryUtils.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Stack/Opc.Ua.Types/Diagnostics/TelemetryExtensions.cs b/Stack/Opc.Ua.Types/Diagnostics/TelemetryExtensions.cs index 429d3e5ff..06c3fb367 100644 --- a/Stack/Opc.Ua.Types/Diagnostics/TelemetryExtensions.cs +++ b/Stack/Opc.Ua.Types/Diagnostics/TelemetryExtensions.cs @@ -149,7 +149,7 @@ public static Func InternalOnly__TelemetryHook /// us to weed out areas that need telemetry plumbed through. /// /// The telemetry context to use - [Conditional("DEBUG")] + [Conditional("CHECKED")] private static void DebugCheck(ITelemetryContext? telemetry) { DebugLog.Instance.CollectIf(telemetry == null); diff --git a/Stack/Opc.Ua.Types/Diagnostics/TelemetryUtils.cs b/Stack/Opc.Ua.Types/Diagnostics/TelemetryUtils.cs index 771881e54..905cbd9bd 100644 --- a/Stack/Opc.Ua.Types/Diagnostics/TelemetryUtils.cs +++ b/Stack/Opc.Ua.Types/Diagnostics/TelemetryUtils.cs @@ -138,7 +138,7 @@ public void Dispose() } } - [Conditional("DEBUG")] + [Conditional("CHECKED")] private static void DebugCheck() { Debug.Fail("Using a NullLogger"); From 8966e1d3589a85a41fad811d8955717024bb8735 Mon Sep 17 00:00:00 2001 From: romanett Date: Sun, 25 Jan 2026 19:12:32 +0100 Subject: [PATCH 38/42] [Server] Use Classes instead of Interfaces for NodeManagers to ensure compatibility for 1.5.378 (#3497) * Point to concrete classes instead of interfaces * cleanup --- .../ConsoleReferenceClient/ClientSamples.cs | 28 +++++------ .../ReferenceServer/ReferenceServer.cs | 4 +- .../GlobalDiscoverySampleServer.cs | 2 +- .../Transport/MqttPubSubConnection.cs | 4 +- .../Configuration/ConfigurationNodeManager.cs | 2 +- .../NodeManager/IMasterNodeManager.cs | 8 ++-- .../NodeManager/MasterNodeManager.cs | 12 ++--- .../Opc.Ua.Server/Server/IServerInternal.cs | 10 ++-- .../Server/ServerInternalData.cs | 10 ++-- .../Opc.Ua.Server/Server/StandardServer.cs | 15 +++--- .../ReferenceServerWithLimits.cs | 2 +- .../Pkcs10CertificationRequestTests.cs | 48 +++++++++---------- .../ReferenceServerTest.cs | 15 +++--- 13 files changed, 78 insertions(+), 82 deletions(-) diff --git a/Applications/ConsoleReferenceClient/ClientSamples.cs b/Applications/ConsoleReferenceClient/ClientSamples.cs index 5ec8ff807..398116f05 100644 --- a/Applications/ConsoleReferenceClient/ClientSamples.cs +++ b/Applications/ConsoleReferenceClient/ClientSamples.cs @@ -1617,7 +1617,6 @@ public void ExportNodesToNodeSet2(ISession session, IList nodes, string f stopwatch.ElapsedMilliseconds); } - /// /// Exports nodes to separate NodeSet2 XML files, one per namespace. /// Excludes OPC Foundation companion specifications (namespaces starting with http://opcfoundation.org/UA/). @@ -1664,10 +1663,10 @@ public async Task> ExportNodesToNodeSet2PerN .GroupBy(node => node.NodeId.NamespaceIndex) .Where(group => { - var namespaceUri = session.NamespaceUris.GetString(group.Key); + string namespaceUri = session.NamespaceUris.GetString(group.Key); // Exclude OPC Foundation companion specifications return !string.IsNullOrEmpty(namespaceUri) && - !namespaceUri.StartsWith("http://opcfoundation.org/UA/", StringComparison.OrdinalIgnoreCase); + !namespaceUri.StartsWith("http://opcfoundation.org/UA/", StringComparison.OrdinalIgnoreCase); }) .ToDictionary( group => group.Key, @@ -1676,16 +1675,16 @@ public async Task> ExportNodesToNodeSet2PerN var exportedFiles = new Dictionary(); // Export each namespace to its own file - foreach (var kvp in nodesByNamespace) + foreach (KeyValuePair> kvp in nodesByNamespace) { cancellationToken.ThrowIfCancellationRequested(); - var namespaceUri = session.NamespaceUris.GetString(kvp.Key); - + string namespaceUri = session.NamespaceUris.GetString(kvp.Key); + // Create a safe filename from the namespace URI - - var fileName = CreateSafeFileName(namespaceUri, kvp.Key); - var filePath = Path.Combine(outputDirectory, fileName); + + string fileName = CreateSafeFileName(namespaceUri, kvp.Key); + string filePath = Path.Combine(outputDirectory, fileName); m_logger.LogInformation( "Exporting namespace {NamespaceIndex} ({NamespaceUri}): {Count} nodes to {FilePath}", @@ -1729,14 +1728,13 @@ await Task.Run(() => private static string CreateSafeFileName(string namespaceUri, ushort namespaceIndex) { // Extract meaningful part from URI - var fileName = namespaceUri - .Replace("http://", "") - .Replace("https://", "") - .Replace("urn:", ""); + string fileName = namespaceUri + .Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("urn:", string.Empty, StringComparison.OrdinalIgnoreCase); // Replace invalid filename characters - var invalidChars = Path.GetInvalidFileNameChars(); - foreach (var c in invalidChars) + foreach (char c in Path.GetInvalidFileNameChars()) { fileName = fileName.Replace(c, '_'); } diff --git a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs index 248f2d6f0..f764e476c 100644 --- a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs +++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs @@ -75,7 +75,7 @@ public class ReferenceServer : ReverseConnectServer /// always creates a CoreNodeManager which handles the built-in nodes defined by the specification. /// Any additional NodeManagers are expected to handle application specific nodes. /// - protected override IMasterNodeManager CreateMasterNodeManager( + protected override MasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) { @@ -332,7 +332,7 @@ private void SessionManager_ImpersonateUser(ISession session, ImpersonateEventAr m_logger.LogInformation( Utils.TraceMasks.Security, "X509 Token Accepted: {Identity}", - args.Identity?.DisplayName); + args.Identity.DisplayName); return; } diff --git a/Libraries/Opc.Ua.Gds.Server.Common/GlobalDiscoverySampleServer.cs b/Libraries/Opc.Ua.Gds.Server.Common/GlobalDiscoverySampleServer.cs index 493eb0361..e0ea9d9ae 100644 --- a/Libraries/Opc.Ua.Gds.Server.Common/GlobalDiscoverySampleServer.cs +++ b/Libraries/Opc.Ua.Gds.Server.Common/GlobalDiscoverySampleServer.cs @@ -91,7 +91,7 @@ protected override void OnServerStarted(IServerInternal server) /// always creates a CoreNodeManager which handles the built-in nodes defined by the specification. /// Any additional NodeManagers are expected to handle application specific nodes. /// - protected override IMasterNodeManager CreateMasterNodeManager( + protected override MasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) { diff --git a/Libraries/Opc.Ua.PubSub/Transport/MqttPubSubConnection.cs b/Libraries/Opc.Ua.PubSub/Transport/MqttPubSubConnection.cs index 24875ee1c..a20d8b081 100644 --- a/Libraries/Opc.Ua.PubSub/Transport/MqttPubSubConnection.cs +++ b/Libraries/Opc.Ua.PubSub/Transport/MqttPubSubConnection.cs @@ -257,13 +257,13 @@ public override async Task PublishNetworkMessageAsync(UaNetworkMessage net try { - IMqttClient publisherClient; + MqttClient publisherClient; lock (Lock) { publisherClient = m_publisherMqttClient; } - if (publisherClient != null && publisherClient.IsConnected) + if (publisherClient.IsConnected) { // get the encoded bytes byte[] bytes = networkMessage.Encode(MessageContext); diff --git a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs index f21e37e87..a020e8a0f 100644 --- a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs +++ b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs @@ -393,7 +393,7 @@ public void HasApplicationSecureAdminAccess(ISystemContext context) /// public void HasApplicationSecureAdminAccess( ISystemContext context, - CertificateStoreIdentifier _) + CertificateStoreIdentifier trustedStore) { if (context is SessionSystemContext { OperationContext: OperationContext operationContext }) { diff --git a/Libraries/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs index 8543d45c2..9c10d8c26 100644 --- a/Libraries/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs +++ b/Libraries/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs @@ -47,17 +47,17 @@ public interface IMasterNodeManager /// /// Returns the configuration node manager. /// - IConfigurationNodeManager ConfigurationNodeManager { get; } + ConfigurationNodeManager ConfigurationNodeManager { get; } /// /// Returns the core node manager. /// - ICoreNodeManager CoreNodeManager { get; } + CoreNodeManager CoreNodeManager { get; } /// /// Returns the diagnostics node manager. /// - IDiagnosticsNodeManager DiagnosticsNodeManager { get; } + DiagnosticsNodeManager DiagnosticsNodeManager { get; } /// /// The node managers being managed. @@ -221,7 +221,6 @@ ValueTask ModifyMonitoredItemsAsync( /// /// /// Throw if the namespaceUri or the nodeManager are null. - void RegisterNamespaceManager(string namespaceUri, IAsyncNodeManager nodeManager); /// @@ -248,7 +247,6 @@ ValueTask ModifyMonitoredItemsAsync( /// Registers a set of node ids. /// /// is null. - void RegisterNodes(OperationContext context, NodeIdCollection nodesToRegister, out NodeIdCollection registeredNodeIds); /// diff --git a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs index 7c3eb42f2..92c36727a 100644 --- a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs +++ b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs @@ -303,15 +303,15 @@ protected static PermissionType GetHistoryPermissionType(PerformUpdateType updat } /// - public ICoreNodeManager CoreNodeManager => m_nodeManagers[1].SyncNodeManager as ICoreNodeManager; + public CoreNodeManager CoreNodeManager => m_nodeManagers[1].SyncNodeManager as CoreNodeManager; /// - public IDiagnosticsNodeManager DiagnosticsNodeManager - => m_nodeManagers[0].SyncNodeManager as IDiagnosticsNodeManager; + public DiagnosticsNodeManager DiagnosticsNodeManager + => m_nodeManagers[0].SyncNodeManager as DiagnosticsNodeManager; /// - public IConfigurationNodeManager ConfigurationNodeManager - => m_nodeManagers[0].SyncNodeManager as IConfigurationNodeManager; + public ConfigurationNodeManager ConfigurationNodeManager + => m_nodeManagers[0].SyncNodeManager as ConfigurationNodeManager; /// public virtual async ValueTask StartupAsync(CancellationToken cancellationToken = default) @@ -3468,7 +3468,7 @@ protected ServiceResult ValidateCallRequestItem( // Initialize input arguments to empty collection if null. // Methods with only output parameters (no input parameters) are valid. - callMethodRequest.InputArguments ??= new VariantCollection(); + callMethodRequest.InputArguments ??= []; return StatusCodes.Good; } diff --git a/Libraries/Opc.Ua.Server/Server/IServerInternal.cs b/Libraries/Opc.Ua.Server/Server/IServerInternal.cs index 33f49e96e..bbc42ec23 100644 --- a/Libraries/Opc.Ua.Server/Server/IServerInternal.cs +++ b/Libraries/Opc.Ua.Server/Server/IServerInternal.cs @@ -96,25 +96,25 @@ public interface IServerInternal : IAuditEventServer, IDisposable /// The master node manager for the server. /// /// The node manager. - IMasterNodeManager NodeManager { get; } + MasterNodeManager NodeManager { get; } /// /// The internal node manager for the servers. /// /// The core node manager. - ICoreNodeManager CoreNodeManager { get; } + CoreNodeManager CoreNodeManager { get; } /// /// Returns the node manager that managers the server diagnostics. /// /// The diagnostics node manager. - IDiagnosticsNodeManager DiagnosticsNodeManager { get; } + DiagnosticsNodeManager DiagnosticsNodeManager { get; } /// /// Returns the node manager that managers the server configuration. /// /// The configuration node manager. - IConfigurationNodeManager ConfigurationNodeManager { get; } + ConfigurationNodeManager ConfigurationNodeManager { get; } /// /// The manager for events that all components use to queue events that occur. @@ -298,7 +298,7 @@ void CreateServerObject( /// Stores the MasterNodeManager and the CoreNodeManager /// /// The node manager. - void SetNodeManager(IMasterNodeManager nodeManager); + void SetNodeManager(MasterNodeManager nodeManager); /// /// Stores the MainNodeManagerFactory diff --git a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs index 15c9ea5f9..65ac02155 100644 --- a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs +++ b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs @@ -148,7 +148,7 @@ protected virtual void Dispose(bool disposing) /// Stores the MasterNodeManager, the DiagnosticsNodeManager and the CoreNodeManager /// /// The node manager. - public void SetNodeManager(IMasterNodeManager nodeManager) + public void SetNodeManager(MasterNodeManager nodeManager) { NodeManager = nodeManager; DiagnosticsNodeManager = nodeManager.DiagnosticsNodeManager; @@ -285,7 +285,7 @@ public void SetModellingRulesManager(ModellingRulesManager modellingRulesManager /// The master node manager for the server. /// /// The node manager. - public IMasterNodeManager NodeManager { get; private set; } + public MasterNodeManager NodeManager { get; private set; } /// public IMainNodeManagerFactory MainNodeManagerFactory { get; private set; } @@ -294,16 +294,16 @@ public void SetModellingRulesManager(ModellingRulesManager modellingRulesManager /// The internal node manager for the servers. /// /// The core node manager. - public ICoreNodeManager CoreNodeManager { get; private set; } + public CoreNodeManager CoreNodeManager { get; private set; } /// /// Returns the node manager that managers the server diagnostics. /// /// The diagnostics node manager. - public IDiagnosticsNodeManager DiagnosticsNodeManager { get; private set; } + public DiagnosticsNodeManager DiagnosticsNodeManager { get; private set; } /// - public IConfigurationNodeManager ConfigurationNodeManager { get; private set; } + public ConfigurationNodeManager ConfigurationNodeManager { get; private set; } /// /// The manager for events that all components use to queue events that occur. diff --git a/Libraries/Opc.Ua.Server/Server/StandardServer.cs b/Libraries/Opc.Ua.Server/Server/StandardServer.cs index d9d1e6ddd..0234add73 100644 --- a/Libraries/Opc.Ua.Server/Server/StandardServer.cs +++ b/Libraries/Opc.Ua.Server/Server/StandardServer.cs @@ -2327,10 +2327,11 @@ public bool RegisterWithDiscoveryServer() /// public async ValueTask RegisterWithDiscoveryServerAsync(CancellationToken ct = default) { - var configuration = new ApplicationConfiguration(Configuration); - - // use a dedicated certificate validator with the registration, but derive behavior from server config - configuration.CertificateValidator = new CertificateValidator(MessageContext.Telemetry); + var configuration = new ApplicationConfiguration(Configuration) + { + // use a dedicated certificate validator with the registration, but derive behavior from server config + CertificateValidator = new CertificateValidator(MessageContext.Telemetry) + }; await configuration .CertificateValidator.UpdateAsync( configuration.SecurityConfiguration, @@ -2625,8 +2626,8 @@ protected virtual void OnApplicationCertificateError( /// /// Verifies that the request header is valid. /// - /// The request header. /// The secure channel context. + /// The request header. /// Type of the request. /// protected virtual OperationContext ValidateRequest( @@ -3044,7 +3045,7 @@ await base.StartApplicationAsync(configuration, cancellationToken) // create the master node manager. m_logger.LogInformation(Utils.TraceMasks.StartStop, "Server - CreateMasterNodeManager."); - IMasterNodeManager masterNodeManager = CreateMasterNodeManager( + MasterNodeManager masterNodeManager = CreateMasterNodeManager( m_serverInternal, configuration); @@ -3626,7 +3627,7 @@ protected virtual ResourceManager CreateResourceManager( /// The server. /// The configuration. /// Returns the master node manager for the server, the return type is . - protected virtual IMasterNodeManager CreateMasterNodeManager( + protected virtual MasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) { diff --git a/Tests/Opc.Ua.Client.Tests/ReferenceServerWithLimits.cs b/Tests/Opc.Ua.Client.Tests/ReferenceServerWithLimits.cs index 13c0a1f25..6b8a333ca 100644 --- a/Tests/Opc.Ua.Client.Tests/ReferenceServerWithLimits.cs +++ b/Tests/Opc.Ua.Client.Tests/ReferenceServerWithLimits.cs @@ -97,7 +97,7 @@ public void SetMaxNumberOfContinuationPoints(uint maxNumberOfContinuationPoints) } } - protected override IMasterNodeManager CreateMasterNodeManager( + protected override MasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) { diff --git a/Tests/Opc.Ua.Security.Certificates.Tests/Pkcs10CertificationRequestTests.cs b/Tests/Opc.Ua.Security.Certificates.Tests/Pkcs10CertificationRequestTests.cs index 8109e9663..66b899f44 100644 --- a/Tests/Opc.Ua.Security.Certificates.Tests/Pkcs10CertificationRequestTests.cs +++ b/Tests/Opc.Ua.Security.Certificates.Tests/Pkcs10CertificationRequestTests.cs @@ -29,11 +29,9 @@ using System; using System.IO; -using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using NUnit.Framework; -using Opc.Ua.Tests; using Assert = NUnit.Framework.Legacy.ClassicAssert; namespace Opc.Ua.Security.Certificates.Tests @@ -48,7 +46,6 @@ namespace Opc.Ua.Security.Certificates.Tests [SetCulture("en-us")] public class Pkcs10CertificationRequestTests { - #region Test Methods /// /// Test parsing a valid RSA CSR from file. /// @@ -82,8 +79,8 @@ public void ParseValidRsaCsrFromFile() public void CreateAndParseRsaCsr() { const string subject = "CN=Test RSA CSR, O=OPC Foundation"; - string applicationUri = "urn:localhost:opcfoundation.org:TestRsaCsr"; - string[] domainNames = new[] { "localhost", "127.0.0.1" }; + const string applicationUri = "urn:localhost:opcfoundation.org:TestRsaCsr"; + string[] domainNames = ["localhost", "127.0.0.1"]; // Create a certificate to generate CSR from using X509Certificate2 certificate = CertificateBuilder.Create(subject) @@ -102,7 +99,7 @@ public void CreateAndParseRsaCsr() // Verify subject Assert.NotNull(csr.Subject); - Assert.That(csr.Subject.Name, Does.Contain("CN=Test RSA CSR")); + NUnit.Framework.Assert.That(csr.Subject.Name, Does.Contain("CN=Test RSA CSR")); // Verify signature bool isValid = csr.Verify(); @@ -120,8 +117,8 @@ public void CreateAndParseRsaCsr() public void CreateAndParseEcdsaCsrP256() { const string subject = "CN=Test ECDSA P256 CSR, O=OPC Foundation"; - string applicationUri = "urn:localhost:opcfoundation.org:TestEcdsaCsr"; - string[] domainNames = new[] { "localhost", "127.0.0.1" }; + const string applicationUri = "urn:localhost:opcfoundation.org:TestEcdsaCsr"; + string[] domainNames = ["localhost", "127.0.0.1"]; // Create a certificate to generate CSR from using X509Certificate2 certificate = CertificateBuilder.Create(subject) @@ -141,7 +138,7 @@ public void CreateAndParseEcdsaCsrP256() // Verify subject Assert.NotNull(csr.Subject); - Assert.That(csr.Subject.Name, Does.Contain("CN=Test ECDSA P256 CSR")); + NUnit.Framework.Assert.That(csr.Subject.Name, Does.Contain("CN=Test ECDSA P256 CSR")); // Verify SubjectPublicKeyInfo Assert.NotNull(csr.SubjectPublicKeyInfo); @@ -153,7 +150,7 @@ public void CreateAndParseEcdsaCsrP256() Assert.True(isValid, "ECDSA CSR signature should be valid"); #else // ECDSA verification not supported on older frameworks - Assert.Throws(() => csr.Verify()); + NUnit.Framework.Assert.Throws(() => csr.Verify()); #endif } @@ -163,7 +160,7 @@ public void CreateAndParseEcdsaCsrP256() [Test] public void ParseNullCsrThrowsArgumentNullException() { - Assert.Throws(() => new Pkcs10CertificationRequest(null)); + NUnit.Framework.Assert.Throws(() => new Pkcs10CertificationRequest(null)); } /// @@ -172,8 +169,8 @@ public void ParseNullCsrThrowsArgumentNullException() [Test] public void ParseInvalidCsrThrowsCryptographicException() { - byte[] invalidData = new byte[] { 0x01, 0x02, 0x03, 0x04 }; - Assert.Throws(() => new Pkcs10CertificationRequest(invalidData)); + byte[] invalidData = [0x01, 0x02, 0x03, 0x04]; + NUnit.Framework.Assert.Throws(() => new Pkcs10CertificationRequest(invalidData)); } /// @@ -183,8 +180,8 @@ public void ParseInvalidCsrThrowsCryptographicException() public void ParseCsrWithTamperedSignatureFails() { const string subject = "CN=Test Tampered CSR, O=OPC Foundation"; - string applicationUri = "urn:localhost:opcfoundation.org:TestTamperedCsr"; - string[] domainNames = new[] { "localhost" }; + const string applicationUri = "urn:localhost:opcfoundation.org:TestTamperedCsr"; + string[] domainNames = ["localhost"]; // Create a certificate to generate CSR from using X509Certificate2 certificate = CertificateBuilder.Create(subject) @@ -215,8 +212,8 @@ public void ParseCsrWithTamperedSignatureFails() public void ParseCsrAndExtractSubjectAltName() { const string subject = "CN=Test SAN CSR, O=OPC Foundation"; - string applicationUri = "urn:localhost:opcfoundation.org:TestSanCsr"; - string[] domainNames = new[] { "localhost", "testhost.local", "192.168.1.1" }; + const string applicationUri = "urn:localhost:opcfoundation.org:TestSanCsr"; + string[] domainNames = ["localhost", "testhost.local", "192.168.1.1"]; // Create a certificate to generate CSR from using X509Certificate2 certificate = CertificateBuilder.Create(subject) @@ -235,12 +232,12 @@ public void ParseCsrAndExtractSubjectAltName() X509SubjectAltNameExtension sanExtension = Pkcs10Utils.GetSubjectAltNameExtension(csr.Attributes); Assert.NotNull(sanExtension); - Assert.That(sanExtension.Uris, Has.Count.EqualTo(1)); - Assert.That(sanExtension.Uris[0], Is.EqualTo(applicationUri)); + NUnit.Framework.Assert.That(sanExtension.Uris, Has.Count.EqualTo(1)); + NUnit.Framework.Assert.That(sanExtension.Uris[0], Is.EqualTo(applicationUri)); // Verify domain names (may include URIs and domain names) int totalNames = sanExtension.DomainNames.Count + sanExtension.IPAddresses.Count; - Assert.That(totalNames, Is.EqualTo(domainNames.Length)); + NUnit.Framework.Assert.That(totalNames, Is.EqualTo(domainNames.Length)); } /// @@ -293,6 +290,8 @@ public void GetCertificationRequestInfoReturnsValidData() Assert.Greater(requestInfo.Length, 0); } + private static readonly string[] s_domainNames = ["localhost"]; + /// /// Test parsing multiple CSRs in sequence. /// @@ -310,7 +309,7 @@ public void ParseMultipleCsrsInSequence() using X509Certificate2 certificate = CertificateBuilder.Create(subject) .SetNotBefore(DateTime.UtcNow.AddDays(-1)) .SetLifeTime(TimeSpan.FromDays(30)) - .AddExtension(new X509SubjectAltNameExtension(applicationUri, new[] { "localhost" })) + .AddExtension(new X509SubjectAltNameExtension(applicationUri, s_domainNames)) .CreateForRSA(); byte[] csrData = CertificateFactory.CreateSigningRequest(certificate); @@ -321,7 +320,7 @@ public void ParseMultipleCsrsInSequence() csrList.Add(csr); } - Assert.That(csrList, Has.Count.EqualTo(count)); + NUnit.Framework.Assert.That(csrList, Has.Count.EqualTo(count)); } /// @@ -341,9 +340,8 @@ public void SubjectContainsExpectedDNComponents() var csr = new Pkcs10CertificationRequest(csrData); string subjectName = csr.Subject.Name; - Assert.That(subjectName, Does.Contain("CN=TestSubject")); - Assert.That(subjectName, Does.Contain("O=TestOrg")); + NUnit.Framework.Assert.That(subjectName, Does.Contain("CN=TestSubject")); + NUnit.Framework.Assert.That(subjectName, Does.Contain("O=TestOrg")); } - #endregion } } diff --git a/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs b/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs index aa43b7821..f69106e8c 100644 --- a/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs +++ b/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs @@ -1138,7 +1138,7 @@ public async Task ServerEventNotifierHistoryReadBitAsync() [Test] public async Task ServerStatusTimestampsMatchAsync() { - var logger = m_telemetry.CreateLogger(); + ILogger logger = m_telemetry.CreateLogger(); // Read ServerStatus children (CurrentTime, StartTime, State, etc.) var nodesToRead = new ReadValueIdCollection @@ -1149,7 +1149,7 @@ public async Task ServerStatusTimestampsMatchAsync() }; m_requestHeader.Timestamp = DateTime.UtcNow; - var readResponse = await m_server.ReadAsync( + ReadResponse readResponse = await m_server.ReadAsync( m_secureChannelContext, m_requestHeader, 0, @@ -1163,7 +1163,7 @@ public async Task ServerStatusTimestampsMatchAsync() // Verify that SourceTimestamp and ServerTimestamp are equal for all ServerStatus children for (int i = 0; i < readResponse.Results.Count; i++) { - var result = readResponse.Results[i]; + DataValue result = readResponse.Results[i]; logger.LogInformation( "NodeId: {NodeId}, SourceTimestamp: {SourceTimestamp}, ServerTimestamp: {ServerTimestamp}", nodesToRead[i].NodeId, @@ -1187,7 +1187,7 @@ public async Task HistoryReadInt32ValueNodeAsync() ILogger logger = telemetry.CreateLogger(); // Get the NodeId for Data_Dynamic_Scalar_Int32Value - NodeId int32ValueNodeId = new NodeId( + var int32ValueNodeId = new NodeId( TestData.Variables.Data_Dynamic_Scalar_Int32Value, (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(TestData.Namespaces.TestData)); @@ -1227,7 +1227,8 @@ public async Task HistoryReadInt32ValueNodeAsync() "Int32Value node should have HistoryRead access level"); // Perform a history read operation - var historyReadDetails = new ReadRawModifiedDetails { + var historyReadDetails = new ReadRawModifiedDetails + { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -1271,7 +1272,7 @@ public async Task HistoryReadInt32ValueNodeAsync() Assert.Greater(historyData.DataValues.Count, 0, "Should have at least one historical value"); // Verify the data values have proper timestamps - foreach (var dataValue in historyData.DataValues) + foreach (DataValue dataValue in historyData.DataValues) { Assert.IsNotNull(dataValue, "DataValue should not be null"); Assert.IsTrue(dataValue.ServerTimestamp != DateTime.MinValue, @@ -1280,7 +1281,7 @@ public async Task HistoryReadInt32ValueNodeAsync() } else { - Assert.Fail("HistoryData body should be of type HistoryData"); + NUnit.Framework.Assert.Fail("HistoryData body should be of type HistoryData"); } } From 6765151a007a256cfc2f21e329b4b21d49bad0a7 Mon Sep 17 00:00:00 2001 From: romanett Date: Tue, 27 Jan 2026 07:42:01 +0100 Subject: [PATCH 39/42] Fix EndpointIncomingRequest to do serviceLookup at invocation time instead of construction. This makes the object smaller and fixes a bug where the transport channel response was not sent due to unhandled exception (#3505) --- Stack/Opc.Ua.Core/Stack/Server/EndpointBase.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Stack/Opc.Ua.Core/Stack/Server/EndpointBase.cs b/Stack/Opc.Ua.Core/Stack/Server/EndpointBase.cs index a929d304a..96212ee90 100644 --- a/Stack/Opc.Ua.Core/Stack/Server/EndpointBase.cs +++ b/Stack/Opc.Ua.Core/Stack/Server/EndpointBase.cs @@ -1107,7 +1107,6 @@ public EndpointIncomingRequest( SecureChannelContext = context; Request = request; m_vts = ServiceResponsePooledValueTaskSource.Create(); - m_service = m_endpoint.FindService(Request.TypeId); m_cancellationToken = cancellationToken; } @@ -1170,7 +1169,8 @@ .Body is AdditionalParametersType parameters && using (activity) { - IServiceResponse response = await m_service.InvokeAsync(Request, SecureChannelContext, linkedCts.Token).ConfigureAwait(false); + ServiceDefinition service = m_endpoint.FindService(Request.TypeId); + IServiceResponse response = await service.InvokeAsync(Request, SecureChannelContext, linkedCts.Token).ConfigureAwait(false); m_vts.SetResult(response); } } @@ -1232,7 +1232,6 @@ public bool Equals(EndpointIncomingRequest other) } private readonly EndpointBase m_endpoint; - private readonly ServiceDefinition m_service; private readonly ServiceResponsePooledValueTaskSource m_vts; private readonly CancellationToken m_cancellationToken; } From c26de8df0daf0cec8b27ac719c79ab8e92bcd43d Mon Sep 17 00:00:00 2001 From: romanett Date: Tue, 27 Jan 2026 07:44:07 +0100 Subject: [PATCH 40/42] Modified error handling to raise publish error events for (#3502) BadNoSubscription if there are active subscriptions. Fix typo in param name . --- Libraries/Opc.Ua.Client/Session/Session.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Libraries/Opc.Ua.Client/Session/Session.cs b/Libraries/Opc.Ua.Client/Session/Session.cs index 06c3c5ceb..964abe78d 100644 --- a/Libraries/Opc.Ua.Client/Session/Session.cs +++ b/Libraries/Opc.Ua.Client/Session/Session.cs @@ -2510,18 +2510,18 @@ public async Task ReconnectAsync( /// /// Recreate the subscriptions in a recreated session. /// - /// Uses Transfer service + /// Uses Transfer service /// if set to true. /// The template for the subscriptions. /// Cancellation token to cancel operation with private async Task RecreateSubscriptionsAsync( - bool transferSbscriptionTemplates, + bool transferSubscriptionTemplates, IEnumerable subscriptionsTemplate, CancellationToken ct) { using Activity? activity = m_telemetry.StartActivity(); bool transferred = false; - if (transferSbscriptionTemplates) + if (transferSubscriptionTemplates) { try { @@ -3533,7 +3533,8 @@ private void OnPublishComplete( // raise an error event. var error = new ServiceResult(e); - if (error.Code != StatusCodes.BadNoSubscription) + // raise publish error even for BadNoSubscription if there are active subscriptions. + if (error.Code != StatusCodes.BadNoSubscription || m_subscriptions.Any(s => s.Created)) { PublishErrorEventHandler? callback = m_PublishError; From 3580d28c7e37aaa8bd92139ef55085fcdf7af2dd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:43:52 +0100 Subject: [PATCH 41/42] Fix OperationLimits to apply min(client, server) logic per documentation (#3503) * Initial plan * Implement OperationLimits min logic and add tests Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- Libraries/Opc.Ua.Client/Session/Session.cs | 101 ++++++++--- Tests/Opc.Ua.Client.Tests/SessionMock.cs | 2 +- Tests/Opc.Ua.Client.Tests/SessionTests.cs | 198 +++++++++++++++++++++ 3 files changed, 273 insertions(+), 28 deletions(-) diff --git a/Libraries/Opc.Ua.Client/Session/Session.cs b/Libraries/Opc.Ua.Client/Session/Session.cs index 964abe78d..bb3411529 100644 --- a/Libraries/Opc.Ua.Client/Session/Session.cs +++ b/Libraries/Opc.Ua.Client/Session/Session.cs @@ -223,6 +223,25 @@ private Session( DefaultSubscription.MinLifetimeInterval = (uint)m_configuration.ClientConfiguration .MinSubscriptionLifetime; + // initialize operation limits from client configuration. + if (m_configuration.ClientConfiguration.OperationLimits != null) + { + OperationLimits clientLimits = m_configuration.ClientConfiguration.OperationLimits; + OperationLimits.MaxNodesPerRead = clientLimits.MaxNodesPerRead; + OperationLimits.MaxNodesPerHistoryReadData = clientLimits.MaxNodesPerHistoryReadData; + OperationLimits.MaxNodesPerHistoryReadEvents = clientLimits.MaxNodesPerHistoryReadEvents; + OperationLimits.MaxNodesPerWrite = clientLimits.MaxNodesPerWrite; + OperationLimits.MaxNodesPerHistoryUpdateData = clientLimits.MaxNodesPerHistoryUpdateData; + OperationLimits.MaxNodesPerHistoryUpdateEvents = clientLimits.MaxNodesPerHistoryUpdateEvents; + OperationLimits.MaxNodesPerMethodCall = clientLimits.MaxNodesPerMethodCall; + OperationLimits.MaxNodesPerBrowse = clientLimits.MaxNodesPerBrowse; + OperationLimits.MaxNodesPerRegisterNodes = clientLimits.MaxNodesPerRegisterNodes; + OperationLimits.MaxNodesPerTranslateBrowsePathsToNodeIds = + clientLimits.MaxNodesPerTranslateBrowsePathsToNodeIds; + OperationLimits.MaxNodesPerNodeManagement = clientLimits.MaxNodesPerNodeManagement; + OperationLimits.MaxMonitoredItemsPerCall = clientLimits.MaxMonitoredItemsPerCall; + } + NamespaceUris = messageContext.NamespaceUris; ServerUris = messageContext.ServerUris; Factory = messageContext.Factory; @@ -1835,6 +1854,35 @@ public async Task FetchTypeTreeAsync( public async Task FetchOperationLimitsAsync(CancellationToken ct) { using Activity? activity = m_telemetry.StartActivity(); + + // Helper extraction + static T Get(ref int index, IList values, IList errors) + where T : struct + { + DataValue value = values[index]; + ServiceResult error = errors.Count > 0 ? errors[index] : ServiceResult.Good; + index++; + if (ServiceResult.IsNotBad(error) && value.Value is T retVal) + { + return retVal; + } + return default; + } + + // Apply operation limit logic: if client value is 0, use server value; otherwise use min(client, server) + static uint ApplyOperationLimit(uint clientLimit, uint serverLimit) + { + if (clientLimit == 0) + { + return serverLimit; + } + if (serverLimit == 0) + { + return clientLimit; + } + return Math.Min(clientLimit, serverLimit); + } + // First we read the node read max to optimize the second read. var nodeIds = new List { @@ -1843,7 +1891,7 @@ public async Task FetchOperationLimitsAsync(CancellationToken ct) (DataValueCollection values, IList errors) = await this.ReadValuesAsync(nodeIds, ct).ConfigureAwait(false); int index = 0; - OperationLimits.MaxNodesPerRead = Get(ref index, values, errors); + OperationLimits.MaxNodesPerRead = ApplyOperationLimit(OperationLimits.MaxNodesPerRead, Get(ref index, values, errors)); nodeIds = [ @@ -1878,18 +1926,31 @@ public async Task FetchOperationLimitsAsync(CancellationToken ct) (values, errors) = await this.ReadValuesAsync(nodeIds, ct).ConfigureAwait(false); index = 0; - OperationLimits.MaxNodesPerHistoryReadData = Get(ref index, values, errors); - OperationLimits.MaxNodesPerHistoryReadEvents = Get(ref index, values, errors); - OperationLimits.MaxNodesPerWrite = Get(ref index, values, errors); - OperationLimits.MaxNodesPerRead = Get(ref index, values, errors); - OperationLimits.MaxNodesPerHistoryUpdateData = Get(ref index, values, errors); - OperationLimits.MaxNodesPerHistoryUpdateEvents = Get(ref index, values, errors); - OperationLimits.MaxNodesPerMethodCall = Get(ref index, values, errors); - OperationLimits.MaxNodesPerBrowse = Get(ref index, values, errors); - OperationLimits.MaxNodesPerRegisterNodes = Get(ref index, values, errors); - OperationLimits.MaxNodesPerNodeManagement = Get(ref index, values, errors); - OperationLimits.MaxMonitoredItemsPerCall = Get(ref index, values, errors); - OperationLimits.MaxNodesPerTranslateBrowsePathsToNodeIds = Get(ref index, values, errors); + OperationLimits.MaxNodesPerHistoryReadData = ApplyOperationLimit( + OperationLimits.MaxNodesPerHistoryReadData, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerHistoryReadEvents = ApplyOperationLimit( + OperationLimits.MaxNodesPerHistoryReadEvents, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerWrite = ApplyOperationLimit( + OperationLimits.MaxNodesPerWrite, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerRead = ApplyOperationLimit( + OperationLimits.MaxNodesPerRead, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerHistoryUpdateData = ApplyOperationLimit( + OperationLimits.MaxNodesPerHistoryUpdateData, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerHistoryUpdateEvents = ApplyOperationLimit( + OperationLimits.MaxNodesPerHistoryUpdateEvents, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerMethodCall = ApplyOperationLimit( + OperationLimits.MaxNodesPerMethodCall, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerBrowse = ApplyOperationLimit( + OperationLimits.MaxNodesPerBrowse, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerRegisterNodes = ApplyOperationLimit( + OperationLimits.MaxNodesPerRegisterNodes, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerNodeManagement = ApplyOperationLimit( + OperationLimits.MaxNodesPerNodeManagement, Get(ref index, values, errors)); + OperationLimits.MaxMonitoredItemsPerCall = ApplyOperationLimit( + OperationLimits.MaxMonitoredItemsPerCall, Get(ref index, values, errors)); + OperationLimits.MaxNodesPerTranslateBrowsePathsToNodeIds = ApplyOperationLimit( + OperationLimits.MaxNodesPerTranslateBrowsePathsToNodeIds, + Get(ref index, values, errors)); ServerCapabilities.MaxBrowseContinuationPoints = Get(ref index, values, errors); ServerCapabilities.MaxHistoryContinuationPoints = Get(ref index, values, errors); ServerCapabilities.MaxQueryContinuationPoints = Get(ref index, values, errors); @@ -1906,20 +1967,6 @@ public async Task FetchOperationLimitsAsync(CancellationToken ct) ServerCapabilities.MaxWhereClauseParameters = Get(ref index, values, errors); ServerCapabilities.MaxSelectClauseParameters = Get(ref index, values, errors); - // Helper extraction - static T Get(ref int index, IList values, IList errors) - where T : struct - { - DataValue value = values[index]; - ServiceResult error = errors.Count > 0 ? errors[index] : ServiceResult.Good; - index++; - if (ServiceResult.IsNotBad(error) && value.Value is T retVal) - { - return retVal; - } - return default; - } - uint maxByteStringLength = (uint?)m_configuration.TransportQuotas?.MaxByteStringLength ?? 0u; if (maxByteStringLength != 0 && (ServerCapabilities.MaxByteStringLength == 0 || diff --git a/Tests/Opc.Ua.Client.Tests/SessionMock.cs b/Tests/Opc.Ua.Client.Tests/SessionMock.cs index 8f9d981da..e89361cd1 100644 --- a/Tests/Opc.Ua.Client.Tests/SessionMock.cs +++ b/Tests/Opc.Ua.Client.Tests/SessionMock.cs @@ -52,7 +52,7 @@ public sealed class SessionMock : Session /// /// Create the mock /// - private SessionMock( + internal SessionMock( Mock channel, ApplicationConfiguration configuration, ConfiguredEndpoint endpoint) diff --git a/Tests/Opc.Ua.Client.Tests/SessionTests.cs b/Tests/Opc.Ua.Client.Tests/SessionTests.cs index 32947fea6..703b983d2 100644 --- a/Tests/Opc.Ua.Client.Tests/SessionTests.cs +++ b/Tests/Opc.Ua.Client.Tests/SessionTests.cs @@ -275,6 +275,204 @@ public void FetchOperationLimitsAsyncShouldHandleTimeout() sut.Channel.Verify(); } + [Test] + public async Task FetchOperationLimitsAsyncShouldApplyClientLimitsWhenSmaller() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + var channel = new Mock(); + channel + .SetupGet(s => s.MessageContext) + .Returns(new ServiceMessageContext(telemetry)); + channel + .SetupGet(s => s.SupportedFeatures) + .Returns(TransportChannelFeatures.Reconnect); + + // Configure client with smaller limits than server + var configuration = new ApplicationConfiguration(telemetry) + { + ClientConfiguration = new ClientConfiguration + { + OperationLimits = new OperationLimits + { + MaxNodesPerRead = 500, + MaxNodesPerWrite = 2000, + MaxNodesPerBrowse = 4000 + } + } + }; + + var endpoint = new EndpointDescription + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + EndpointUrl = "opc.tcp://localhost:4840" + }; + + var sut = new SessionMock( + channel, + configuration, + new ConfiguredEndpoint(null, endpoint)); + + CancellationToken ct = CancellationToken.None; + + // Server returns larger values + var dataValues = new DataValueCollection + { + new DataValue(new Variant(1000u)), // MaxNodesPerHistoryReadData - no client limit + new DataValue(new Variant(1000u)), // MaxNodesPerHistoryReadEvents - no client limit + new DataValue(new Variant(5000u)), // MaxNodesPerWrite - client has 2000 + new DataValue(new Variant(1000u)), // MaxNodesPerRead - client has 500 + new DataValue(new Variant(1000u)), // MaxNodesPerHistoryUpdateData + new DataValue(new Variant(1000u)), // MaxNodesPerHistoryUpdateEvents + new DataValue(new Variant(1000u)), // MaxNodesPerMethodCall + new DataValue(new Variant(10000u)), // MaxNodesPerBrowse - client has 4000 + new DataValue(new Variant(1000u)), // MaxNodesPerRegisterNodes + new DataValue(new Variant(1000u)), // MaxNodesPerNodeManagement + new DataValue(new Variant(1000u)), // MaxMonitoredItemsPerCall + new DataValue(new Variant(1000u)), // MaxNodesPerTranslateBrowsePathsToNodeIds + new DataValue(new Variant((ushort)100)), + new DataValue(new Variant((ushort)100)), + new DataValue(new Variant((ushort)100)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(100.0)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)) + }; + + channel + .SetupSequence(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(new ReadResponse + { + Results = [new DataValue(new Variant(1000u))], + DiagnosticInfos = [] + })) + .Returns(new ValueTask(new ReadResponse + { + Results = dataValues, + DiagnosticInfos = [] + })); + + // Act + await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false); + + // Assert - should use smaller of client and server values + Assert.That(sut.OperationLimits.MaxNodesPerRead, Is.EqualTo(500)); // min(500, 1000) + Assert.That(sut.OperationLimits.MaxNodesPerWrite, Is.EqualTo(2000)); // min(2000, 5000) + Assert.That(sut.OperationLimits.MaxNodesPerBrowse, Is.EqualTo(4000)); // min(4000, 10000) + Assert.That(sut.OperationLimits.MaxNodesPerHistoryReadData, Is.EqualTo(1000)); // 0 -> server + Assert.That(sut.OperationLimits.MaxNodesPerHistoryReadEvents, Is.EqualTo(1000)); // 0 -> server + + channel.Verify(); + } + + [Test] + public async Task FetchOperationLimitsAsyncShouldApplyServerLimitsWhenSmaller() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + var channel = new Mock(); + channel + .SetupGet(s => s.MessageContext) + .Returns(new ServiceMessageContext(telemetry)); + channel + .SetupGet(s => s.SupportedFeatures) + .Returns(TransportChannelFeatures.Reconnect); + + // Configure client with larger limits than server + var configuration = new ApplicationConfiguration(telemetry) + { + ClientConfiguration = new ClientConfiguration + { + OperationLimits = new OperationLimits + { + MaxNodesPerRead = 2000, + MaxNodesPerWrite = 10000, + MaxNodesPerBrowse = 8000 + } + } + }; + + var endpoint = new EndpointDescription + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + EndpointUrl = "opc.tcp://localhost:4840" + }; + + var sut = new SessionMock( + channel, + configuration, + new ConfiguredEndpoint(null, endpoint)); + + CancellationToken ct = CancellationToken.None; + + // Server returns smaller values + var dataValues = new DataValueCollection + { + new DataValue(new Variant(1000u)), // MaxNodesPerHistoryReadData + new DataValue(new Variant(1000u)), // MaxNodesPerHistoryReadEvents + new DataValue(new Variant(5000u)), // MaxNodesPerWrite - server has 5000, client has 10000 + new DataValue(new Variant(1000u)), // MaxNodesPerRead - server has 1000, client has 2000 + new DataValue(new Variant(1000u)), // MaxNodesPerHistoryUpdateData + new DataValue(new Variant(1000u)), // MaxNodesPerHistoryUpdateEvents + new DataValue(new Variant(1000u)), // MaxNodesPerMethodCall + new DataValue(new Variant(5000u)), // MaxNodesPerBrowse - server has 5000, client has 8000 + new DataValue(new Variant(1000u)), // MaxNodesPerRegisterNodes + new DataValue(new Variant(1000u)), // MaxNodesPerNodeManagement + new DataValue(new Variant(1000u)), // MaxMonitoredItemsPerCall + new DataValue(new Variant(1000u)), // MaxNodesPerTranslateBrowsePathsToNodeIds + new DataValue(new Variant((ushort)100)), + new DataValue(new Variant((ushort)100)), + new DataValue(new Variant((ushort)100)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(100.0)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)), + new DataValue(new Variant(1000u)) + }; + + channel + .SetupSequence(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(new ReadResponse + { + Results = [new DataValue(new Variant(1000u))], + DiagnosticInfos = [] + })) + .Returns(new ValueTask(new ReadResponse + { + Results = dataValues, + DiagnosticInfos = [] + })); + + // Act + await sut.FetchOperationLimitsAsync(ct).ConfigureAwait(false); + + // Assert - should use smaller of client and server values + Assert.That(sut.OperationLimits.MaxNodesPerRead, Is.EqualTo(1000)); // min(2000, 1000) + Assert.That(sut.OperationLimits.MaxNodesPerWrite, Is.EqualTo(5000)); // min(10000, 5000) + Assert.That(sut.OperationLimits.MaxNodesPerBrowse, Is.EqualTo(5000)); // min(8000, 5000) + + channel.Verify(); + } + [Test] public async Task FetchNamespaceTablesAsyncShouldFetchAndUpdateTablesAsync() { From 219f0e14ad5c5c637945c0a241c002faa9319a07 Mon Sep 17 00:00:00 2001 From: romanett Date: Tue, 27 Jan 2026 21:24:26 +0100 Subject: [PATCH 42/42] revert breaking changes for 1.5.378 (#3508) * revert breaking changes * Add TrustList for backcompat * revert more changes --- .../ApplicationConfigurationBuilder.cs | 2 +- .../ApplicationInstance.cs | 4 +- .../IApplicationInstance.cs | 4 +- .../GlobalDiscoveryServerClient.cs | 11 ++- .../ServerPushConfigurationClient.cs | 11 ++- .../Diagnostics/DiagnosticsNodeManager.cs | 10 +- .../NodeManager/MasterNodeManager.cs | 98 +++++++++++++++++++ 7 files changed, 128 insertions(+), 12 deletions(-) diff --git a/Libraries/Opc.Ua.Configuration/ApplicationConfigurationBuilder.cs b/Libraries/Opc.Ua.Configuration/ApplicationConfigurationBuilder.cs index 2f5601964..3a73ba222 100644 --- a/Libraries/Opc.Ua.Configuration/ApplicationConfigurationBuilder.cs +++ b/Libraries/Opc.Ua.Configuration/ApplicationConfigurationBuilder.cs @@ -52,7 +52,7 @@ public ApplicationConfigurationBuilder(ApplicationInstance applicationInstance) /// /// The application instance used to build the configuration. /// - public IApplicationInstance ApplicationInstance { get; } + public ApplicationInstance ApplicationInstance { get; } /// /// The application configuration. diff --git a/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs b/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs index 82bfd6ef1..4012e22a8 100644 --- a/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs +++ b/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs @@ -97,7 +97,7 @@ public ApplicationInstance( public Type ConfigurationType { get; set; } /// - public IServerBase Server { get; private set; } + public ServerBase Server { get; private set; } /// public ApplicationConfiguration ApplicationConfiguration { get; set; } @@ -114,7 +114,7 @@ public ApplicationInstance( public bool DisableCertificateAutoCreation { get; set; } /// - public async Task StartAsync(IServerBase server) + public async Task StartAsync(ServerBase server) { Server = server; diff --git a/Libraries/Opc.Ua.Configuration/IApplicationInstance.cs b/Libraries/Opc.Ua.Configuration/IApplicationInstance.cs index 9ab50aad3..fa79a7f67 100644 --- a/Libraries/Opc.Ua.Configuration/IApplicationInstance.cs +++ b/Libraries/Opc.Ua.Configuration/IApplicationInstance.cs @@ -93,7 +93,7 @@ public interface IApplicationInstance /// Gets the server. /// /// The server. - IServerBase Server { get; } + ServerBase Server { get; } /// /// Adds a Certificate to the Trusted Store of the Application, needed e.g. for the GDS to trust it´s own CA @@ -143,7 +143,7 @@ public interface IApplicationInstance /// Starts the UA server. /// /// The server. - Task StartAsync(IServerBase server); + Task StartAsync(ServerBase server); /// /// Stops the UA server. diff --git a/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs b/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs index c1ef8089b..9fc6404b4 100644 --- a/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs +++ b/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs @@ -1448,7 +1448,16 @@ public TrustListDataType ReadTrustList(NodeId trustListId) /// Reads the trust list. /// /// - public async Task ReadTrustListAsync(NodeId trustListId, long maxTrustListSize = 0, CancellationToken ct = default) + public Task ReadTrustListAsync(NodeId trustListId, CancellationToken ct = default) + { + return ReadTrustListAsync(trustListId, 0, ct); + } + + /// + /// Reads the trust list. + /// + /// + public async Task ReadTrustListAsync(NodeId trustListId, long maxTrustListSize, CancellationToken ct = default) { ISession session = await ConnectIfNeededAsync(ct).ConfigureAwait(false); diff --git a/Libraries/Opc.Ua.Gds.Client.Common/ServerPushConfigurationClient.cs b/Libraries/Opc.Ua.Gds.Client.Common/ServerPushConfigurationClient.cs index d70c73a52..d8ec0a0a6 100644 --- a/Libraries/Opc.Ua.Gds.Client.Common/ServerPushConfigurationClient.cs +++ b/Libraries/Opc.Ua.Gds.Client.Common/ServerPushConfigurationClient.cs @@ -608,7 +608,16 @@ public bool UpdateTrustList(TrustListDataType trustList) /// Updates the trust list. /// /// - public async Task UpdateTrustListAsync(TrustListDataType trustList, long maxTrustListSize = 0, CancellationToken ct = default) + public Task UpdateTrustListAsync(TrustListDataType trustList, CancellationToken ct = default) + { + return UpdateTrustListAsync(trustList, 0, ct); + } + + /// + /// Updates the trust list. + /// + /// + public async Task UpdateTrustListAsync(TrustListDataType trustList, long maxTrustListSize, CancellationToken ct = default) { ISession session = await ConnectIfNeededAsync(ct).ConfigureAwait(false); IUserIdentity oldUser = await ElevatePermissionsAsync(session, ct).ConfigureAwait(false); diff --git a/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs b/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs index db971d714..b2e7036e0 100644 --- a/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs +++ b/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs @@ -235,7 +235,7 @@ var setSubscriptionDurable /// /// Called when a client sets a subscription as durable. /// - protected ServiceResult OnSetSubscriptionDurable( + public ServiceResult OnSetSubscriptionDurable( ISystemContext context, MethodState method, NodeId objectId, @@ -253,7 +253,7 @@ protected ServiceResult OnSetSubscriptionDurable( /// /// Called when a client gets the monitored items of a subscription. /// - protected ServiceResult OnGetMonitoredItems( + public ServiceResult OnGetMonitoredItems( ISystemContext context, MethodState method, IList inputArguments, @@ -299,7 +299,7 @@ protected ServiceResult OnGetMonitoredItems( /// /// Called when a client initiates resending of all data monitored items in a Subscription. /// - protected ServiceResult OnResendData( + public ServiceResult OnResendData( ISystemContext context, MethodState method, IList inputArguments, @@ -340,7 +340,7 @@ protected ServiceResult OnResendData( /// /// Called when a client locks the server. /// - protected ServiceResult OnLockServer( + public ServiceResult OnLockServer( ISystemContext context, MethodState method, IList inputArguments, @@ -361,7 +361,7 @@ protected ServiceResult OnLockServer( /// /// Called when a client locks the server. /// - protected ServiceResult OnUnlockServer( + public ServiceResult OnUnlockServer( ISystemContext context, MethodState method, IList inputArguments, diff --git a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs index 92c36727a..8d95b39f7 100644 --- a/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs +++ b/Libraries/Opc.Ua.Server/NodeManager/MasterNodeManager.cs @@ -666,6 +666,15 @@ await nodeManager.AddReferencesAsync(map, cancellationToken) .ConfigureAwait(false); } + /// + /// Deletes the references to the target. + /// + [Obsolete("Use DeleteReferencesAsync")] + public virtual void DeleteReferences(NodeId targetId, IList references) + { + DeleteReferencesAsync(targetId, references).AsTask().GetAwaiter().GetResult(); + } + /// public virtual async ValueTask DeleteReferencesAsync(NodeId targetId, IList references, @@ -2431,6 +2440,36 @@ await nodeManager.ConditionRefreshAsync(context, monitoredItems, cancellationTok } } + /// + /// Creates a set of monitored items. + /// + /// is null. + /// + /// + [Obsolete("Use CreateMonitoredItemsAsync")] + public virtual void CreateMonitoredItems( + OperationContext context, + uint subscriptionId, + double publishingInterval, + TimestampsToReturn timestampsToReturn, + IList itemsToCreate, + IList errors, + IList filterResults, + IList monitoredItems, + bool createDurable) + { + CreateMonitoredItemsAsync( + context, + subscriptionId, + publishingInterval, + timestampsToReturn, + itemsToCreate, + errors, + filterResults, + monitoredItems, + createDurable).AsTask().GetAwaiter().GetResult(); + } + /// public virtual async ValueTask CreateMonitoredItemsAsync( OperationContext context, @@ -2831,6 +2870,29 @@ await manager.SubscribeToAllEventsAsync( } } + /// + /// Modifies a set of monitored items. + /// + /// is null. + /// + [Obsolete("Use ModifyMonitoredItemsAsync")] + public virtual void ModifyMonitoredItems( + OperationContext context, + TimestampsToReturn timestampsToReturn, + IList monitoredItems, + IList itemsToModify, + IList errors, + IList filterResults) + { + ModifyMonitoredItemsAsync( + context, + timestampsToReturn, + monitoredItems, + itemsToModify, + errors, + filterResults).AsTask().GetAwaiter().GetResult(); + } + /// public virtual async ValueTask ModifyMonitoredItemsAsync( OperationContext context, @@ -3025,6 +3087,24 @@ await nodeManager.SubscribeToAllEventsAsync( } } + /// + /// Transfers a set of monitored items. + /// + /// is null. + [Obsolete("User TransferMonitoredItemsAsync")] + public virtual void TransferMonitoredItems( + OperationContext context, + bool sendInitialValues, + IList monitoredItems, + IList errors) + { + TransferMonitoredItemsAsync( + context, + sendInitialValues, + monitoredItems, + errors).AsTask().GetAwaiter().GetResult(); + } + /// public virtual async ValueTask TransferMonitoredItemsAsync( OperationContext context, @@ -3071,6 +3151,24 @@ await nodeManager.TransferMonitoredItemsAsync( } } + /// + /// Deletes a set of monitored items. + /// + /// is null. + [Obsolete("Use DeleteMonitoredItemsAsync")] + public virtual void DeleteMonitoredItems( + OperationContext context, + uint subscriptionId, + IList itemsToDelete, + IList errors) + { + DeleteMonitoredItemsAsync( + context, + subscriptionId, + itemsToDelete, + errors).AsTask().GetAwaiter().GetResult(); + } + /// public virtual async ValueTask DeleteMonitoredItemsAsync( OperationContext context,