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/ diff --git a/.github/workflows/stability-test.yml b/.github/workflows/stability-test.yml new file mode 100644 index 000000000..2d996ebc9 --- /dev/null +++ b/.github/workflows/stability-test.yml @@ -0,0 +1,80 @@ +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' + TARGET_FRAMEWORK: 'net10.0' + 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 \ + --framework ${{ env.TARGET_FRAMEWORK }} \ + --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/Applications/ConsoleReferenceClient/ClientSamples.cs b/Applications/ConsoleReferenceClient/ClientSamples.cs index 6f85b169f..398116f05 100644 --- a/Applications/ConsoleReferenceClient/ClientSamples.cs +++ b/Applications/ConsoleReferenceClient/ClientSamples.cs @@ -1617,6 +1617,144 @@ 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 => + { + string 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 (KeyValuePair> kvp in nodesByNamespace) + { + cancellationToken.ThrowIfCancellationRequested(); + + string namespaceUri = session.NamespaceUris.GetString(kvp.Key); + + // Create a safe filename from the namespace URI + + string fileName = CreateSafeFileName(namespaceUri, kvp.Key); + string 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 + 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 + foreach (char c in Path.GetInvalidFileNameChars()) + { + 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/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/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/ReferenceNodeManager.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs index 2b0717c9f..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) { @@ -5068,26 +5062,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/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs index c98397494..f764e476c 100644 --- a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs +++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs @@ -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/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/Directory.Packages.props b/Directory.Packages.props index c400d6c8d..73cfadf4c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,16 +8,16 @@ - - - - - + + + + + - - - - + + + + @@ -28,11 +28,11 @@ - - + + - - + + @@ -40,15 +40,15 @@ - + - + - - + + - + \ No newline at end of file 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 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/Libraries/Opc.Ua.Client/Session/Session.cs b/Libraries/Opc.Ua.Client/Session/Session.cs index 73670373b..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; @@ -950,8 +969,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); @@ -1196,8 +1215,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 +1249,6 @@ await m_configuration clientCertificateChainData, clientNonce); - HandleSignedSoftwareCertificates(serverSoftwareCertificates); - // process additional header ProcessResponseAdditionalHeader(response.ResponseHeader, serverCertificate); @@ -1280,10 +1295,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 +1305,7 @@ SignedSoftwareCertificateCollection clientSoftwareCertificates ActivateSessionResponse activateResponse = await ActivateSessionAsync( null, clientSignature, - clientSoftwareCertificates, + null, m_preferredLocales, new ExtensionObject(identityToken), userTokenSignature, @@ -1320,12 +1331,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 +1492,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, @@ -1853,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 { @@ -1861,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 = [ @@ -1896,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); @@ -1924,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 || @@ -2339,10 +2368,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) @@ -2532,18 +2557,18 @@ SignedSoftwareCertificateCollection clientSoftwareCertificates /// /// 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 { @@ -2640,14 +2665,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 +2676,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. /// @@ -3114,6 +3111,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( @@ -3562,7 +3563,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); @@ -3579,7 +3580,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; @@ -3676,6 +3678,7 @@ private void OnPublishComplete( }); return; case StatusCodes.BadTimeout: + case StatusCodes.BadRequestTimeout: break; default: m_logger.LogError( @@ -4175,38 +4178,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.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 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/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs b/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs index 19f9da779..4012e22a8 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; } - /// - /// Gets the application configuration used when the Start() method was called. - /// - /// The application configuration. + /// public ApplicationConfiguration ApplicationConfiguration { get; set; } /// @@ -128,28 +107,13 @@ 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) { 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..fa79a7f67 --- /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. + 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 + /// + /// 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(ServerBase server); + + /// + /// Stops the UA server. + /// + ValueTask StopAsync(); + } +} diff --git a/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs b/Libraries/Opc.Ua.Gds.Client.Common/GlobalDiscoveryServerClient.cs index 2841c8f5a..9fc6404b4 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,17 @@ public TrustListDataType ReadTrustList(NodeId trustListId) /// /// Reads the trust list. /// - public async Task ReadTrustListAsync(NodeId trustListId, 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); @@ -1456,6 +1471,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 +1491,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..d8ec0a0a6 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,17 @@ public bool UpdateTrustList(TrustListDataType trustList) /// /// Updates the trust list. /// - public async Task UpdateTrustListAsync(TrustListDataType trustList, 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); @@ -595,6 +631,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.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 fde4bcd1a..a020e8a0f 100644 --- a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs +++ b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs @@ -43,26 +43,10 @@ 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. /// - public class ConfigurationNodeManager : DiagnosticsNodeManager, ICallAsyncNodeManager + public class ConfigurationNodeManager : DiagnosticsNodeManager, ICallAsyncNodeManager, IConfigurationNodeManager { /// /// Initializes the configuration and diagnostics manager. @@ -265,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) @@ -319,7 +301,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); } @@ -332,9 +315,7 @@ .. configuration.ServerConfiguration.SupportedPrivateKeyFormats } } - /// - /// Gets and returns the node associated with the specified NamespaceUri - /// + /// public NamespaceMetadataState GetNamespaceMetadataState(string namespaceUri) { if (namespaceUri == null) @@ -361,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( @@ -405,24 +384,16 @@ 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 _) + CertificateStoreIdentifier trustedStore) { if (context is SessionSystemContext { OperationContext: OperationContext operationContext }) { 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/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/Configuration/TrustList.cs b/Libraries/Opc.Ua.Server/Configuration/TrustList.cs index 4cdd06754..57a8d1967 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,19 +69,33 @@ 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.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); } /// @@ -94,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( @@ -110,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); @@ -139,23 +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; + uint fileHandle = 0; + MemoryStream strm = null; + try + { var trustList = new TrustListDataType { SpecifiedLists = (uint)masks }; ICertificateStore store = m_trustedStore.OpenStore(m_telemetry); @@ -170,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); } @@ -179,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); } @@ -202,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); } @@ -211,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); } @@ -222,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( @@ -244,24 +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 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]; @@ -269,6 +395,8 @@ private ServiceResult Read( int bytesRead = m_strm.Read(data, 0, length); Debug.Assert(bytesRead >= 0); + m_totalBytesProcessed += bytesRead; + if (bytesRead < length) { byte[] bytes = new byte[bytesRead]; @@ -277,7 +405,11 @@ private ServiceResult Read( } } - return ServiceResult.Good; + return new ValueTask(new ReadMethodStateResult + { + ServiceResult = ServiceResult.Good, + Data = data + }); } private ServiceResult Write( @@ -286,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); @@ -294,18 +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 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( @@ -313,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); @@ -321,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; @@ -334,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( @@ -343,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( @@ -356,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; @@ -458,8 +685,6 @@ private ServiceResult CloseAndUpdate( } } - restartRequired = false; - // report the TrustListUpdatedAuditEvent m_node.ReportTrustListUpdatedAuditEvent( context, @@ -470,7 +695,11 @@ private ServiceResult CloseAndUpdate( result.StatusCode, m_logger); - return result; + return new CloseAndUpdateMethodStateResult + { + ServiceResult = result, + ApplyChangesRequired = false + }; } private ServiceResult AddCertificate( @@ -479,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( @@ -491,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 @@ -532,7 +786,10 @@ private ServiceResult AddCertificate( store?.Close(); } - m_node.LastUpdateTime.Value = DateTime.UtcNow; + lock (m_lock) + { + m_node.LastUpdateTime.Value = DateTime.UtcNow; + } } } @@ -546,7 +803,10 @@ private ServiceResult AddCertificate( result.StatusCode, m_logger); - return result; + return new AddCertificateMethodStateResult + { + ServiceResult = result + }; } private ServiceResult RemoveCertificate( @@ -556,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, @@ -567,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; } } @@ -654,7 +941,10 @@ private ServiceResult RemoveCertificate( result.StatusCode, m_logger); - return result; + return new RemoveCertificateMethodStateResult + { + ServiceResult = result + }; } private static MemoryStream EncodeTrustListData( @@ -697,7 +987,8 @@ private static TrustListDataType DecodeTrustListData( private async Task UpdateStoreCrlsAsync( CertificateStoreIdentifier storeIdentifier, - X509CRLCollection updatedCrls) + X509CRLCollection updatedCrls, + CancellationToken cancellationToken = default) { bool result = true; try @@ -712,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 @@ -741,7 +1032,8 @@ private async Task UpdateStoreCrlsAsync( private async Task UpdateStoreCertificatesAsync( CertificateStoreIdentifier storeIdentifier, - X509Certificate2Collection updatedCerts) + X509Certificate2Collection updatedCerts, + CancellationToken cancellationToken = default) { bool result = true; try @@ -756,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; } @@ -774,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 @@ -825,5 +1117,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/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/Diagnostics/DiagnosticsNodeManager.cs b/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs index 0ed69b096..b2e7036e0 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. @@ -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/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/Stack/Opc.Ua.Core/Stack/Types/SoftwareCertificate.cs b/Libraries/Opc.Ua.Server/NodeManager/ICoreNodeManager.cs similarity index 54% rename from Stack/Opc.Ua.Core/Stack/Types/SoftwareCertificate.cs rename to Libraries/Opc.Ua.Server/NodeManager/ICoreNodeManager.cs index 7a8144b03..4192d5651 100644 --- a/Stack/Opc.Ua.Core/Stack/Types/SoftwareCertificate.cs +++ b/Libraries/Opc.Ua.Server/NodeManager/ICoreNodeManager.cs @@ -27,51 +27,30 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ -using System; -using System.IO; -using System.Runtime.Serialization; -using System.Security.Cryptography.X509Certificates; +using System.Collections.Generic; -namespace Opc.Ua +namespace Opc.Ua.Server { /// - /// The SoftwareCertificate class. + /// The default node manager for the server. /// - public class SoftwareCertificate + /// + /// Every Server has one instance of this NodeManager. + /// It stores objects that implement ILocalNode and indexes them by NodeId. + /// + public interface ICoreNodeManager : INodeManager { /// - /// The SignedSoftwareCertificate that contains the SoftwareCertificate + /// Imports the nodes from a dictionary of NodeState objects. /// - public X509Certificate2 SignedCertificate { get; set; } + void ImportNodes(ISystemContext context, IEnumerable predefinedNodes); /// - /// Validates a software certificate. + /// Imports the nodes from a dictionary of NodeState objects. /// - 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; - } + 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..9c10d8c26 --- /dev/null +++ b/Libraries/Opc.Ua.Server/NodeManager/IMasterNodeManager.cs @@ -0,0 +1,354 @@ +/* ======================================================================== + * 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. + /// + ConfigurationNodeManager ConfigurationNodeManager { get; } + + /// + /// Returns the core node manager. + /// + CoreNodeManager CoreNodeManager { get; } + + /// + /// Returns the diagnostics node manager. + /// + DiagnosticsNodeManager 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 38ebb8fee..8d95b39f7 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; - /// - /// Returns the diagnostics node manager. - /// + /// public DiagnosticsNodeManager DiagnosticsNodeManager => m_nodeManagers[0].SyncNodeManager as DiagnosticsNodeManager; - /// - /// Returns the configuration node manager. - /// + /// public ConfigurationNodeManager ConfigurationNodeManager => m_nodeManagers[0].SyncNodeManager as ConfigurationNodeManager; - /// - /// 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,8 @@ 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) { (object handle, IAsyncNodeManager nodeManager) result = @@ -658,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) { @@ -706,14 +642,13 @@ 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(); } - /// - /// Adds the references to the target. - /// + /// public virtual async ValueTask AddReferencesAsync(NodeId sourceId, IList references, CancellationToken cancellationToken = default) @@ -734,14 +669,13 @@ await nodeManager.AddReferencesAsync(map, cancellationToken) /// /// Deletes the references to the target. /// + [Obsolete("Use DeleteReferencesAsync")] 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) @@ -771,17 +705,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++) @@ -811,10 +741,7 @@ await nodeManager.DeleteReferenceAsync( } } - /// - /// Registers a set of node ids. - /// - /// is null. + /// public virtual void RegisterNodes( OperationContext context, NodeIdCollection nodesToRegister, @@ -853,10 +780,7 @@ public virtual void RegisterNodes( */ } - /// - /// Unregisters a set of node ids. - /// - /// is null. + /// public virtual void UnregisterNodes( OperationContext context, NodeIdCollection nodesToUnregister) @@ -890,6 +814,7 @@ public virtual void UnregisterNodes( /// /// is null. /// + [Obsolete("Use TranslateBrowsePathsToNodeIdsAsync instead.")] public virtual void TranslateBrowsePathsToNodeIds( OperationContext context, BrowsePathCollection browsePaths, @@ -901,11 +826,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, @@ -1283,11 +1204,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, @@ -1471,11 +1388,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, @@ -1882,11 +1795,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, @@ -2026,10 +1935,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, @@ -2157,10 +2063,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, @@ -2269,9 +2172,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, @@ -2406,11 +2307,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, @@ -2523,9 +2420,7 @@ await nodeManager.CallAsync( return (results, diagnosticInfos); } - /// - /// Handles condition refresh request. - /// + /// public virtual async ValueTask ConditionRefreshAsync( OperationContext context, IList monitoredItems, @@ -2551,6 +2446,7 @@ await nodeManager.ConditionRefreshAsync(context, monitoredItems, cancellationTok /// is null. /// /// + [Obsolete("Use CreateMonitoredItemsAsync")] public virtual void CreateMonitoredItems( OperationContext context, uint subscriptionId, @@ -2574,12 +2470,7 @@ public virtual void CreateMonitoredItems( createDurable).AsTask().GetAwaiter().GetResult(); } - /// - /// Creates a set of monitored items. - /// - /// is null. - /// - /// + /// public virtual async ValueTask CreateMonitoredItemsAsync( OperationContext context, uint subscriptionId, @@ -2854,11 +2745,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, @@ -2988,6 +2875,7 @@ await manager.SubscribeToAllEventsAsync( /// /// is null. /// + [Obsolete("Use ModifyMonitoredItemsAsync")] public virtual void ModifyMonitoredItems( OperationContext context, TimestampsToReturn timestampsToReturn, @@ -3005,11 +2893,7 @@ public virtual void ModifyMonitoredItems( filterResults).AsTask().GetAwaiter().GetResult(); } - /// - /// Modifies a set of monitored items. - /// - /// is null. - /// + /// public virtual async ValueTask ModifyMonitoredItemsAsync( OperationContext context, TimestampsToReturn timestampsToReturn, @@ -3207,6 +3091,7 @@ await nodeManager.SubscribeToAllEventsAsync( /// Transfers a set of monitored items. /// /// is null. + [Obsolete("User TransferMonitoredItemsAsync")] public virtual void TransferMonitoredItems( OperationContext context, bool sendInitialValues, @@ -3220,10 +3105,7 @@ public virtual void TransferMonitoredItems( errors).AsTask().GetAwaiter().GetResult(); } - /// - /// Transfers a set of monitored items. - /// - /// is null. + /// public virtual async ValueTask TransferMonitoredItemsAsync( OperationContext context, bool sendInitialValues, @@ -3273,6 +3155,7 @@ await nodeManager.TransferMonitoredItemsAsync( /// Deletes a set of monitored items. /// /// is null. + [Obsolete("Use DeleteMonitoredItemsAsync")] public virtual void DeleteMonitoredItems( OperationContext context, uint subscriptionId, @@ -3286,10 +3169,7 @@ public virtual void DeleteMonitoredItems( errors).AsTask().GetAwaiter().GetResult(); } - /// - /// Deletes a set of monitored items. - /// - /// is null. + /// public virtual async ValueTask DeleteMonitoredItemsAsync( OperationContext context, uint subscriptionId, @@ -3406,10 +3286,7 @@ await nodeManager.SubscribeToAllEventsAsync( } } - /// - /// Changes the monitoring mode for a set of items. - /// - /// is null. + /// public virtual async ValueTask SetMonitoringModeAsync( OperationContext context, MonitoringMode monitoringMode, @@ -3504,14 +3381,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); /// @@ -3693,7 +3566,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 dee2635d4..bbc42ec23 100644 --- a/Libraries/Opc.Ua.Server/Server/IServerInternal.cs +++ b/Libraries/Opc.Ua.Server/Server/IServerInternal.cs @@ -86,6 +86,12 @@ 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. /// @@ -104,6 +110,12 @@ public interface IServerInternal : IAuditEventServer, IDisposable /// The diagnostics node manager. DiagnosticsNodeManager DiagnosticsNodeManager { get; } + /// + /// Returns the node manager that managers the server configuration. + /// + /// The configuration node manager. + ConfigurationNodeManager ConfigurationNodeManager { get; } + /// /// The manager for events that all components use to queue events that occur. /// @@ -240,7 +252,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. @@ -287,6 +300,12 @@ void CreateServerObject( /// The node manager. void SetNodeManager(MasterNodeManager 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 6032cb8b0..65ac02155 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) { 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. /// @@ -277,6 +287,9 @@ public void SetModellingRulesManager(ModellingRulesManager modellingRulesManager /// The node manager. public MasterNodeManager NodeManager { get; private set; } + /// + public IMainNodeManagerFactory MainNodeManagerFactory { get; private set; } + /// /// The internal node manager for the servers. /// @@ -289,6 +302,9 @@ public void SetModellingRulesManager(ModellingRulesManager modellingRulesManager /// The diagnostics node manager. public DiagnosticsNodeManager DiagnosticsNodeManager { get; private set; } + /// + public ConfigurationNodeManager ConfigurationNodeManager { get; private set; } + /// /// The manager for events that all components use to queue events that occur. /// @@ -494,7 +510,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 +519,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); } /// @@ -597,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) @@ -843,6 +861,7 @@ private void OnReadServerStatus( { serverStatusState.Timestamp = now; serverStatusState.CurrentTime.Timestamp = now; + serverStatusState.State.Timestamp = now; } } } diff --git a/Libraries/Opc.Ua.Server/Server/StandardServer.cs b/Libraries/Opc.Ua.Server/Server/StandardServer.cs index 834e47ea4..0234add73 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. @@ -346,7 +344,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 +525,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 +574,6 @@ X509Certificate2Collection clientCertificateChain ServerNonce = serverNonce, ServerCertificate = serverCertificate, ServerEndpoints = serverEndpoints, - ServerSoftwareCertificates = serverSoftwareCertificates, ServerSignature = serverSignature, MaxRequestMessageSize = maxRequestMessageSize }; @@ -724,75 +717,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 +749,7 @@ public override async Task ActivateSessionAsync( ServerInternal.ReportAuditActivateSessionEvent( m_logger, context?.AuditEntryId, - session, - softwareCertificates); + session); ResponseHeader responseHeader = CreateResponse(requestHeader, StatusCodes.Good); @@ -845,7 +776,6 @@ public override async Task ActivateSessionAsync( m_logger, context?.AuditEntryId, session, - softwareCertificates, e); lock (ServerInternal.DiagnosticsWriteLock) @@ -1542,7 +1472,7 @@ public override async Task HistoryUpdateAsync( /// /// Returns a object /// - public override Task CreateSubscriptionAsync( + public override async Task CreateSubscriptionAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, double requestedPublishingInterval, @@ -1560,7 +1490,7 @@ public override Task CreateSubscriptionAsync( try { - ServerInternal.SubscriptionManager.CreateSubscription( + CreateSubscriptionResponse response = await ServerInternal.SubscriptionManager.CreateSubscriptionAsync( context, requestedPublishingInterval, requestedLifetimeCount, @@ -1568,19 +1498,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) { @@ -1610,7 +1532,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, @@ -1626,19 +1548,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) { @@ -1670,7 +1588,7 @@ public override Task TransferSubscriptionsAsync( /// /// Returns a object /// - public override Task DeleteSubscriptionsAsync( + public override async Task DeleteSubscriptionsAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, UInt32Collection subscriptionIds, @@ -1685,18 +1603,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) { @@ -2073,7 +1987,7 @@ public override Task SetTriggeringAsync( /// /// Returns a object /// - public override Task CreateMonitoredItemsAsync( + public override async Task CreateMonitoredItemsAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, uint subscriptionId, @@ -2090,20 +2004,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) { @@ -2137,7 +2047,7 @@ public override Task CreateMonitoredItemsAsync( /// /// Returns a object /// - public override Task ModifyMonitoredItemsAsync( + public override async Task ModifyMonitoredItemsAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, uint subscriptionId, @@ -2154,20 +2064,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) { @@ -2200,7 +2106,7 @@ public override Task ModifyMonitoredItemsAsync( /// /// Returns a object /// - public override Task DeleteMonitoredItemsAsync( + public override async Task DeleteMonitoredItemsAsync( SecureChannelContext secureChannelContext, RequestHeader requestHeader, uint subscriptionId, @@ -2216,19 +2122,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) { @@ -2368,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 @@ -2427,16 +2324,14 @@ 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); - - // 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, @@ -2448,109 +2343,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 + { + await client.CloseAsync(ct).ConfigureAwait(false); + client = null; + } + catch (Exception e) { - 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); - } + 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; } @@ -2728,21 +2623,11 @@ 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. /// - /// The request header. /// The secure channel context. + /// The request header. /// Type of the request. /// protected virtual OperationContext ValidateRequest( @@ -3153,6 +3038,11 @@ 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( @@ -3758,6 +3648,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. /// @@ -3844,54 +3747,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/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/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..3f5a565cb 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,10 +2152,15 @@ private void PublishSubscriptions(object data) break; } - int delay = (int)(DateTime.UtcNow - start).TotalMilliseconds; - timeToWait = sleepCycle; + 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( @@ -2201,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( @@ -2228,17 +2257,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 +2276,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/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/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; 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); } 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/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; } 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/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"); 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 e87855847..b772e2271 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( @@ -515,10 +515,19 @@ 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|| + e.Status?.StatusCode == StatusCodes.BadSecureChannelClosed || + e.Status?.StatusCode == StatusCodes.BadRequestInterrupted) + { + return; + } + m_logger.LogError( "Session '{SessionName}' keep alive error: {StatusCode}", session.SessionName, - e.Status.ToLongString()); + e.Status?.ToLongString()); } } } diff --git a/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs b/Tests/Opc.Ua.Client.Tests/ClientTestFramework.cs index 3bcce121c..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; } @@ -70,6 +71,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 +166,7 @@ await CreateReferenceServerFixtureAsync( .Config .TransportQuotas .MaxStringLength = TransportQuotaMaxStringLength; + ClientFixture.Config.TransportQuotas.SecurityTokenLifetime = SecurityTokenLifetime; if (!string.IsNullOrEmpty(customUrl)) { @@ -215,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); @@ -224,6 +228,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..f159f2a3c --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/ConnectionStabilityTest.cs @@ -0,0 +1,459 @@ +/* ======================================================================== + * 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] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Category("Client")] + public class ConnectionStabilityTest : ClientTestFramework + { + /// + /// 5 minutes for CI + /// + private const int kSecurityTokenLifetimeCIMs = 5 * 60 * 1000; + + /// + /// 10 seconds for local testing + /// + private const int kSecurityTokenLifetimeLocalMs = 10 * 1000; + + /// + /// Report status every 60 seconds + /// + private const int kStatusReportIntervalSeconds = 60; + + /// + /// Accept 95% of expected notifications (5% tolerance) + /// + private const double kNotificationToleranceRatio = 0.95; + + public ConnectionStabilityTest() + : base(Utils.UriSchemeOpcTcp) + { + SupportsExternalServerUrl = true; + } + + [Test] + [Order(100)] + public async Task ShortHaulStabilityTestAsync() + { + try + { + SecurityTokenLifetime = kSecurityTokenLifetimeLocalMs; + await OneTimeSetUpAsync().ConfigureAwait(false); + + // 2 minutes for local testing + await RunStabilityTestAsync(2).ConfigureAwait(false); + } + finally + { + await OneTimeTearDownAsync().ConfigureAwait(false); + } + } + + [Test] + [Order(100)] + [Explicit] + [Category("ConnectionStability")] + public async Task LongHaulStabilityTestAsync() + { + 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); + } + } + + /// + /// 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 + /// + private async Task RunStabilityTestAsync(int testDurationMinutes) + { + // Get test duration from environment variable or use default + int testDurationSeconds = testDurationMinutes * 60; + int tokenLifetimeMs = SecurityTokenLifetime; + + 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(kStatusReportIntervalSeconds), + statusReportingCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + reportCount++; + int totalNotifications = valueChanges.Values.Sum(); + int elapsedMinutes = reportCount * kStatusReportIntervalSeconds / 60; + + TestContext.Out.WriteLine( + $"[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 + { + TestContext.Out.WriteLine("Per-node notification counts:"); + foreach (KeyValuePair kvp in valueChanges.OrderBy(x => x.Key.ToString())) + { + TestContext.Out.WriteLine($" {kvp.Key}: {kvp.Value} notifications"); + } + } +#endif + } + }, 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)) + { +#if DEBUG + TestContext.Out.WriteLine($" {nodeId}: {changes} notifications"); +#endif + if (changes < (writeCount * kNotificationToleranceRatio)) + { + allNodesReceivedData = false; + TestContext.Out.WriteLine($" WARNING: Expected at least {writeCount * kNotificationToleranceRatio: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}"); + } + } + } + } + } +} 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(); } 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 })); + } + } + } +} 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); + } + } + } } } 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, 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 9ad403374..703b983d2 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 { @@ -273,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() { @@ -1340,5 +1540,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"])); + } } } 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. /// 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..070ce96aa 100644 --- a/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs +++ b/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs @@ -43,15 +43,16 @@ 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; } - 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. @@ -236,8 +237,9 @@ private static void RegisterDefaultUsers(IUserDatabase userDatabase) } private static async Task LoadAsync( - ApplicationInstance application, - int basePort) + IApplicationInstance application, + 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; + } + } +} 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 9d7cf5a36..f69106e8c 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); @@ -989,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 @@ -1000,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, @@ -1014,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, @@ -1038,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)); @@ -1078,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, @@ -1122,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, @@ -1131,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"); } } 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; } diff --git a/UA.slnx b/UA.slnx index 3ffdbdb9f..5029191a9 100644 --- a/UA.slnx +++ b/UA.slnx @@ -69,6 +69,7 @@ +