diff --git a/.github/workflows/buildandtest.yml b/.github/workflows/buildandtest.yml
index a7e780b035..51916aee9a 100644
--- a/.github/workflows/buildandtest.yml
+++ b/.github/workflows/buildandtest.yml
@@ -17,6 +17,10 @@ jobs:
build-and-test:
name: test-${{matrix.os}}-${{matrix.csproj}}
runs-on: ${{ matrix.os }}
+ # Per-job time cap. The non-Conformance test projects all finish well
+ # inside an hour; the Conformance suite is split into shards in the
+ # `build-and-test-conformance` job below and has its own cap.
+ timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -55,8 +59,11 @@ jobs:
run: dotnet build ${{ env.CSPROJECT }} --framework ${{ matrix.framework }} --configuration ${{ matrix.configuration }} /p:CustomTestTarget=${{ matrix.customtesttarget }}
- name: Test
- # note: /p:CollectCoverage=true is only used to disable deterministic builds
- run: dotnet test ${{ env.CSPROJECT }} --no-build --framework ${{ matrix.framework }} --logger trx --configuration ${{ matrix.configuration }} /p:CollectCoverage=true /p:CustomTestTarget=${{ matrix.customtesttarget }} --collect:"XPlat Code Coverage" --settings ./Tests/coverlet.runsettings.xml --results-directory ${{ env.TESTRESULTS }}
+ # note: /p:CollectCoverage=true is only used to disable deterministic builds.
+ # --blame-hang-timeout terminates the test host if a single test hangs > 10 min
+ # and dumps a process dump, instead of letting the runner stall until GHA
+ # evicts it. --blame writes a sequence.xml so we know which test was running.
+ run: dotnet test ${{ env.CSPROJECT }} --no-build --framework ${{ matrix.framework }} --logger trx --configuration ${{ matrix.configuration }} /p:CollectCoverage=true /p:CustomTestTarget=${{ matrix.customtesttarget }} --collect:"XPlat Code Coverage" --settings ./Tests/coverlet.runsettings.xml --results-directory ${{ env.TESTRESULTS }} --blame --blame-hang-timeout 10m --blame-hang-dump-type mini
- name: Upload test results
uses: actions/upload-artifact@v7
@@ -79,6 +86,95 @@ jobs:
# Use always() to always run this step to publish test results when there are test failures
if: ${{ always() }}
+ build-and-test-conformance:
+ name: test-${{matrix.os}}-Conformance-${{matrix.shard}}
+ runs-on: ${{ matrix.os }}
+ # The conformance test suite has ~3,200 NUnit fixtures. We shard it into
+ # 6 parallel jobs to keep wall-time per shard under ~25 min on the slow
+ # hosted runners (was 50–100 min as a single job). Each shard uses a
+ # --filter that pins to its folders AND excludes the LongRunning
+ # category, which gets its own shard with a more generous budget.
+ timeout-minutes: 90
+ strategy:
+ fail-fast: false
+ matrix:
+ # os: [ubuntu-latest, windows-latest, macOS-latest] - disable mac os due to cost
+ os: [ubuntu-latest, windows-latest]
+ shard:
+ - Security
+ - Subscription
+ - InformationModel
+ - AlarmsHistory
+ - Discovery
+ - LongRunning
+ include:
+ - framework: 'net10.0'
+ dotnet-version: '10.0.x'
+ configuration: 'Release'
+ customtesttarget: net10.0
+ - shard: Security
+ filter: '(FullyQualifiedName~Conformance.Tests.Security.|FullyQualifiedName~Conformance.Tests.Auditing.)&TestCategory!=LongRunning'
+ - shard: Subscription
+ filter: '(FullyQualifiedName~Conformance.Tests.SubscriptionServices.|FullyQualifiedName~Conformance.Tests.MonitoredItemServices.)&TestCategory!=LongRunning'
+ - shard: InformationModel
+ filter: '(FullyQualifiedName~Conformance.Tests.InformationModel.|FullyQualifiedName~Conformance.Tests.AddressSpaceModel.|FullyQualifiedName~Conformance.Tests.AttributeServices.|FullyQualifiedName~Conformance.Tests.ViewServices.|FullyQualifiedName~Conformance.Tests.MethodServices.|FullyQualifiedName~Conformance.Tests.NodeManagement.|FullyQualifiedName~Conformance.Tests.AliasName.)&TestCategory!=LongRunning'
+ - shard: AlarmsHistory
+ filter: '(FullyQualifiedName~Conformance.Tests.AlarmsAndConditions.|FullyQualifiedName~Conformance.Tests.HistoricalAccess.|FullyQualifiedName~Conformance.Tests.DataAccess.|FullyQualifiedName~Conformance.Tests.FileSystem.)&TestCategory!=LongRunning'
+ - shard: Discovery
+ filter: '(FullyQualifiedName~Conformance.Tests.Discovery.|FullyQualifiedName~Conformance.Tests.DiscoveryServices.|FullyQualifiedName~Conformance.Tests.GDS.|FullyQualifiedName~Conformance.Tests.SessionServices.|FullyQualifiedName~Conformance.Tests.Miscellaneous.|FullyQualifiedName~Conformance.Tests.Mock.)&TestCategory!=LongRunning'
+ - shard: LongRunning
+ filter: 'TestCategory=LongRunning'
+
+ env:
+ OS: ${{ matrix.os }}
+ DOTNET_VERSION: ${{ matrix.dotnet-version }}
+ CONFIGURATION: ${{ matrix.configuration }}
+ CSPROJ: Conformance
+ CSPROJECT: "./Tests/Opc.Ua.Conformance.Tests/Opc.Ua.Conformance.Tests.csproj"
+ TESTRESULTS: "TestResults-Conformance-${{matrix.shard}}-${{matrix.os}}-${{matrix.framework}}-${{matrix.configuration}}"
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET ${{ matrix.dotnet-version }}
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: ${{ matrix.dotnet-version }}
+
+ - name: Set Cloud Version
+ shell: pwsh
+ run: ./.azurepipelines/set-version.ps1
+
+ - name: Build
+ run: dotnet build ${{ env.CSPROJECT }} --framework ${{ matrix.framework }} --configuration ${{ matrix.configuration }} /p:CustomTestTarget=${{ matrix.customtesttarget }}
+
+ - name: Test
+ # The Conformance suite has individual tests that legitimately take 1-3 min
+ # under load (keep-alive cycles, slow-sampling waits); the LongRunning
+ # shard concentrates those into a separate budget. Keep --blame-hang-timeout
+ # generous enough to absorb a normal test on top.
+ run: dotnet test ${{ env.CSPROJECT }} --no-build --framework ${{ matrix.framework }} --logger trx --configuration ${{ matrix.configuration }} /p:CollectCoverage=true /p:CustomTestTarget=${{ matrix.customtesttarget }} --collect:"XPlat Code Coverage" --settings ./Tests/coverlet.runsettings.xml --results-directory ${{ env.TESTRESULTS }} --filter "${{ matrix.filter }}" --blame --blame-hang-timeout 10m --blame-hang-dump-type mini
+
+ - name: Upload test results
+ uses: actions/upload-artifact@v7
+ with:
+ name: dotnet-results-Conformance-${{matrix.shard}}-${{matrix.os}}-${{matrix.framework}}-${{matrix.configuration}}
+ path: ${{ env.TESTRESULTS }}
+ if: ${{ always() }}
+
+ - name: Upload to Codecov
+ uses: codecov/codecov-action@v6
+ with:
+ name: codecov-umbrella
+ token: ${{ secrets.CODECOV_TOKEN }}
+ directory: ${{ env.TESTRESULTS }}
+ env_vars: CSPROJ,OS,DOTNET_VERSION,CONFIGURATION
+ fail_ci_if_error: false
+ verbose: true
+ if: ${{ always() }}
+
aot-test:
name: aot-${{ matrix.os }}
runs-on: ${{ matrix.os }}
diff --git a/Applications/ConsoleLdsServer/ConsoleLdsServer.csproj b/Applications/ConsoleLdsServer/ConsoleLdsServer.csproj
new file mode 100644
index 0000000000..22e67de0a8
--- /dev/null
+++ b/Applications/ConsoleLdsServer/ConsoleLdsServer.csproj
@@ -0,0 +1,30 @@
+
+
+ $(AppTargetFrameWorks)
+ ConsoleLdsServer
+ Exe
+ ConsoleLdsServer
+ OPC Foundation
+ .NET Console Local Discovery Server (LDS / LDS-ME)
+ Copyright © 2004-2025 OPC Foundation, Inc
+ Opc.Ua.Lds.Server.Console
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
diff --git a/Applications/ConsoleLdsServer/Lds.Server.Config.xml b/Applications/ConsoleLdsServer/Lds.Server.Config.xml
new file mode 100644
index 0000000000..e25dc4cdfa
--- /dev/null
+++ b/Applications/ConsoleLdsServer/Lds.Server.Config.xml
@@ -0,0 +1,106 @@
+
+
+ OPC UA Local Discovery Server
+ urn:localhost:UA:LocalDiscoveryServer
+ uri:opcfoundation.org:LocalDiscoveryServer
+ DiscoveryServer_3
+
+
+
+ Directory
+ %LocalApplicationData%/OPC Foundation/pki/own
+ CN=OPC UA Local Discovery Server, C=US, S=Arizona, O=OPC Foundation, DC=localhost
+ RsaSha256
+
+
+
+ Directory
+ %LocalApplicationData%/OPC Foundation/pki/issuer
+
+
+ Directory
+ %LocalApplicationData%/OPC Foundation/pki/trusted
+
+
+ Directory
+ %LocalApplicationData%/OPC Foundation/pki/rejected
+
+ 5
+ false
+ true
+ true
+ 2048
+ false
+ true
+
+
+
+ 120000
+ 1048576
+ 1048576
+ 65535
+ 4194304
+ 65535
+ 30000
+ 3600000
+
+
+
+
+ opc.tcp://localhost:4840
+
+
+
+ None_1
+ http://opcfoundation.org/UA/SecurityPolicy#None
+
+
+ Sign_2
+ http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256
+
+
+ SignAndEncrypt_3
+ http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256
+
+
+ 5
+ 100
+ 2000
+
+
+ Anonymous_0
+
+
+ false
+ 0
+ 1000
+ 600000
+
+ http://opcfoundation.org/UA-Profile/Server/LocalDiscovery2017
+
+ 5
+
+ LDS
+
+
+ PFX
+ PEM
+
+ 0
+ false
+
+
+ %LocalApplicationData%/OPC Foundation/Logs/Opc.Ua.LocalDiscoveryServer.log.txt
+ true
+ 515
+
+
diff --git a/Applications/ConsoleLdsServer/Program.cs b/Applications/ConsoleLdsServer/Program.cs
new file mode 100644
index 0000000000..5595233302
--- /dev/null
+++ b/Applications/ConsoleLdsServer/Program.cs
@@ -0,0 +1,197 @@
+/* ========================================================================
+ * 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.CommandLine;
+using System.Diagnostics;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Configuration;
+using Opc.Ua.Lds.Server;
+using Serilog;
+using Serilog.Events;
+
+namespace Opc.Ua.Lds.Server.Console
+{
+ ///
+ /// Console host for the OPC UA Local Discovery Server (LDS / LDS-ME).
+ ///
+ public static class Program
+ {
+ private const string ApplicationName = "ConsoleLdsServer";
+ private const string ConfigSectionName = "Lds.Server";
+
+ public static Task Main(string[] args)
+ {
+ System.Console.WriteLine(".NET OPC UA Local Discovery Server");
+ System.Console.WriteLine(
+ "OPC UA library: {0} @ {1} -- {2}",
+ Utils.GetAssemblyBuildNumber(),
+ Utils.GetAssemblyTimestamp().ToString("G", CultureInfo.InvariantCulture),
+ Utils.GetAssemblySoftwareVersion());
+
+ var consoleOption = new Option("--console", "-c") { Description = "log to console" };
+ var autoAcceptOption = new Option("--autoaccept", "-a") { Description = "auto accept untrusted certificates (testing only)" };
+ var noMdnsOption = new Option("--no-mdns") { Description = "disable mDNS multicast (LDS-ME) advertisement" };
+ var loopbackMdnsOption = new Option("--mdns-loopback") { Description = "restrict mDNS to the loopback interface (testing)" };
+ var timeoutOption = new Option("--timeout", "-t")
+ {
+ Description = "timeout in seconds before exiting (-1 = run until Ctrl+C)",
+ DefaultValueFactory = _ => -1
+ };
+
+ var rootCommand = new RootCommand($"Usage: dotnet {ApplicationName}.dll [OPTIONS]")
+ {
+ consoleOption,
+ autoAcceptOption,
+ noMdnsOption,
+ loopbackMdnsOption,
+ timeoutOption
+ };
+
+ rootCommand.SetAction(async (parseResult, cancellationToken) =>
+ {
+ bool logConsole = parseResult.GetValue(consoleOption);
+ bool autoAccept = parseResult.GetValue(autoAcceptOption);
+ bool noMdns = parseResult.GetValue(noMdnsOption);
+ bool loopbackMdns = parseResult.GetValue(loopbackMdnsOption);
+ int timeoutSec = parseResult.GetValue(timeoutOption);
+ int timeoutMs = timeoutSec >= 0 ? timeoutSec * 1000 : -1;
+
+ if (logConsole)
+ {
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information)
+ .CreateLogger();
+ }
+
+ ITelemetryContext telemetry = DefaultTelemetry.Create(builder =>
+ {
+ builder.SetMinimumLevel(LogLevel.Information);
+ if (logConsole)
+ {
+ builder.AddSerilog(Log.Logger);
+ }
+ });
+ Microsoft.Extensions.Logging.ILogger logger = telemetry.LoggerFactory.CreateLogger("Main");
+
+ var sw = Stopwatch.StartNew();
+ var application = new ApplicationInstance(telemetry)
+ {
+ ApplicationName = ApplicationName,
+ ApplicationType = ApplicationType.DiscoveryServer,
+ ConfigSectionName = ConfigSectionName
+ };
+
+ try
+ {
+ System.Console.WriteLine($"Loading configuration from {ConfigSectionName}.Config.xml.");
+ await application
+ .LoadApplicationConfigurationAsync(silent: false)
+ .ConfigureAwait(false);
+
+ System.Console.WriteLine("Checking the application certificate.");
+ bool ok = await application
+ .CheckApplicationInstanceCertificatesAsync(silent: false)
+ .ConfigureAwait(false);
+ if (!ok)
+ {
+ System.Console.Error.WriteLine("Application instance certificate invalid.");
+ return 1;
+ }
+
+ if (autoAccept)
+ {
+ application.ApplicationConfiguration.SecurityConfiguration
+ .AutoAcceptUntrustedCertificates = true;
+ }
+
+ var server = new LdsServer(telemetry);
+ if (!noMdns)
+ {
+ server.MulticastFactory = lds => new MulticastDiscovery(
+ lds.Store,
+ loopbackOnly: loopbackMdns,
+ logger: telemetry.LoggerFactory.CreateLogger());
+ }
+ server.Store.StartPruneTimer();
+
+ System.Console.WriteLine("Starting the LDS.");
+ await application.StartAsync(server, cancellationToken).ConfigureAwait(false);
+
+ foreach (EndpointDescription ep in server.GetEndpoints())
+ {
+ System.Console.WriteLine(" Endpoint: {0}", ep.EndpointUrl);
+ }
+
+ System.Console.WriteLine($"LDS started ({sw.ElapsedMilliseconds} ms). Press Ctrl-C to exit...");
+
+ try
+ {
+ if (timeoutMs >= 0)
+ {
+ using CancellationTokenSource timeoutCts =
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeoutMs);
+ await Task.Delay(Timeout.Infinite, timeoutCts.Token).ConfigureAwait(false);
+ }
+ else
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // expected — Ctrl-C or timeout.
+ }
+
+ System.Console.WriteLine("Stopping the LDS.");
+ await application.StopAsync(default).ConfigureAwait(false);
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ logger?.LogError(ex, "Fatal error starting LDS.");
+ System.Console.Error.WriteLine(ex);
+ return 2;
+ }
+ finally
+ {
+ (telemetry as IDisposable)?.Dispose();
+ }
+ });
+
+ return rootCommand.Parse(args).InvokeAsync();
+ }
+ }
+}
diff --git a/Applications/ConsoleReferenceServer/Program.cs b/Applications/ConsoleReferenceServer/Program.cs
index 428fc3f11d..9457a64344 100644
--- a/Applications/ConsoleReferenceServer/Program.cs
+++ b/Applications/ConsoleReferenceServer/Program.cs
@@ -144,7 +144,12 @@ public static Task Main(string[] args)
var sw = Stopwatch.StartNew();
// create the UA server
- var server = new UAServer(telemetry, t => new ReferenceServer(t))
+ var server = new UAServer(
+ telemetry,
+ t => new ReferenceServer(t)
+ {
+ EnableConformanceNodeManagers = cttMode
+ })
{
AutoAccept = autoAccept,
Password = password
diff --git a/Applications/Quickstarts.Servers/Alarms/AlarmHolders/AlarmConditionTypeHolder.cs b/Applications/Quickstarts.Servers/Alarms/AlarmHolders/AlarmConditionTypeHolder.cs
index 81b5e9db97..0bfb06fdf7 100644
--- a/Applications/Quickstarts.Servers/Alarms/AlarmHolders/AlarmConditionTypeHolder.cs
+++ b/Applications/Quickstarts.Servers/Alarms/AlarmHolders/AlarmConditionTypeHolder.cs
@@ -92,9 +92,12 @@ public void Initialize(
QualifiedName.From(BrowseNames.ShelvingState),
LocalizedText.From(BrowseNames.ShelvingState),
false);
+ alarm.ShelvingState.LastTransition ??=
+ alarm.ShelvingState.AddLastTransition(SystemContext);
}
// Off normal does not create MaxTimeShelved.
alarm.MaxTimeShelved ??= PropertyState.With(alarm);
+ alarm.LatchedState ??= alarm.AddLatchedState(SystemContext);
}
// Call the base class to set parameters
diff --git a/Applications/Quickstarts.Servers/Alarms/AlarmNodeManager.cs b/Applications/Quickstarts.Servers/Alarms/AlarmNodeManager.cs
index 5d8179bb67..8a37acae00 100644
--- a/Applications/Quickstarts.Servers/Alarms/AlarmNodeManager.cs
+++ b/Applications/Quickstarts.Servers/Alarms/AlarmNodeManager.cs
@@ -263,7 +263,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(mandatoryExclusiveLevel.AlarmNodeName, mandatoryExclusiveLevel);
@@ -275,7 +275,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(
mandatoryNonExclusiveLevel.AlarmNodeName,
mandatoryNonExclusiveLevel);
@@ -288,7 +288,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(offNormal.AlarmNodeName, offNormal);
AlarmHolder alarmCondition = new AlarmConditionHolder(
@@ -299,7 +299,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(alarmCondition.AlarmNodeName, alarmCondition);
AlarmHolder discrepancyAlarm = new DiscrepancyAlarmTypeHolder(
@@ -311,7 +311,7 @@ public override void CreateAddressSpace(
alarmControllerType,
interval,
discrepancyTargetSource.NodeId,
- optional: false);
+ optional: true);
m_alarms.Add(discrepancyAlarm.AlarmNodeName, discrepancyAlarm);
AlarmHolder limitAlarm = new LimitAlarmHolder(
@@ -322,7 +322,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(limitAlarm.AlarmNodeName, limitAlarm);
AlarmHolder exclusiveLimitAlarm = new ExclusiveLimitAlarmHolder(
@@ -333,7 +333,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(exclusiveLimitAlarm.AlarmNodeName, exclusiveLimitAlarm);
AlarmHolder exclusiveDeviationAlarm = new ExclusiveDeviationAlarmTypeHolder(
@@ -345,7 +345,7 @@ public override void CreateAddressSpace(
alarmControllerType,
interval,
setpointSource.NodeId,
- optional: false);
+ optional: true);
m_alarms.Add(exclusiveDeviationAlarm.AlarmNodeName, exclusiveDeviationAlarm);
AlarmHolder exclusiveRateOfChangeAlarm = new ExclusiveRateOfChangeAlarmTypeHolder(
@@ -356,7 +356,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(exclusiveRateOfChangeAlarm.AlarmNodeName, exclusiveRateOfChangeAlarm);
AlarmHolder nonExclusiveLimitAlarm = new NonExclusiveLimitAlarmHolder(
@@ -367,7 +367,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(nonExclusiveLimitAlarm.AlarmNodeName, nonExclusiveLimitAlarm);
AlarmHolder nonExclusiveDeviationAlarm = new NonExclusiveDeviationAlarmTypeHolder(
@@ -379,7 +379,7 @@ public override void CreateAddressSpace(
alarmControllerType,
interval,
setpointSource.NodeId,
- optional: false);
+ optional: true);
m_alarms.Add(nonExclusiveDeviationAlarm.AlarmNodeName, nonExclusiveDeviationAlarm);
AlarmHolder nonExclusiveRateOfChangeAlarm = new NonExclusiveRateOfChangeAlarmTypeHolder(
@@ -390,7 +390,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(
nonExclusiveRateOfChangeAlarm.AlarmNodeName,
nonExclusiveRateOfChangeAlarm);
@@ -403,7 +403,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(discreteAlarm.AlarmNodeName, discreteAlarm);
AlarmHolder systemOffNormalAlarm = new SystemOffNormalAlarmTypeHolder(
@@ -414,7 +414,7 @@ public override void CreateAddressSpace(
GetSupportedAlarmConditionType(ref conditionTypeIndex),
alarmControllerType,
interval,
- optional: false);
+ optional: true);
m_alarms.Add(systemOffNormalAlarm.AlarmNodeName, systemOffNormalAlarm);
AddPredefinedNode(SystemContext, alarmsFolder);
diff --git a/Applications/Quickstarts.Servers/FileSystem/DirectoryBrowser.cs b/Applications/Quickstarts.Servers/FileSystem/DirectoryBrowser.cs
new file mode 100644
index 0000000000..adf39d7235
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/DirectoryBrowser.cs
@@ -0,0 +1,225 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ using Opc.Ua;
+ using System.Collections.Generic;
+ using System.IO;
+
+ ///
+ /// Browses the file system folder and files
+ ///
+ public class DirectoryBrowser : NodeBrowser
+ {
+ ///
+ /// Create browser
+ ///
+ public DirectoryBrowser(ISystemContext context, ViewDescription view,
+ NodeId referenceType, bool includeSubtypes, BrowseDirection browseDirection,
+ QualifiedName browseName, IEnumerable additionalReferences,
+ bool internalOnly, DirectoryObjectState source)
+ : base(context, view, referenceType, includeSubtypes, browseDirection,
+ browseName, additionalReferences, internalOnly)
+ {
+ m_source = source;
+ m_stage = Stage.Begin;
+ }
+
+ ///
+ /// Returns the next reference.
+ ///
+ public override IReference Next()
+ {
+ lock (DataLock)
+ {
+ // enumerate pre-defined references.
+ IReference reference = base.Next();
+
+ if (reference != null)
+ {
+ return reference;
+ }
+
+ // don't start browsing huge number of references when only internal references are requested.
+ if (InternalOnly)
+ {
+ return null;
+ }
+
+ if (!IsRequired(ReferenceTypeIds.HasComponent, false))
+ {
+ return null;
+ }
+
+ if (m_stage == Stage.Begin)
+ {
+ string[] dirs;
+ string[] files;
+ try
+ {
+ dirs = Directory.GetDirectories(m_source.FullPath);
+ }
+ catch
+ {
+ dirs = System.Array.Empty();
+ }
+ try
+ {
+ files = Directory.GetFiles(m_source.FullPath);
+ }
+ catch
+ {
+ files = System.Array.Empty();
+ }
+ m_directories = new List(dirs);
+ m_filesPending = new List(files);
+ m_stage = Stage.Directories;
+ }
+
+ // enumerate segments.
+ if (m_stage == Stage.Directories)
+ {
+ reference = NextChild();
+
+ if (reference != null)
+ {
+ return reference;
+ }
+
+ m_stage = Stage.Files;
+ }
+
+ // enumerate files.
+ if (m_stage == Stage.Files)
+ {
+ reference = NextChild();
+
+ if (reference != null)
+ {
+ return reference;
+ }
+
+ m_stage = Stage.Done;
+ }
+
+ // all done.
+ return null;
+ }
+ }
+
+ ///
+ /// Returns the next child.
+ ///
+ private NodeStateReference NextChild()
+ {
+ NodeId targetId = NodeId.Null;
+
+ // check if a specific browse name is requested.
+ if (!BrowseName.IsNull)
+ {
+ // browse name must be qualified by the correct namespace.
+ if (m_source.BrowseName.NamespaceIndex != BrowseName.NamespaceIndex)
+ {
+ return null;
+ }
+
+ // look for matching directory.
+ if (m_stage == Stage.Directories && m_directories != null)
+ {
+ foreach (string name in m_directories)
+ {
+ if (BrowseName.Name == Path.GetFileName(name))
+ {
+ targetId = ModelUtils.ConstructIdForDirectory(name, m_source.NodeId.NamespaceIndex);
+ break;
+ }
+ }
+ m_directories = null;
+ }
+
+ // look for matching file.
+ if (m_stage == Stage.Files && m_filesPending != null)
+ {
+ foreach (string name in m_filesPending)
+ {
+ if (BrowseName.Name == Path.GetFileName(name))
+ {
+ targetId = ModelUtils.ConstructIdForFile(name, m_source.NodeId.NamespaceIndex);
+ break;
+ }
+ }
+ m_filesPending = null;
+ }
+ }
+ // return the child at the next position.
+ else
+ {
+ // look for next directory.
+ if (m_stage == Stage.Directories && m_directories != null && m_directories.Count > 0)
+ {
+ string name = m_directories[0];
+ m_directories.RemoveAt(0);
+ targetId = ModelUtils.ConstructIdForDirectory(name, m_source.NodeId.NamespaceIndex);
+ }
+ // look for next file.
+ else if (m_stage == Stage.Files && m_filesPending != null && m_filesPending.Count > 0)
+ {
+ string name = m_filesPending[0];
+ m_filesPending.RemoveAt(0);
+ targetId = ModelUtils.ConstructIdForFile(name, m_source.NodeId.NamespaceIndex);
+ }
+ }
+
+ // create reference.
+ if (!targetId.IsNull)
+ {
+ return new NodeStateReference(ReferenceTypeIds.HasComponent, false, targetId);
+ }
+
+ return null;
+ }
+
+ ///
+ /// The stages available in a browse operation.
+ ///
+ private enum Stage
+ {
+ Begin,
+ Directories,
+ Files,
+ Done
+ }
+
+ private Stage m_stage;
+ private readonly DirectoryObjectState m_source;
+ private List m_filesPending;
+ private List m_directories;
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/DirectoryObjectState.cs b/Applications/Quickstarts.Servers/FileSystem/DirectoryObjectState.cs
new file mode 100644
index 0000000000..b88f002430
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/DirectoryObjectState.cs
@@ -0,0 +1,331 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+
+ ///
+ /// A object which maps a segment to directory
+ ///
+ public class DirectoryObjectState : FileDirectoryState
+ {
+ ///
+ /// Gets the full path
+ ///
+ public string FullPath { get; }
+
+ ///
+ /// Is volume
+ ///
+ public bool IsVolume { get; }
+
+ ///
+ /// Create directory object
+ ///
+ public DirectoryObjectState(ISystemContext context, NodeId nodeId,
+ string path, bool isVolume) : base(null)
+ {
+ System.Diagnostics.Contracts.Contract.Assume(context != null);
+ FullPath = path;
+ IsVolume = isVolume;
+ TypeDefinitionId = ObjectTypeIds.FileDirectoryType;
+ SymbolicName = path;
+ NodeId = nodeId;
+ string name = isVolume ? path : ModelUtils.GetName(path);
+ BrowseName = new QualifiedName(name, nodeId.NamespaceIndex);
+ DisplayName = new LocalizedText(name);
+ Description = LocalizedText.Null;
+ WriteMask = 0;
+ UserWriteMask = 0;
+ EventNotifier = EventNotifiers.None;
+
+ DeleteFileSystemObject = new DeleteFileMethodState(this)
+ {
+ OnCall = OnDeleteFileSystemObject,
+ Executable = true,
+ UserExecutable = true
+ };
+ DeleteFileSystemObject.Create(context, MethodIds.FileDirectoryType_DeleteFileSystemObject,
+ new QualifiedName(BrowseNames.DeleteFileSystemObject),
+ new LocalizedText(BrowseNames.DeleteFileSystemObject), false);
+
+ CreateFile = new CreateFileMethodState(this)
+ {
+ OnCall = OnCreateFile,
+ Executable = true,
+ UserExecutable = true
+ };
+ CreateFile.Create(context, MethodIds.FileDirectoryType_CreateFile,
+ new QualifiedName(BrowseNames.CreateFile),
+ new LocalizedText(BrowseNames.CreateFile), false);
+
+ CreateDirectory = new CreateDirectoryMethodState(this)
+ {
+ OnCall = OnCreateDirectory,
+ Executable = true,
+ UserExecutable = true
+ };
+ CreateDirectory.Create(context, MethodIds.FileDirectoryType_CreateDirectory,
+ new QualifiedName(BrowseNames.CreateDirectory),
+ new LocalizedText(BrowseNames.CreateDirectory), false);
+
+ MoveOrCopy = new MoveOrCopyMethodState(this)
+ {
+ OnCall = OnMoveOrCopy,
+ Executable = true,
+ UserExecutable = true
+ };
+ MoveOrCopy.Create(context, MethodIds.FileDirectoryType_MoveOrCopy,
+ new QualifiedName(BrowseNames.MoveOrCopy),
+ new LocalizedText(BrowseNames.MoveOrCopy), false);
+ }
+
+ private ServiceResult OnMoveOrCopy(ISystemContext _context, MethodState _method,
+ NodeId _objectId, NodeId objectToMoveOrCopy, NodeId targetDirectory, bool createCopy,
+ string newName, ref NodeId newNodeId)
+ {
+ if (!FileSystemNodeId.TryParse(objectToMoveOrCopy, out FileSystemNodeId objectToMoveOrCopy2) ||
+ objectToMoveOrCopy2.RootType == ModelUtils.Volume)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidArgument,
+ "Source is not a directory or file");
+ }
+ if (!FileSystemNodeId.TryParse(targetDirectory, out FileSystemNodeId targetDirectory2) ||
+ targetDirectory2.RootType != ModelUtils.Directory)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidArgument,
+ "Target is not a directory");
+ }
+ string path = objectToMoveOrCopy2.RootId;
+ string dst = Path.Combine(targetDirectory2.RootId, newName ?? Path.GetFileName(path));
+ try
+ {
+ if (File.Exists(path))
+ {
+ if (createCopy)
+ {
+ File.Copy(path, dst);
+ }
+ else
+ {
+ File.Move(path, dst);
+ }
+ newNodeId = ModelUtils.ConstructIdForFile(dst,
+ NodeId.NamespaceIndex);
+ }
+ else if (Directory.Exists(path))
+ {
+ if (createCopy)
+ {
+ CopyDirectory(path, dst);
+ }
+ else
+ {
+ Directory.Move(path, dst);
+ }
+ newNodeId = ModelUtils.ConstructIdForDirectory(dst,
+ NodeId.NamespaceIndex);
+ }
+ else
+ {
+ return ServiceResult.Create(StatusCodes.BadNotFound,
+ $"File system object {path} not found");
+ }
+ return ServiceResult.Good;
+ }
+ catch (Exception ex)
+ {
+ return ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
+ "Failed to move or copy");
+ }
+ }
+
+ private ServiceResult OnCreateDirectory(ISystemContext _context, MethodState _method,
+ NodeId _objectId, string directoryName, ref NodeId directoryNodeId)
+ {
+ string name = Path.Combine(FullPath, directoryName);
+ if (File.Exists(name) || Directory.Exists(name))
+ {
+ return ServiceResult.Create(StatusCodes.BadBrowseNameDuplicated,
+ "Directory or file with same name exists");
+ }
+ Directory.CreateDirectory(name);
+ directoryNodeId = ModelUtils.ConstructIdForDirectory(name, NodeId.NamespaceIndex);
+ return ServiceResult.Good;
+ }
+
+ private ServiceResult OnCreateFile(ISystemContext _context, MethodState _method,
+ NodeId _objectId, string fileName, bool requestFileOpen, ref NodeId fileNodeId,
+ ref uint fileHandle)
+ {
+ string name = Path.Combine(FullPath, fileName);
+ if (File.Exists(name) || Directory.Exists(name))
+ {
+ return ServiceResult.Create(StatusCodes.BadBrowseNameDuplicated,
+ "Directory or file with same name exists");
+ }
+ fileNodeId = ModelUtils.ConstructIdForFile(name, NodeId.NamespaceIndex);
+ if (requestFileOpen)
+ {
+ if (!(_context.SystemHandle is FileSystem system) ||
+ !(system.GetHandle(fileNodeId) is FileHandle handle))
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "Failed to get handle");
+ }
+
+ return handle.Open(0x2, out fileHandle); // open for writing
+ }
+ try
+ {
+ using (FileStream f = File.Create(name))
+ {
+ }
+ }
+ catch (Exception ex)
+ {
+ return ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, null);
+ }
+ fileHandle = 0;
+ return StatusCodes.Good;
+ }
+
+ private ServiceResult OnDeleteFileSystemObject(ISystemContext _context,
+ MethodState _method, NodeId _objectId, NodeId objectToDelete)
+ {
+ if (!FileSystemNodeId.TryParse(objectToDelete, out FileSystemNodeId objectToDelete2))
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "Not a fileSystem object.");
+ }
+ string path = objectToDelete2.RootId;
+ try
+ {
+ switch (objectToDelete2.RootType)
+ {
+ case ModelUtils.File:
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ break;
+ }
+ return ServiceResult.Create(StatusCodes.BadNotFound,
+ $"File system object {path} not found");
+ case ModelUtils.Directory:
+ if (Directory.Exists(path))
+ {
+ Directory.Delete(path, true);
+ break;
+ }
+ return ServiceResult.Create(StatusCodes.BadNotFound,
+ $"File system object {path} not found");
+ case ModelUtils.Volume:
+ return ServiceResult.Create(StatusCodes.BadUserAccessDenied,
+ "Cannot delete root of filesystem");
+ default:
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "Not a fileSystem object.");
+ }
+ }
+ catch (Exception ex)
+ {
+ return ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
+ "Failed to delete file system object.");
+ }
+ return ServiceResult.Good;
+ }
+
+ ///
+ /// Create browser on directory
+ ///
+ public override INodeBrowser CreateBrowser(
+ ISystemContext context, ViewDescription view, NodeId referenceType,
+ bool includeSubtypes, BrowseDirection browseDirection,
+ QualifiedName browseName, IEnumerable additionalReferences,
+ bool internalOnly)
+ {
+ NodeBrowser browser = new DirectoryBrowser(
+ context, view, referenceType, includeSubtypes,
+ browseDirection, browseName, additionalReferences,
+ internalOnly, this);
+
+ PopulateBrowser(context, browser);
+ return browser;
+ }
+
+ ///
+ /// Populates the browser with references that meet the criteria.
+ ///
+ protected override void PopulateBrowser(ISystemContext context, NodeBrowser browser)
+ {
+ base.PopulateBrowser(context, browser);
+
+ // check if the parent segments need to be returned.
+ if (browser.IsRequired(ReferenceTypeIds.Organizes, true) && IsVolume)
+ {
+ // add reference to server
+ browser.Add(ReferenceTypeIds.Organizes, true, ObjectIds.Server);
+ }
+ else if (browser.IsRequired(ReferenceTypeIds.HasComponent, true) && !IsVolume)
+ {
+ string parent = Path.GetDirectoryName(FullPath);
+ if (!string.IsNullOrEmpty(parent))
+ {
+ if (Path.GetPathRoot(FullPath) == parent)
+ {
+ browser.Add(ReferenceTypeIds.HasComponent, true,
+ ModelUtils.ConstructIdForVolume(parent, NodeId.NamespaceIndex));
+ }
+ else
+ {
+ browser.Add(ReferenceTypeIds.HasComponent, true,
+ ModelUtils.ConstructIdForDirectory(parent, NodeId.NamespaceIndex));
+ }
+ }
+ }
+ }
+
+ private static void CopyDirectory(string sourcePath, string targetPath)
+ {
+ foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories))
+ {
+ Directory.CreateDirectory(dirPath.Replace(sourcePath, targetPath));
+ }
+ foreach (string newPath in Directory.GetFiles(sourcePath, "*", SearchOption.AllDirectories))
+ {
+ File.Copy(newPath, newPath.Replace(sourcePath, targetPath), true);
+ }
+ }
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/FileHandle.cs b/Applications/Quickstarts.Servers/FileSystem/FileHandle.cs
new file mode 100644
index 0000000000..15ded08b18
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/FileHandle.cs
@@ -0,0 +1,212 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Threading;
+
+ ///
+ /// File handle for an opened file. Provides read/write streams keyed by
+ /// the file handle integer returned to OPC UA clients via the Open method.
+ ///
+ public sealed class FileHandle : IDisposable
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ internal FileHandle(FileSystemNodeId nodeId)
+ {
+ NodeId = nodeId;
+ }
+
+ ///
+ /// The parsed node id of the file the handle refers to.
+ ///
+ internal FileSystemNodeId NodeId { get; }
+
+ private bool IsOpenForWrite => m_write != null;
+
+ private bool IsOpenForRead => m_reads.Count > 0;
+
+ ///
+ /// Length
+ ///
+ public long Length => new FileInfo(NodeId.RootId).Length;
+
+ ///
+ /// Can be written to
+ ///
+ public bool IsWriteable => !IsOpenForRead && !IsOpenForWrite
+ && !new FileInfo(NodeId.RootId).IsReadOnly;
+
+ ///
+ /// Last modification
+ ///
+ public DateTime LastModifiedTime => File.GetLastWriteTimeUtc(NodeId.RootId);
+
+ ///
+ /// How many file handles are open
+ ///
+ public ushort OpenCount => (ushort)(m_reads.Count + (IsOpenForWrite ? 1 : 0));
+
+ ///
+ /// Mime type
+ ///
+ public string MimeType { get; } = "text/plain";
+
+ ///
+ /// Max byte string length
+ ///
+ public uint MaxByteStringLength { get; } = 4 * 1024;
+
+ ///
+ /// Get stream
+ ///
+ public Stream GetStream(uint fileHandle)
+ {
+ lock (m_lock)
+ {
+ if (m_write != null && fileHandle == 1)
+ {
+ return m_write;
+ }
+ else if (m_reads.TryGetValue(fileHandle, out Stream stream))
+ {
+ return stream;
+ }
+ return null;
+ }
+ }
+
+ ///
+ /// Open
+ ///
+ public ServiceResult Open(byte mode, out uint fileHandle)
+ {
+ lock (m_lock)
+ {
+ fileHandle = 0u;
+ try
+ {
+ if (mode == 0x1)
+ {
+ if (m_write != null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File already open for write");
+ }
+ // read
+ var stream = new FileStream(NodeId.RootId,
+ FileMode.Open, FileAccess.Read);
+ fileHandle = ++m_handles;
+ m_reads.Add(fileHandle, stream);
+ }
+ else if ((mode & 0x2) != 0)
+ {
+ if (m_reads.Count != 0 || m_write != null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File already open for read or write");
+ }
+ if ((mode & 0x4) != 0)
+ {
+ // Erase = OpenOrCreate + Truncate
+ m_write = new FileStream(NodeId.RootId,
+ FileMode.Create, FileAccess.Write);
+ }
+ else if ((mode & 0x8) != 0)
+ {
+ // Append
+ m_write = new FileStream(NodeId.RootId,
+ FileMode.Append, FileAccess.Write);
+ }
+ else
+ {
+ // Open or create
+ m_write = new FileStream(NodeId.RootId,
+ FileMode.OpenOrCreate, FileAccess.Write);
+ }
+ fileHandle = 1u;
+ }
+ else
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidArgument,
+ "Unknown mode value.");
+ }
+ }
+ catch (Exception ex)
+ {
+ return ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
+ "Failed to open file");
+ }
+ }
+ return ServiceResult.Good;
+ }
+
+ public bool Close(uint fileHandle)
+ {
+ lock (m_lock)
+ {
+ if (m_write != null && fileHandle == 1)
+ {
+ m_write.Dispose();
+ m_write = null;
+ return true;
+ }
+ if (m_reads.TryGetValue(fileHandle, out Stream stream))
+ {
+ stream.Dispose();
+ m_reads.Remove(fileHandle);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void Dispose()
+ {
+ m_write?.Dispose();
+ foreach (Stream stream in m_reads.Values)
+ {
+ stream.Dispose();
+ }
+ m_reads.Clear();
+ }
+
+ private uint m_handles = 1;
+ private readonly Dictionary m_reads = new Dictionary();
+ private readonly Lock m_lock = new Lock();
+ private Stream m_write;
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/FileObjectState.cs b/Applications/Quickstarts.Servers/FileSystem/FileObjectState.cs
new file mode 100644
index 0000000000..ffed3f400d
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/FileObjectState.cs
@@ -0,0 +1,380 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ using Opc.Ua;
+ using System;
+ using System.IO;
+
+ ///
+ /// A object which maps a segment to a UA object.
+ ///
+ public class FileObjectState : FileState
+ {
+ ///
+ /// Gets the path to the file
+ ///
+ public string FullPath { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public FileObjectState(ISystemContext context, NodeId nodeId, string path)
+ : base(null)
+ {
+ System.Diagnostics.Contracts.Contract.Assume(context != null);
+ FullPath = path;
+
+ string name = Path.GetFileName(path);
+ TypeDefinitionId = ObjectTypeIds.FileType;
+ SymbolicName = path;
+ NodeId = nodeId;
+ BrowseName = new QualifiedName(name, nodeId.NamespaceIndex);
+ DisplayName = new LocalizedText(name);
+ Description = LocalizedText.Null;
+ WriteMask = 0;
+ UserWriteMask = 0;
+ EventNotifier = EventNotifiers.None;
+
+ OpenCount = PropertyState.With(this);
+ OpenCount.OnReadValue += OnOpenCount;
+ OpenCount.AccessLevel = AccessLevels.CurrentRead;
+ OpenCount.UserAccessLevel = AccessLevels.CurrentRead;
+ OpenCount.Create(context, VariableIds.FileType_OpenCount,
+ new QualifiedName(BrowseNames.OpenCount),
+ new LocalizedText(BrowseNames.OpenCount), true);
+
+ Writable = PropertyState.With(this);
+ Writable.OnReadValue += OnWritable;
+ Writable.AccessLevel = AccessLevels.CurrentRead;
+ Writable.UserAccessLevel = AccessLevels.CurrentRead;
+ Writable.Create(context, VariableIds.FileType_Writable,
+ new QualifiedName(BrowseNames.Writable),
+ new LocalizedText(BrowseNames.Writable), true);
+
+ UserWritable = PropertyState.With(this);
+ UserWritable.OnReadValue += OnWritable;
+ UserWritable.AccessLevel = AccessLevels.CurrentRead;
+ UserWritable.UserAccessLevel = AccessLevels.CurrentRead;
+ UserWritable.Create(context, VariableIds.FileType_UserWritable,
+ new QualifiedName(BrowseNames.UserWritable),
+ new LocalizedText(BrowseNames.UserWritable), true);
+
+ Size = PropertyState.With(this);
+ Size.OnReadValue += OnSize;
+ Size.AccessLevel = AccessLevels.CurrentRead;
+ Size.UserAccessLevel = AccessLevels.CurrentRead;
+ Size.Create(context, VariableIds.FileType_Size,
+ new QualifiedName(BrowseNames.Size),
+ new LocalizedText(BrowseNames.Size), true);
+
+ MimeType = PropertyState.With(this);
+ MimeType.OnReadValue += OnMimeType;
+ MimeType.AccessLevel = AccessLevels.CurrentRead;
+ MimeType.UserAccessLevel = AccessLevels.CurrentRead;
+ MimeType.Create(context, VariableIds.FileType_MimeType,
+ new QualifiedName(BrowseNames.MimeType),
+ new LocalizedText(BrowseNames.MimeType), true);
+
+ LastModifiedTime = PropertyState.With(this);
+ LastModifiedTime.OnReadValue += OnLastModifiedTime;
+ LastModifiedTime.AccessLevel = AccessLevels.CurrentRead;
+ LastModifiedTime.UserAccessLevel = AccessLevels.CurrentRead;
+ LastModifiedTime.Create(context, VariableIds.FileType_LastModifiedTime,
+ new QualifiedName(BrowseNames.LastModifiedTime),
+ new LocalizedText(BrowseNames.LastModifiedTime), true);
+
+ Open = new OpenMethodState(this)
+ {
+ OnCall = OnOpen,
+ Executable = true,
+ UserExecutable = true
+ };
+ Open.Create(context, MethodIds.FileType_Open,
+ new QualifiedName(BrowseNames.Open),
+ new LocalizedText(BrowseNames.Open), false);
+
+ Write = new WriteMethodState(this)
+ {
+ OnCall = OnWrite,
+ Executable = true,
+ UserExecutable = true
+ };
+ Write.Create(context, MethodIds.FileType_Write,
+ new QualifiedName(BrowseNames.Write),
+ new LocalizedText(BrowseNames.Write), false);
+
+ Read = new ReadMethodState(this)
+ {
+ OnCall = OnRead,
+ Executable = true,
+ UserExecutable = true
+ };
+ Read.Create(context, MethodIds.FileType_Read,
+ new QualifiedName(BrowseNames.Read),
+ new LocalizedText(BrowseNames.Read), false);
+
+ Close = new CloseMethodState(this)
+ {
+ OnCall = OnClose,
+ Executable = true,
+ UserExecutable = true
+ };
+ Close.Create(context, MethodIds.FileType_Close,
+ new QualifiedName(BrowseNames.Close),
+ new LocalizedText(BrowseNames.Close), false);
+
+ GetPosition = new GetPositionMethodState(this)
+ {
+ OnCall = OnGetPosition,
+ Executable = true,
+ UserExecutable = true
+ };
+ GetPosition.Create(context, MethodIds.FileType_GetPosition,
+ new QualifiedName(BrowseNames.GetPosition),
+ new LocalizedText(BrowseNames.GetPosition), false);
+
+ SetPosition = new SetPositionMethodState(this)
+ {
+ OnCall = OnSetPosition,
+ Executable = true,
+ UserExecutable = true
+ };
+ SetPosition.Create(context, MethodIds.FileType_SetPosition,
+ new QualifiedName(BrowseNames.SetPosition),
+ new LocalizedText(BrowseNames.SetPosition), false);
+ }
+
+ private ServiceResult OnMimeType(ISystemContext context, NodeState node,
+ NumericRange indexRange, QualifiedName dataEncoding, ref Variant value,
+ ref StatusCode statusCode, ref DateTimeUtc timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out FileHandle handle, out ServiceResult result))
+ {
+ value = new Variant(handle.MimeType);
+ timestamp = DateTimeUtc.Now;
+ statusCode = StatusCodes.Uncertain;
+ }
+ return result;
+ }
+
+ private ServiceResult OnLastModifiedTime(ISystemContext context,
+ NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
+ ref Variant value, ref StatusCode statusCode, ref DateTimeUtc timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out FileHandle handle, out ServiceResult result))
+ {
+ value = new Variant((DateTimeUtc)handle.LastModifiedTime);
+ timestamp = DateTimeUtc.Now;
+ statusCode = StatusCodes.Good;
+ }
+ return result;
+ }
+
+ private ServiceResult OnWritable(ISystemContext context, NodeState node,
+ NumericRange indexRange, QualifiedName dataEncoding, ref Variant value,
+ ref StatusCode statusCode, ref DateTimeUtc timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out FileHandle handle, out ServiceResult result))
+ {
+ value = new Variant(handle.IsWriteable);
+ timestamp = DateTimeUtc.Now;
+ statusCode = StatusCodes.Good;
+ }
+ return result;
+ }
+
+ private ServiceResult OnSize(ISystemContext context, NodeState node,
+ NumericRange indexRange, QualifiedName dataEncoding, ref Variant value,
+ ref StatusCode statusCode, ref DateTimeUtc timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out FileHandle handle, out ServiceResult result))
+ {
+ value = new Variant((ulong)handle.Length);
+ timestamp = DateTimeUtc.Now;
+ statusCode = StatusCodes.Good;
+ }
+ return result;
+ }
+
+ private ServiceResult OnOpenCount(ISystemContext context, NodeState node,
+ NumericRange indexRange, QualifiedName dataEncoding, ref Variant value,
+ ref StatusCode statusCode, ref DateTimeUtc timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out FileHandle handle, out ServiceResult result))
+ {
+ value = new Variant(handle.OpenCount);
+ timestamp = DateTimeUtc.Now;
+ statusCode = StatusCodes.Good;
+ }
+ return result;
+ }
+
+ private ServiceResult OnOpen(ISystemContext _context, MethodState _method,
+ NodeId _objectId, byte mode, ref uint fileHandle)
+ {
+ if (GetFileHandle(_context, _objectId, out FileHandle handle, out ServiceResult result))
+ {
+ result = handle.Open(mode, out fileHandle);
+ }
+ return result;
+ }
+
+ private ServiceResult OnClose(ISystemContext _context, MethodState _method,
+ NodeId _objectId, uint fileHandle)
+ {
+ if (GetFileHandle(_context, _objectId, out FileHandle handle, out ServiceResult result)
+ && !handle.Close(fileHandle))
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File handle could not be closed.");
+ }
+ return result;
+ }
+
+ private ServiceResult OnSetPosition(ISystemContext _context, MethodState _method,
+ NodeId _objectId, uint fileHandle, ulong position)
+ {
+ if (GetFileHandle(_context, _objectId, out FileHandle handle, out ServiceResult result))
+ {
+ Stream stream = handle.GetStream(fileHandle);
+ if (stream == null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File handle not open.");
+ }
+ stream.Position = (long)position;
+ }
+ return result;
+ }
+
+ private ServiceResult OnGetPosition(ISystemContext _context,
+ MethodState _method, NodeId _objectId, uint fileHandle, ref ulong position)
+ {
+ if (GetFileHandle(_context, _objectId, out FileHandle handle, out ServiceResult result))
+ {
+ Stream stream = handle.GetStream(fileHandle);
+ if (stream == null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File handle not open.");
+ }
+ position = (ulong)stream.Position;
+ }
+ return result;
+ }
+
+ private ServiceResult OnRead(ISystemContext _context, MethodState _method,
+ NodeId _objectId, uint fileHandle, int length, ref ByteString data)
+ {
+ if (GetFileHandle(_context, _objectId, out FileHandle handle, out ServiceResult result))
+ {
+ Stream stream = handle.GetStream(fileHandle);
+ if (stream == null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File handle not open.");
+ }
+ var buffer = new byte[length];
+ int read = stream.Read(buffer, 0, length);
+ if (read == length)
+ {
+ data = ByteString.From(buffer);
+ }
+ else
+ {
+ var trimmed = new byte[read];
+ Array.Copy(buffer, 0, trimmed, 0, read);
+ data = ByteString.From(trimmed);
+ }
+ }
+ return result;
+ }
+
+ private ServiceResult OnWrite(ISystemContext _context, MethodState _method,
+ NodeId _objectId, uint fileHandle, ByteString data)
+ {
+ if (GetFileHandle(_context, _objectId, out FileHandle handle, out ServiceResult result))
+ {
+ Stream stream = handle.GetStream(fileHandle);
+ if (stream == null)
+ {
+ return StatusCodes.BadInvalidState;
+ }
+ byte[] bytes = data.ToArray();
+ stream.Write(bytes, 0, bytes.Length);
+ }
+ return result;
+ }
+
+ ///
+ /// Populates the browser with references that meet the criteria.
+ ///
+ protected override void PopulateBrowser(ISystemContext context, NodeBrowser browser)
+ {
+ base.PopulateBrowser(context, browser);
+
+ // check if the parent segments need to be returned.
+ if (browser.IsRequired(ReferenceTypeIds.HasComponent, true))
+ {
+ string directory = Path.GetDirectoryName(FullPath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ if (Path.GetPathRoot(FullPath) == directory)
+ {
+ browser.Add(ReferenceTypeIds.HasComponent, true,
+ ModelUtils.ConstructIdForVolume(directory, NodeId.NamespaceIndex));
+ }
+ else
+ {
+ browser.Add(ReferenceTypeIds.HasComponent, true,
+ ModelUtils.ConstructIdForDirectory(directory, NodeId.NamespaceIndex));
+ }
+ }
+ }
+ }
+
+ private static bool GetFileHandle(ISystemContext context, NodeId nodeId,
+ out FileHandle handle, out ServiceResult result)
+ {
+ if (!(context.SystemHandle is FileSystem system) ||
+ !(system.GetHandle(nodeId) is FileHandle h))
+ {
+ result = ServiceResult.Create(StatusCodes.BadInvalidState,
+ "Object is not a file.");
+ handle = null;
+ return false;
+ }
+ handle = h;
+ result = ServiceResult.Good;
+ return true;
+ }
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/FileSystem.cs b/Applications/Quickstarts.Servers/FileSystem/FileSystem.cs
new file mode 100644
index 0000000000..c2ea23ad00
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/FileSystem.cs
@@ -0,0 +1,77 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System;
+ using System.Collections.Generic;
+ using System.Threading;
+
+ ///
+ /// File system and handle management
+ ///
+ public sealed class FileSystem : IDisposable
+ {
+ public void Dispose()
+ {
+ lock (m_syncRoot)
+ {
+ foreach (FileHandle handle in m_handles.Values)
+ {
+ handle.Dispose();
+ }
+ m_handles.Clear();
+ }
+ }
+
+ public FileHandle GetHandle(NodeId nodeId)
+ {
+ lock (m_syncRoot)
+ {
+ if (m_handles.TryGetValue(nodeId, out FileHandle handle))
+ {
+ return handle;
+ }
+ if (!FileSystemNodeId.TryParse(nodeId, out FileSystemNodeId parsed) ||
+ parsed.RootType != ModelUtils.File)
+ {
+ return null;
+ }
+ handle = new FileHandle(parsed);
+ m_handles.Add(nodeId, handle);
+ return handle;
+ }
+ }
+
+ private readonly Lock m_syncRoot = new Lock();
+ private readonly Dictionary m_handles = new Dictionary();
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/FileSystemNodeId.cs b/Applications/Quickstarts.Servers/FileSystem/FileSystemNodeId.cs
new file mode 100644
index 0000000000..35b2b442ed
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/FileSystemNodeId.cs
@@ -0,0 +1,238 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ using Opc.Ua;
+ using System.Text;
+
+ ///
+ /// Stores the elements of a string-encoded NodeId used by the FileSystem
+ /// NodeManager. Replaces the obsolete Opc.Ua.Server.ParsedNodeId.
+ ///
+ ///
+ /// The wire format is: <rootType>:<rootId>[?<componentPath>].
+ ///
+ /// - rootType is an integer; FileSystem uses 0 = Volume, 1 = Directory, 2 = File.
+ /// - rootId is the path string. Any literal & or ? in the path is escaped with a leading &.
+ /// - componentPath is an optional /-separated list of SymbolicName values identifying a child node.
+ ///
+ /// Examples:
+ ///
+ /// - 0:C:\ — the C: volume
+ /// - 1:C:\Windows — a directory
+ /// - 2:C:\file.txt?Open — the Open method on a file
+ /// - 2:C:\file.txt?Size — the Size property on a file
+ ///
+ ///
+ internal readonly struct FileSystemNodeId
+ {
+ ///
+ /// Creates a new .
+ ///
+ /// The root-node type discriminator (0 = Volume, 1 = Directory, 2 = File).
+ /// The root path identifier.
+ /// The namespace index of the resulting NodeId.
+ /// Optional /-separated child symbolic-name path.
+ public FileSystemNodeId(int rootType, string rootId, ushort namespaceIndex,
+ string componentPath = null)
+ {
+ RootType = rootType;
+ RootId = rootId;
+ NamespaceIndex = namespaceIndex;
+ ComponentPath = componentPath;
+ }
+
+ ///
+ /// The root-node type discriminator. FileSystem uses 0 = Volume, 1 = Directory, 2 = File.
+ ///
+ public int RootType { get; }
+
+ ///
+ /// The root path identifier (volume name, full directory path, or full file path).
+ ///
+ public string RootId { get; }
+
+ ///
+ /// Optional /-separated symbolic-name chain identifying a child component.
+ ///
+ public string ComponentPath { get; }
+
+ ///
+ /// The namespace index of the original NodeId.
+ ///
+ public ushort NamespaceIndex { get; }
+
+ ///
+ /// Attempts to parse the given in the FileSystem
+ /// wire format.
+ ///
+ /// The NodeId to parse. Only string-identifier NodeIds with the
+ /// <rootType>:<rootId>[?<componentPath>] shape are accepted.
+ /// On success, the parsed result. On failure, .
+ /// when the NodeId could be parsed; otherwise .
+ public static bool TryParse(NodeId nodeId, out FileSystemNodeId result)
+ {
+ result = default;
+
+ // can only parse non-null string node identifiers.
+ if (nodeId.IsNull)
+ {
+ return false;
+ }
+
+ if (!nodeId.TryGetValue(out string identifier) ||
+ string.IsNullOrEmpty(identifier))
+ {
+ return false;
+ }
+
+ // extract the root type prefix (leading run of decimal digits).
+ int rootType = 0;
+ int start = 0;
+
+ for (int ii = 0; ii < identifier.Length; ii++)
+ {
+ if (!char.IsDigit(identifier[ii]))
+ {
+ start = ii;
+ break;
+ }
+
+ rootType *= 10;
+ rootType += identifier[ii] - '0';
+ }
+
+ if (start >= identifier.Length || identifier[start] != ':')
+ {
+ return false;
+ }
+
+ // extract the rootId (with & escapes), terminated by an unescaped ?.
+ var buffer = new StringBuilder();
+
+ int index = start + 1;
+ int end = identifier.Length;
+ bool escaped = false;
+
+ while (index < end)
+ {
+ char ch = identifier[index++];
+
+ // skip any escape character but keep the one after it.
+ if (ch == '&')
+ {
+ escaped = true;
+ continue;
+ }
+
+ if (!escaped && ch == '?')
+ {
+ end = index;
+ break;
+ }
+
+ buffer.Append(ch);
+ escaped = false;
+ }
+
+ string componentPath = null;
+ if (end < identifier.Length)
+ {
+ componentPath = identifier.Substring(end);
+ }
+
+ result = new FileSystemNodeId(
+ rootType,
+ buffer.ToString(),
+ nodeId.NamespaceIndex,
+ componentPath);
+
+ return true;
+ }
+
+ ///
+ /// Renders this struct back to a .
+ ///
+ public NodeId ToNodeId() => ToNodeId(componentName: null);
+
+ ///
+ /// Renders this struct back to a , appending an
+ /// additional child component name to the existing .
+ ///
+ /// Symbolic name of an additional child to append.
+ /// If or empty, only the existing root + component path is rendered.
+ public NodeId ToNodeId(string componentName)
+ {
+ var buffer = new StringBuilder();
+
+ // write the root type prefix.
+ buffer.Append(RootType).Append(':');
+
+ // write the root id, escaping & and ?.
+ if (RootId != null)
+ {
+ for (int ii = 0; ii < RootId.Length; ii++)
+ {
+ char ch = RootId[ii];
+
+ if (ch is '&' or '?')
+ {
+ buffer.Append('&');
+ }
+
+ buffer.Append(ch);
+ }
+ }
+
+ // append the existing component path, if any.
+ if (!string.IsNullOrEmpty(ComponentPath))
+ {
+ buffer.Append('?').Append(ComponentPath);
+ }
+
+ // append the new component name, if any.
+ if (!string.IsNullOrEmpty(componentName))
+ {
+ if (string.IsNullOrEmpty(ComponentPath))
+ {
+ buffer.Append('?');
+ }
+ else
+ {
+ buffer.Append('/');
+ }
+
+ buffer.Append(componentName);
+ }
+
+ return new NodeId(buffer.ToString(), NamespaceIndex);
+ }
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/FileSystemNodeManager.cs b/Applications/Quickstarts.Servers/FileSystem/FileSystemNodeManager.cs
new file mode 100644
index 0000000000..7859120393
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/FileSystemNodeManager.cs
@@ -0,0 +1,296 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+
+ ///
+ /// A node manager for a server that exposes the host file system
+ /// (drives, directories, and files) under the Server object using the
+ /// FileDirectoryType / FileType companion model from Part 5.
+ ///
+ public class FileSystemNodeManager : CustomNodeManager2
+ {
+ ///
+ /// Initializes the node manager.
+ ///
+ public FileSystemNodeManager(IServerInternal server, ApplicationConfiguration configuration) :
+ base(server, configuration, Namespaces.FileSystem)
+ {
+ SystemContext.SystemHandle = m_system = new FileSystem();
+ SystemContext.NodeIdFactory = this;
+
+ var namespaceUris = new List {
+ Namespaces.FileSystem
+ };
+ NamespaceUris = namespaceUris;
+
+ m_namespaceIndex = Server.NamespaceUris.GetIndexOrAppend(namespaceUris[0]);
+ // get the configuration for the node manager.
+ // use suitable defaults if no configuration exists.
+ m_configuration = new FileSystemServerConfiguration();
+ }
+
+ ///
+ /// An overrideable version of the Dispose.
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ m_system.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ ///
+ /// Creates the NodeId for the specified node.
+ ///
+ public override NodeId New(ISystemContext context, NodeState node)
+ {
+ return ModelUtils.ConstructIdForComponent(node, NamespaceIndex);
+ }
+
+ ///
+ /// Does any initialization required before the address space can be used.
+ ///
+ public override void CreateAddressSpace(IDictionary> externalReferences)
+ {
+ lock (Lock)
+ {
+ // find the top level segments and link them to the Server folder.
+ foreach (DriveInfo fs in DriveInfo.GetDrives())
+ {
+ if (!externalReferences.TryGetValue(ObjectIds.Server, out IList references))
+ {
+ externalReferences[ObjectIds.Server] = references = new List();
+ }
+
+ // construct the NodeId of a segment.
+ NodeId fsId = ModelUtils.ConstructIdForVolume(fs.Name, m_namespaceIndex);
+
+ // add an organizes reference from the server to the volume.
+ references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, fsId));
+ }
+ }
+ }
+
+ ///
+ /// Frees any resources allocated for the address space.
+ ///
+ public override void DeleteAddressSpace()
+ {
+ lock (Lock)
+ {
+ }
+ }
+
+ ///
+ /// Returns a unique handle for the node.
+ ///
+ protected override NodeHandle GetManagerHandle(ServerSystemContext context, NodeId nodeId,
+ IDictionary cache)
+ {
+ lock (Lock)
+ {
+ // quickly exclude nodes that are not in the namespace.
+ if (!IsNodeIdInNamespace(nodeId))
+ {
+ return null;
+ }
+
+ if (nodeId.IdType != IdType.String && PredefinedNodes.TryGetValue(nodeId, out NodeState node))
+ {
+ return new NodeHandle
+ {
+ NodeId = nodeId,
+ Node = node,
+ Validated = true
+ };
+ }
+
+ // parse the identifier.
+ if (FileSystemNodeId.TryParse(nodeId, out FileSystemNodeId parsedNodeId))
+ {
+ return new NodeHandle
+ {
+ NodeId = nodeId,
+ Validated = false,
+ Node = null,
+ ParsedNodeId = parsedNodeId
+ };
+ }
+
+ return null;
+ }
+ }
+
+ ///
+ /// Verifies that the specified node exists.
+ ///
+ protected override NodeState ValidateNode(ServerSystemContext context,
+ NodeHandle handle, IDictionary cache)
+ {
+ // not valid if no root.
+ if (handle == null)
+ {
+ return null;
+ }
+
+ // check if previously validated.
+ if (handle.Validated)
+ {
+ return handle.Node;
+ }
+
+ NodeState target = null;
+
+ // check if already in the cache.
+ if (cache != null)
+ {
+ if (cache.TryGetValue(handle.NodeId, out target))
+ {
+ // nulls mean a NodeId which was previously found to be invalid has been referenced again.
+ if (target == null)
+ {
+ return null;
+ }
+
+ handle.Node = target;
+ handle.Validated = true;
+ return handle.Node;
+ }
+
+ target = null;
+ }
+
+ try
+ {
+ // check if the node id has been parsed.
+ if (handle.ParsedNodeId is not FileSystemNodeId parsedNodeId)
+ {
+ return null;
+ }
+
+ NodeState root = null;
+
+ // Validate drive
+ if (parsedNodeId.RootType == ModelUtils.Volume)
+ {
+ DriveInfo volume = DriveInfo.GetDrives().FirstOrDefault(d => d.Name == parsedNodeId.RootId);
+
+ // volume does not exist.
+ if (volume == null)
+ {
+ return null;
+ }
+
+ NodeId rootId = ModelUtils.ConstructIdForVolume(volume.Name, m_namespaceIndex);
+
+ // create a temporary object to use for the operation.
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ root = new DirectoryObjectState(context, rootId, volume.Name, true);
+#pragma warning restore CA2000 // Dispose objects before losing scope
+ }
+ // Validate directory
+ else if (parsedNodeId.RootType == ModelUtils.Directory)
+ {
+ if (!Directory.Exists(parsedNodeId.RootId))
+ {
+ return null;
+ }
+
+ NodeId rootId = ModelUtils.ConstructIdForDirectory(parsedNodeId.RootId, m_namespaceIndex);
+
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ root = new DirectoryObjectState(context, rootId, parsedNodeId.RootId, false);
+#pragma warning restore CA2000 // Dispose objects before losing scope
+ }
+ // Validate file
+ else if (parsedNodeId.RootType == ModelUtils.File)
+ {
+ if (!File.Exists(parsedNodeId.RootId))
+ {
+ return null;
+ }
+
+ NodeId rootId = ModelUtils.ConstructIdForFile(parsedNodeId.RootId, m_namespaceIndex);
+
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ root = new FileObjectState(context, rootId, parsedNodeId.RootId);
+#pragma warning restore CA2000 // Dispose objects before losing scope
+ }
+ // unknown root type.
+ else
+ {
+ return null;
+ }
+
+ // all done if no components to validate.
+ if (string.IsNullOrEmpty(parsedNodeId.ComponentPath))
+ {
+ handle.Validated = true;
+ handle.Node = target = root;
+ return handle.Node;
+ }
+
+ // validate component.
+ NodeState component = root.FindChildBySymbolicName(context, parsedNodeId.ComponentPath);
+
+ // component does not exist.
+ if (component == null)
+ {
+ return null;
+ }
+
+ // found a valid component.
+ handle.Validated = true;
+ handle.Node = target = component;
+ return handle.Node;
+ }
+ finally
+ {
+ // store the node in the cache to optimize subsequent lookups.
+ cache?.Add(handle.NodeId, target);
+ }
+ }
+
+ private readonly ushort m_namespaceIndex;
+
+#pragma warning disable IDE0052 // Remove unread private members
+ private readonly FileSystemServerConfiguration m_configuration;
+ private readonly FileSystem m_system;
+#pragma warning restore IDE0052 // Remove unread private members
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/FileSystemServer.cs b/Applications/Quickstarts.Servers/FileSystem/FileSystemServer.cs
new file mode 100644
index 0000000000..0998ca8d91
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/FileSystemServer.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 Quickstarts.FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+
+ ///
+ public class FileSystemServer : INodeManagerFactory
+ {
+ ///
+ public ArrayOf NamespacesUris =>
+ [
+ Namespaces.FileSystem
+ ];
+
+ ///
+ public INodeManager Create(IServerInternal server,
+ ApplicationConfiguration configuration)
+ {
+ return new FileSystemNodeManager(server, configuration);
+ }
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/FileSystemServerConfiguration.cs b/Applications/Quickstarts.Servers/FileSystem/FileSystemServerConfiguration.cs
new file mode 100644
index 0000000000..b5fdc1c31b
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/FileSystemServerConfiguration.cs
@@ -0,0 +1,47 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ using System.Runtime.Serialization;
+
+ ///
+ /// Stores the configuration the file system node manager.
+ ///
+ [DataContract(Namespace = Namespaces.FileSystem)]
+ public class FileSystemServerConfiguration
+ {
+ ///
+ /// The default constructor.
+ ///
+ public FileSystemServerConfiguration()
+ {
+ }
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/ModelUtils.cs b/Applications/Quickstarts.Servers/FileSystem/ModelUtils.cs
new file mode 100644
index 0000000000..bbab4a4c4d
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/ModelUtils.cs
@@ -0,0 +1,128 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System.IO;
+ using System.Text;
+
+ ///
+ /// A class that builds NodeIds used by the FileSystem NodeManager
+ ///
+ public static class ModelUtils
+ {
+ ///
+ /// The RootType for a Volume node identfier.
+ ///
+ public const int Volume = 0;
+
+ ///
+ /// The RootType for a Directory node identfier.
+ ///
+ public const int Directory = 1;
+
+ ///
+ /// The RootType for a File node identfier.
+ ///
+ public const int File = 2;
+
+ ///
+ /// Create id for drive
+ ///
+ public static NodeId ConstructIdForVolume(string path, ushort namespaceIndex)
+ => new FileSystemNodeId(Volume, path, namespaceIndex).ToNodeId();
+
+ ///
+ /// Constructs a NodeId for a directory.
+ ///
+ public static NodeId ConstructIdForDirectory(string path, ushort namespaceIndex)
+ => new FileSystemNodeId(Directory, path, namespaceIndex).ToNodeId();
+
+ ///
+ /// Constructs a NodeId for a file.
+ ///
+ public static NodeId ConstructIdForFile(string path, ushort namespaceIndex)
+ => new FileSystemNodeId(File, path, namespaceIndex).ToNodeId();
+
+ public static string GetName(string path)
+ {
+ string name = Path.GetFileName(path);
+ if (string.IsNullOrEmpty(name))
+ {
+ return path;
+ }
+ return name;
+ }
+
+ ///
+ /// Constructs the node identifier for a component.
+ ///
+ public static NodeId ConstructIdForComponent(NodeState component, ushort namespaceIndex)
+ {
+ if (component == null)
+ {
+ return NodeId.Null;
+ }
+
+ // components must be instances with a parent.
+ if (!(component is BaseInstanceState instance) || instance.Parent == null)
+ {
+ return component.NodeId;
+ }
+
+ // parent must have a string identifier.
+ if (!instance.Parent.NodeId.TryGetValue(out string parentId))
+ {
+ return NodeId.Null;
+ }
+
+ var buffer = new StringBuilder();
+ buffer.Append(parentId);
+
+ // check if the parent is another component.
+ int index = parentId.IndexOf('?');
+
+ if (index < 0)
+ {
+ buffer.Append('?');
+ }
+ else
+ {
+ buffer.Append('/');
+ }
+
+ buffer.Append(component.SymbolicName);
+
+ // return the node identifier.
+ return new NodeId(buffer.ToString(), namespaceIndex);
+ }
+ }
+}
diff --git a/Applications/Quickstarts.Servers/FileSystem/Namespaces.cs b/Applications/Quickstarts.Servers/FileSystem/Namespaces.cs
new file mode 100644
index 0000000000..6b0afad73a
--- /dev/null
+++ b/Applications/Quickstarts.Servers/FileSystem/Namespaces.cs
@@ -0,0 +1,42 @@
+/* ========================================================================
+ * 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 Quickstarts.FileSystem
+{
+ ///
+ /// Defines constants for namespaces used by the application.
+ ///
+ public static class Namespaces
+ {
+ ///
+ /// The namespace for the nodes provided by the server.
+ ///
+ public const string FileSystem = "FileSystem";
+ }
+}
diff --git a/Applications/Quickstarts.Servers/ReferenceServer/AliasNameNodeManager.cs b/Applications/Quickstarts.Servers/ReferenceServer/AliasNameNodeManager.cs
new file mode 100644
index 0000000000..4d8aad7c1d
--- /dev/null
+++ b/Applications/Quickstarts.Servers/ReferenceServer/AliasNameNodeManager.cs
@@ -0,0 +1,405 @@
+/* ========================================================================
+ * 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 Opc.Ua;
+using Opc.Ua.Server;
+
+namespace Quickstarts.ReferenceServer
+{
+ ///
+ /// A simple OPC UA AliasName provider that registers two alias categories
+ /// (TagVariables and Topics) under the standard Aliases object
+ /// (i=23470) defined by OPC UA Part 17 (Aliases). The categories expose a
+ /// FindAlias method that performs OPC UA Like-pattern matching
+ /// against the contained alias instances.
+ ///
+ public sealed class AliasNameNodeManager : CustomNodeManager2
+ {
+ // Standard NodeIds defined by OPC UA Part 17.
+ private const uint AliasNameTypeId = 23455;
+ private const uint AliasNameCategoryTypeId = 23456;
+ private const uint AliasNameDataTypeId = 23468;
+ private const uint AliasForReferenceTypeId = 23469;
+ private const uint AliasesObjectId = 23470;
+
+ ///
+ /// Namespace URI used for the alias instances created by this manager.
+ ///
+ public const string NamespaceUri
+ = "http://opcfoundation.org/Quickstarts/AliasName";
+
+ private readonly List _allAliases = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The server internal interface.
+ /// The application configuration.
+ public AliasNameNodeManager(
+ IServerInternal server,
+ ApplicationConfiguration configuration)
+ : base(server, configuration, NamespaceUri)
+ {
+ }
+
+ ///
+ public override void CreateAddressSpace(
+ IDictionary> externalReferences)
+ {
+ base.CreateAddressSpace(externalReferences);
+
+ int refServerNsIndex = Server.NamespaceUris.GetIndex(
+ Namespaces.ReferenceServer);
+ ushort refServerNs = refServerNsIndex >= 0
+ ? (ushort)refServerNsIndex
+ : ushort.MaxValue;
+
+ BaseObjectState tagVariables = CreateCategory("TagVariables");
+ BaseObjectState topics = CreateCategory("Topics");
+
+ // Link from the standard Aliases object (ns=0) into our categories.
+ var aliasesNodeId = new NodeId(AliasesObjectId);
+ AddExternalReference(
+ aliasesNodeId, ReferenceTypeIds.Organizes, false,
+ tagVariables.NodeId, externalReferences);
+ AddExternalReference(
+ aliasesNodeId, ReferenceTypeIds.Organizes, false,
+ topics.NodeId, externalReferences);
+ // The reverse references on the children point back to Aliases.
+ tagVariables.AddReference(
+ ReferenceTypeIds.Organizes, true, aliasesNodeId);
+ topics.AddReference(
+ ReferenceTypeIds.Organizes, true, aliasesNodeId);
+
+ // TagVariables aliases — references into the ReferenceServer namespace.
+ if (refServerNs != ushort.MaxValue)
+ {
+ CreateAlias(tagVariables, "TIC101_Setpoint",
+ [new NodeId("Scalar_Static_Double", refServerNs)]);
+ CreateAlias(tagVariables, "TIC101_PV",
+ [new NodeId("Scalar_Static_Float", refServerNs)]);
+ CreateAlias(tagVariables, "FIC202_Flow",
+ [new NodeId("Scalar_Simulation_Double", refServerNs)]);
+ CreateAlias(tagVariables, "Pump1_Status",
+ [new NodeId("Scalar_Static_Boolean", refServerNs)]);
+ CreateAlias(tagVariables, "Heater_Power",
+ [new NodeId("Scalar_Static_Int32", refServerNs)]);
+ CreateAlias(tagVariables, "MultiRefAlias",
+ [
+ new NodeId("Scalar_Static_Double", refServerNs),
+ new NodeId("Scalar_Static_Int32", refServerNs)
+ ]);
+ }
+
+ // Topics aliases — references into the well-known ns=0 nodes.
+ CreateAlias(topics, "ServerEvents", [ObjectIds.Server]);
+ CreateAlias(topics, "AuditEvents",
+ [new NodeId(ObjectTypes.AuditEventType)]);
+
+ AddPredefinedNode(SystemContext, tagVariables);
+ AddPredefinedNode(SystemContext, topics);
+ }
+
+ private BaseObjectState CreateCategory(string name)
+ {
+ var nodeId = new NodeId(name, NamespaceIndex);
+ var category = new BaseObjectState(null)
+ {
+ SymbolicName = name,
+ NodeId = nodeId,
+ BrowseName = new QualifiedName(name, NamespaceIndex),
+ DisplayName = new LocalizedText(name),
+ TypeDefinitionId = new NodeId(AliasNameCategoryTypeId),
+ ReferenceTypeId = ReferenceTypeIds.Organizes
+ };
+
+ // FindAlias method — instance with method-declaration to the type.
+ var findAlias = new MethodState(category)
+ {
+ SymbolicName = "FindAlias",
+ NodeId = new NodeId(name + "_FindAlias", NamespaceIndex),
+ BrowseName = new QualifiedName("FindAlias", NamespaceIndex),
+ DisplayName = new LocalizedText("FindAlias"),
+ ReferenceTypeId = ReferenceTypeIds.HasComponent,
+ Executable = true,
+ UserExecutable = true
+ };
+
+ findAlias.InputArguments =
+ new PropertyState>
+ .Implementation>(findAlias)
+ {
+ NodeId = new NodeId(name + "_FindAlias_In", NamespaceIndex),
+ BrowseName = QualifiedName.From(BrowseNames.InputArguments),
+ DisplayName = LocalizedText.From(BrowseNames.InputArguments),
+ TypeDefinitionId = VariableTypeIds.PropertyType,
+ ReferenceTypeId = ReferenceTypeIds.HasProperty,
+ DataType = DataTypeIds.Argument,
+ ValueRank = ValueRanks.OneDimension
+ };
+ findAlias.InputArguments.Value = new Argument[]
+ {
+ new()
+ {
+ Name = "AliasNameSearchPattern",
+ Description = LocalizedText.From("AliasNameSearchPattern"),
+ DataType = DataTypeIds.String,
+ ValueRank = ValueRanks.Scalar
+ },
+ new()
+ {
+ Name = "ReferenceTypeFilter",
+ Description = LocalizedText.From("ReferenceTypeFilter"),
+ DataType = DataTypeIds.NodeId,
+ ValueRank = ValueRanks.Scalar
+ }
+ }.ToArrayOf();
+
+ findAlias.OutputArguments =
+ new PropertyState>
+ .Implementation>(findAlias)
+ {
+ NodeId = new NodeId(name + "_FindAlias_Out", NamespaceIndex),
+ BrowseName = QualifiedName.From(BrowseNames.OutputArguments),
+ DisplayName = LocalizedText.From(BrowseNames.OutputArguments),
+ TypeDefinitionId = VariableTypeIds.PropertyType,
+ ReferenceTypeId = ReferenceTypeIds.HasProperty,
+ DataType = DataTypeIds.Argument,
+ ValueRank = ValueRanks.OneDimension
+ };
+ findAlias.OutputArguments.Value = new Argument[]
+ {
+ new()
+ {
+ Name = "AliasNodeList",
+ Description = LocalizedText.From("AliasNodeList"),
+ DataType = new NodeId(AliasNameDataTypeId),
+ ValueRank = ValueRanks.OneDimension
+ }
+ }.ToArrayOf();
+
+ findAlias.OnCallMethod2 =
+ new GenericMethodCalledEventHandler2(OnFindAlias);
+
+ category.AddChild(findAlias);
+ return category;
+ }
+
+ private void CreateAlias(
+ BaseObjectState parent,
+ string name,
+ NodeId[] targets)
+ {
+ var nodeId = new NodeId(parent.BrowseName.Name + "_" + name,
+ NamespaceIndex);
+
+ var alias = new BaseObjectState(parent)
+ {
+ SymbolicName = name,
+ NodeId = nodeId,
+ BrowseName = new QualifiedName(name, NamespaceIndex),
+ DisplayName = new LocalizedText(name),
+ TypeDefinitionId = new NodeId(AliasNameTypeId),
+ ReferenceTypeId = ReferenceTypeIds.HasComponent
+ };
+
+ var aliasForRef = new NodeId(AliasForReferenceTypeId);
+ foreach (NodeId target in targets)
+ {
+ alias.AddReference(aliasForRef, false, target);
+ }
+
+ parent.AddChild(alias);
+
+ _allAliases.Add(new AliasInstance(
+ parent.NodeId, name, aliasForRef, targets));
+ }
+
+ ///
+ /// Implements the FindAlias method per OPC UA Part 17 §6.3.2.
+ ///
+ private ServiceResult OnFindAlias(
+ ISystemContext context,
+ MethodState method,
+ NodeId objectId,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ if (inputArguments.Count < 2)
+ {
+ return new ServiceResult(StatusCodes.BadArgumentsMissing);
+ }
+
+ if (!inputArguments[0].TryGetValue(out string pattern) ||
+ pattern == null)
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ if (!inputArguments[1].TryGetValue(out NodeId refTypeFilter) ||
+ refTypeFilter.IsNull)
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ var results = new List();
+
+ if (pattern.Length == 0)
+ {
+ outputArguments[0] = new Variant(results.ToArray());
+ return ServiceResult.Good;
+ }
+
+ try
+ {
+ CollectMatches(objectId, pattern, refTypeFilter, results);
+ }
+ catch (ArgumentException)
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ outputArguments[0] = new Variant(results.ToArray());
+ return ServiceResult.Good;
+ }
+
+ ///
+ /// Recursively collects matching alias instances within
+ /// and any sub-categories.
+ ///
+ private void CollectMatches(
+ NodeId categoryId,
+ string pattern,
+ NodeId refTypeFilter,
+ List results)
+ {
+ var aliasForRef = new NodeId(AliasForReferenceTypeId);
+ var categoryTypeId = new NodeId(AliasNameCategoryTypeId);
+
+ foreach (AliasInstance alias in _allAliases)
+ {
+ if (alias.CategoryId != categoryId)
+ {
+ continue;
+ }
+
+ if (!AliasNameWildcardMatcher.IsMatch(alias.Name, pattern))
+ {
+ continue;
+ }
+
+ bool matchesFilter = aliasForRef == refTypeFilter
+ || Server.TypeTree.IsTypeOf(aliasForRef, refTypeFilter);
+
+ if (!matchesFilter)
+ {
+ continue;
+ }
+
+ var data = new AliasNameDataTypeRecord(
+ new QualifiedName(alias.Name, NamespaceIndex),
+ alias.Targets);
+ results.Add(new ExtensionObject(data));
+ }
+
+ // Walk into sub-categories (children of category type).
+ BaseObjectState category =
+ FindPredefinedNode(categoryId);
+ if (category == null)
+ {
+ return;
+ }
+
+ var children = new List();
+ category.GetChildren(SystemContext, children);
+ foreach (BaseInstanceState child in children)
+ {
+ if (child is BaseObjectState obj
+ && obj.TypeDefinitionId == categoryTypeId)
+ {
+ CollectMatches(
+ obj.NodeId, pattern, refTypeFilter, results);
+ }
+ }
+ }
+
+ private sealed record AliasInstance(
+ NodeId CategoryId,
+ string Name,
+ NodeId ReferenceTypeId,
+ NodeId[] Targets);
+ }
+
+ ///
+ /// In-memory representation of the AliasNameDataType structure
+ /// (i=23468) defined by OPC UA Part 17. Encoded as the Body of an
+ /// when returned from FindAlias.
+ ///
+ internal sealed class AliasNameDataTypeRecord : IEncodeable
+ {
+ public AliasNameDataTypeRecord(QualifiedName aliasName, NodeId[] targets)
+ {
+ AliasName = aliasName;
+ ReferencedNodes = targets;
+ }
+
+ public QualifiedName AliasName { get; }
+ public NodeId[] ReferencedNodes { get; }
+
+ public ExpandedNodeId TypeId => new(23468u);
+ public ExpandedNodeId BinaryEncodingId => new(23499u);
+ public ExpandedNodeId XmlEncodingId => new(23505u);
+
+ public void Encode(IEncoder encoder)
+ {
+ encoder.WriteQualifiedName("AliasName", AliasName);
+
+ // ReferencedNodes is defined as NodeId[] in the spec.
+ ArrayOf ids = ReferencedNodes != null
+ ? ReferencedNodes.ToArrayOf()
+ : default;
+ encoder.WriteNodeIdArray("ReferencedNodes", ids);
+ }
+
+ public void Decode(IDecoder decoder)
+ {
+ // Server-side only — decode is not used.
+ throw new NotSupportedException();
+ }
+
+ public bool IsEqual(IEncodeable encodeable)
+ {
+ return ReferenceEquals(this, encodeable);
+ }
+
+ public object Clone() => this;
+ }
+}
diff --git a/Applications/Quickstarts.Servers/ReferenceServer/AliasNameWildcardMatcher.cs b/Applications/Quickstarts.Servers/ReferenceServer/AliasNameWildcardMatcher.cs
new file mode 100644
index 0000000000..1fc62ba2e0
--- /dev/null
+++ b/Applications/Quickstarts.Servers/ReferenceServer/AliasNameWildcardMatcher.cs
@@ -0,0 +1,106 @@
+/* ========================================================================
+ * 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.Text.RegularExpressions;
+
+namespace Quickstarts.ReferenceServer
+{
+ ///
+ /// Implements the OPC UA Like-operator wildcard pattern match
+ /// described in OPC UA Part 4 §7.40 (FilterOperator Like).
+ ///
+ ///
+ /// Supported wildcards:
+ ///
+ /// - % — matches zero or more characters.
+ /// - _ — matches exactly one character.
+ /// - [abc] — matches any single character from the set.
+ /// - [!abc] — matches any single character not in the set.
+ /// - \ — escapes the next wildcard character.
+ ///
+ /// Algorithm ported from
+ /// Stack/Opc.Ua.Core/Stack/Types/FilterEvaluator.cs (private Match).
+ ///
+ public static partial class AliasNameWildcardMatcher
+ {
+ ///
+ /// Returns true when matches the
+ /// OPC UA Like wildcard .
+ ///
+ /// String to test.
+ /// OPC UA Like pattern.
+ public static bool IsMatch(string target, string pattern)
+ {
+ if (target == null || pattern == null)
+ {
+ return false;
+ }
+
+ string expression = pattern;
+
+ // Suppress the regex meta characters that are not OPC UA wildcards
+ // so they will not interfere with the match.
+ expression = SuppressUnusedCharacters.Replace(expression, "\\$1");
+
+ // Replace the OPC UA wildcards with their regex equivalents.
+ expression = ReplaceWildcards.Replace(expression, ".*");
+ expression = ReplaceUnderscores.Replace(expression, ".");
+ expression = ReplaceBrackets.Replace(expression, "[^");
+
+ return Regex.IsMatch(target, "^" + expression + "$");
+ }
+
+#if NET8_0_OR_GREATER
+ [GeneratedRegex("([\\^\\$\\.\\|\\?\\*\\+\\(\\)])", RegexOptions.Compiled)]
+ private static partial Regex _SuppressUnusedCharacters();
+ private static Regex SuppressUnusedCharacters => _SuppressUnusedCharacters();
+
+ [GeneratedRegex("(? _ReplaceWildcards();
+
+ [GeneratedRegex("(? _ReplaceUnderscores();
+
+ [GeneratedRegex("(? _ReplaceBrackets();
+#else
+ private static Regex SuppressUnusedCharacters { get; }
+ = new("([\\^\\$\\.\\|\\?\\*\\+\\(\\)])", RegexOptions.Compiled);
+ private static Regex ReplaceWildcards { get; }
+ = new("(?
+ /// Adds a new instance node to the address space under a parent that may
+ /// belong to a different node manager. Used by the AddNodes service
+ /// implementation in to allow the test
+ /// fixture to exercise the Node Management service set.
+ ///
+ ///
+ /// The node is registered in this manager's namespace, an inverse
+ /// reference back to the parent is attached, and a forward reference
+ /// from the parent to the new node is added through the master node
+ /// manager so the parent's node manager records the link as well.
+ ///
+ public async ValueTask AddInstanceNodeAsync(
+ ServerSystemContext context,
+ NodeId parentNodeId,
+ NodeId referenceTypeId,
+ BaseInstanceState instance,
+ CancellationToken cancellationToken = default)
+ {
+ if (instance == null)
+ {
+ throw new ArgumentNullException(nameof(instance));
+ }
+
+ ServerSystemContext contextToUse = SystemContext.Copy(context);
+
+ if (instance.NodeId.IsNull)
+ {
+ instance.NodeId = new NodeId(
+ Guid.NewGuid().ToString(),
+ NamespaceIndexes[0]);
+ }
+
+ instance.ReferenceTypeId = referenceTypeId;
+ instance.AddReference(referenceTypeId, true, parentNodeId);
+
+ await AddPredefinedNodeAsync(contextToUse, instance, cancellationToken)
+ .ConfigureAwait(false);
+
+ var references = new List
+ {
+ new NodeStateReference(referenceTypeId, false, instance.NodeId)
+ };
+
+ await Server.NodeManager.AddReferencesAsync(
+ parentNodeId,
+ references,
+ cancellationToken).ConfigureAwait(false);
+
+ return instance.NodeId;
+ }
+
private static bool IsAnalogType(BuiltInType builtInType)
{
switch (builtInType)
@@ -268,13 +335,36 @@ public override async ValueTask CreateAddressSpaceAsync(
"Int16",
DataTypeIds.Int16,
ValueRanks.Scalar));
- variables.Add(
- CreateVariable(
- staticFolder,
- scalarStatic + "Int32",
- "Int32",
- DataTypeIds.Int32,
- ValueRanks.Scalar));
+ BaseDataVariableState int32Static = CreateVariable(
+ staticFolder,
+ scalarStatic + "Int32",
+ "Int32",
+ DataTypeIds.Int32,
+ ValueRanks.Scalar);
+ // Expose RolePermissions / UserRolePermissions
+ // on the Int32 static scalar so the conformance attribute
+ // tests (AttributeReadComplexTests RolePermissions /
+ // UserRolePermissions read) return Good rather than
+ // BadAttributeIdInvalid. Anonymous users are granted
+ // Browse + Read + ReadRolePermissions; SecurityAdmin gets
+ // full permissions for write-attribute scenarios.
+ var anonPerms = new RolePermissionType
+ {
+ RoleId = ObjectIds.WellKnownRole_Anonymous,
+ Permissions =
+ (uint)PermissionType.Browse |
+ (uint)PermissionType.Read |
+ (uint)PermissionType.Write |
+ (uint)PermissionType.ReadRolePermissions
+ };
+ var adminPerms = new RolePermissionType
+ {
+ RoleId = ObjectIds.WellKnownRole_SecurityAdmin,
+ Permissions = 0xFFFF
+ };
+ int32Static.RolePermissions = new[] { anonPerms, adminPerms }.ToArrayOf();
+ int32Static.UserRolePermissions = new[] { anonPerms }.ToArrayOf();
+ variables.Add(int32Static);
variables.Add(
CreateVariable(
staticFolder,
@@ -3814,6 +3904,9 @@ const string daMultiStateValueDiscrete
await AddPredefinedNodeAsync(SystemContext, root, cancellationToken).ConfigureAwait(false);
+ // Enable history archiving for selected scalar variables.
+ EnableHistoryArchiving();
+
if (m_simulationEnabled)
{
// reset random generator and generate boundary values
@@ -4137,7 +4230,6 @@ private TwoStateDiscreteState CreateTwoStateDiscreteItemVariable(
{
var variable = new TwoStateDiscreteState(parent)
{
- NodeId = new NodeId(path, NamespaceIndex),
BrowseName = new QualifiedName(path, NamespaceIndex),
DisplayName = new LocalizedText("en", name),
WriteMask = AttributeWriteMask.None,
@@ -4180,7 +4272,6 @@ private MultiStateDiscreteState CreateMultiStateDiscreteItemVariable(
{
var variable = new MultiStateDiscreteState(parent)
{
- NodeId = new NodeId(path, NamespaceIndex),
BrowseName = new QualifiedName(path, NamespaceIndex),
DisplayName = new LocalizedText("en", name),
WriteMask = AttributeWriteMask.None,
@@ -4240,7 +4331,6 @@ private MultiStateValueDiscreteState CreateMultiStateValueDiscreteItemVariable(
{
var variable = new MultiStateValueDiscreteState(parent)
{
- NodeId = new NodeId(path, NamespaceIndex),
BrowseName = new QualifiedName(path, NamespaceIndex),
DisplayName = new LocalizedText("en", name),
WriteMask = AttributeWriteMask.None,
@@ -5376,6 +5466,526 @@ protected override ValueTask ValidateNodeAsync(
private bool m_simulationEnabled = true;
private int m_simulationsRunning;
private readonly List m_dynamicNodes = [];
+ private HistoryArchive m_historyArchive;
+
+ #region Historical Access
+
+ ///
+ /// Identifiers of the nodes that support history archiving.
+ ///
+ private static readonly string[] HistoricalNodeNames =
+ [
+ "Scalar_Static_Double",
+ "Scalar_Static_Int32",
+ "Scalar_Static_Float"
+ ];
+
+ ///
+ /// Enables history archiving on selected scalar variables.
+ ///
+ private void EnableHistoryArchiving()
+ {
+ m_historyArchive = new HistoryArchive(Server.Telemetry);
+
+ foreach (string name in HistoricalNodeNames)
+ {
+ var nodeId = new NodeId(name, NamespaceIndex);
+
+ if (!PredefinedNodes.TryGetValue(nodeId, out NodeState node))
+ {
+ continue;
+ }
+
+ if (node is not BaseVariableState variable)
+ {
+ continue;
+ }
+
+ variable.Historizing = true;
+ variable.AccessLevel = (byte)(variable.AccessLevel | AccessLevels.HistoryRead | AccessLevels.HistoryWrite);
+ variable.UserAccessLevel = (byte)(variable.UserAccessLevel | AccessLevels.HistoryRead | AccessLevels.HistoryWrite);
+
+ BuiltInType builtInType = TypeInfo.GetBuiltInType(variable.DataType);
+ m_historyArchive.CreateRecord(nodeId, builtInType);
+ }
+ }
+
+ ///
+ /// Returns the history data source for a node.
+ ///
+ private IHistoryDataSource GetHistoryDataSource(NodeId nodeId)
+ {
+ return m_historyArchive?.GetHistoryFile(nodeId);
+ }
+
+ ///
+ /// Restores a previously cached history reader.
+ ///
+ private static HistoryDataReader RestoreDataReader(
+ ServerSystemContext context,
+ ByteString continuationPoint)
+ {
+ if (context?.OperationContext?.Session == null)
+ {
+ return null;
+ }
+
+ return context.OperationContext.Session
+ .RestoreHistoryContinuationPoint(continuationPoint) as HistoryDataReader;
+ }
+
+ ///
+ /// Saves a history data reader as a continuation point.
+ ///
+ private static void SaveDataReader(ServerSystemContext context, HistoryDataReader reader)
+ {
+ context?.OperationContext?.Session?
+ .SaveHistoryContinuationPoint(reader.Id, reader);
+ }
+
+ ///
+ /// Reads raw history data for a single variable.
+ ///
+ private static ServiceResult HistoryReadRaw(
+ ServerSystemContext context,
+ IHistoryDataSource datasource,
+ ReadRawModifiedDetails details,
+ TimestampsToReturn timestampsToReturn,
+ bool releaseContinuationPoints,
+ HistoryReadValueId nodeToRead,
+ HistoryReadResult result)
+ {
+ List dataValues = [];
+
+ HistoryDataReader reader;
+ if (!nodeToRead.ContinuationPoint.IsEmpty)
+ {
+ reader = RestoreDataReader(context, nodeToRead.ContinuationPoint);
+
+ if (reader == null)
+ {
+ return StatusCodes.BadContinuationPointInvalid;
+ }
+
+ if (reader.VariableId != nodeToRead.NodeId)
+ {
+ reader.Dispose();
+ return StatusCodes.BadContinuationPointInvalid;
+ }
+
+ if (releaseContinuationPoints)
+ {
+ reader.Dispose();
+ return ServiceResult.Good;
+ }
+ }
+ else
+ {
+ if (datasource == null)
+ {
+ return StatusCodes.BadNotReadable;
+ }
+
+ reader = new HistoryDataReader(nodeToRead.NodeId, datasource);
+ reader.BeginReadRaw(
+ context,
+ details,
+ timestampsToReturn,
+ nodeToRead.ParsedIndexRange,
+ nodeToRead.DataEncoding,
+ dataValues);
+ }
+
+ bool complete = reader.NextReadRaw(
+ context,
+ timestampsToReturn,
+ nodeToRead.ParsedIndexRange,
+ nodeToRead.DataEncoding,
+ dataValues);
+
+ if (!complete)
+ {
+ SaveDataReader(context, reader);
+ result.StatusCode = StatusCodes.GoodMoreData;
+ }
+ else
+ {
+ reader.Dispose();
+ }
+
+ result.HistoryData = new ExtensionObject(new HistoryData
+ {
+ DataValues = dataValues
+ });
+
+ return result.StatusCode;
+ }
+
+ ///
+ /// Reads raw or modified history data.
+ ///
+ protected override async ValueTask HistoryReadRawModifiedAsync(
+ ServerSystemContext context,
+ ReadRawModifiedDetails details,
+ TimestampsToReturn timestampsToReturn,
+ ArrayOf nodesToRead,
+ IList results,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache,
+ CancellationToken cancellationToken = default)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+
+ NodeState source = await ValidateNodeAsync(
+ context, handle, cache, cancellationToken).ConfigureAwait(false);
+
+ if (source == null)
+ {
+ continue;
+ }
+
+ if (source is not BaseVariableState)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ IHistoryDataSource datasource = GetHistoryDataSource(handle.NodeId);
+
+ if (datasource == null)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ errors[handle.Index] = HistoryReadRaw(
+ context,
+ datasource,
+ details,
+ timestampsToReturn,
+ false,
+ nodesToRead[handle.Index],
+ results[handle.Index]);
+ }
+ }
+
+ ///
+ /// Reads processed (aggregate) history data.
+ ///
+ protected override async ValueTask HistoryReadProcessedAsync(
+ ServerSystemContext context,
+ ReadProcessedDetails details,
+ TimestampsToReturn timestampsToReturn,
+ ArrayOf nodesToRead,
+ IList results,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache,
+ CancellationToken cancellationToken = default)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+
+ NodeState source = await ValidateNodeAsync(
+ context, handle, cache, cancellationToken).ConfigureAwait(false);
+
+ if (source == null)
+ {
+ continue;
+ }
+
+ if (source is not BaseVariableState)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ IHistoryDataSource datasource = GetHistoryDataSource(handle.NodeId);
+
+ if (datasource == null)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ // Determine which aggregate to use for this node.
+ NodeId aggregateId = NodeId.Null;
+ if (!details.AggregateType.IsNull && details.AggregateType.Count > 0)
+ {
+ aggregateId = (ii < details.AggregateType.Count)
+ ? details.AggregateType[ii]
+ : details.AggregateType[0];
+ }
+
+ AggregateConfiguration configuration = details.AggregateConfiguration;
+ if (configuration == null || configuration.UseServerCapabilitiesDefaults)
+ {
+ configuration = Server.AggregateManager.GetDefaultConfiguration(handle.NodeId);
+ }
+
+ IAggregateCalculator calculator = Server.AggregateManager.CreateCalculator(
+ aggregateId,
+ details.StartTime,
+ details.EndTime,
+ details.ProcessingInterval,
+ false,
+ configuration);
+
+ if (calculator == null)
+ {
+ errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
+ continue;
+ }
+
+ // Feed all raw values in the time range to the calculator.
+ var rawDetails = new ReadRawModifiedDetails
+ {
+ StartTime = details.StartTime,
+ EndTime = details.EndTime,
+ NumValuesPerNode = 0,
+ IsReadModified = false,
+ ReturnBounds = true
+ };
+
+ List rawValues = [];
+ var tempReader = new HistoryDataReader(handle.NodeId, datasource);
+ tempReader.BeginReadRaw(
+ context,
+ rawDetails,
+ TimestampsToReturn.Source,
+ NumericRange.Null,
+ QualifiedName.Null,
+ rawValues);
+
+ tempReader.NextReadRaw(
+ context,
+ TimestampsToReturn.Source,
+ NumericRange.Null,
+ QualifiedName.Null,
+ rawValues);
+
+ tempReader.Dispose();
+
+ // Push values and collect processed results.
+ List processedValues = [];
+
+ foreach (DataValue rawValue in rawValues)
+ {
+ if (!calculator.QueueRawValue(rawValue))
+ {
+ // Collect any computed values.
+ DataValue computed = calculator.GetProcessedValue(false);
+ while (computed != null)
+ {
+ processedValues.Add(computed);
+ computed = calculator.GetProcessedValue(false);
+ }
+ }
+ }
+
+ // Flush remaining.
+ DataValue final1 = calculator.GetProcessedValue(true);
+ while (final1 != null)
+ {
+ processedValues.Add(final1);
+ final1 = calculator.GetProcessedValue(true);
+ }
+
+ results[handle.Index].HistoryData = new ExtensionObject(new HistoryData
+ {
+ DataValues = processedValues
+ });
+
+ errors[handle.Index] = ServiceResult.Good;
+ }
+ }
+
+ ///
+ /// Releases continuation points for history read operations.
+ ///
+ protected override async ValueTask HistoryReleaseContinuationPointsAsync(
+ ServerSystemContext context,
+ ArrayOf nodesToRead,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache,
+ CancellationToken cancellationToken = default)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+
+ NodeState source = await ValidateNodeAsync(
+ context, handle, cache, cancellationToken).ConfigureAwait(false);
+
+ if (source == null)
+ {
+ continue;
+ }
+
+ if (source is not BaseVariableState)
+ {
+ errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
+ continue;
+ }
+
+ errors[handle.Index] = HistoryReadRaw(
+ context,
+ null,
+ null,
+ TimestampsToReturn.Neither,
+ true,
+ nodesToRead[handle.Index],
+ new HistoryReadResult());
+ }
+ }
+
+ ///
+ /// Inserts, replaces, or updates raw history data values.
+ ///
+ protected override async ValueTask HistoryUpdateDataAsync(
+ ServerSystemContext context,
+ ArrayOf nodesToUpdate,
+ IList results,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache,
+ CancellationToken cancellationToken)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+
+ NodeState source = await ValidateNodeAsync(
+ context, handle, cache, cancellationToken).ConfigureAwait(false);
+ if (source == null)
+ {
+ continue;
+ }
+
+ if (GetHistoryDataSource(handle.NodeId) is not IHistoryDataSource file)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ UpdateDataDetails details = nodesToUpdate[handle.Index];
+ HistoryUpdateResult result = results[handle.Index];
+
+ var perResult = new List();
+ if (!details.UpdateValues.IsNull)
+ {
+ foreach (DataValue value in details.UpdateValues)
+ {
+ StatusCode sc = details.PerformInsertReplace switch
+ {
+ PerformUpdateType.Insert => file.InsertRaw(value),
+ PerformUpdateType.Replace => file.ReplaceRaw(value),
+ PerformUpdateType.Update => file.UpsertRaw(value),
+ PerformUpdateType.Remove => file.DeleteAtTime(value.SourceTimestamp.ToDateTime()),
+ _ => StatusCodes.BadInvalidArgument
+ };
+ perResult.Add(sc);
+ }
+ }
+ result.OperationResults = perResult;
+ errors[handle.Index] = ServiceResult.Good;
+ }
+ }
+
+ ///
+ /// Deletes raw history values in a time range.
+ ///
+ protected override async ValueTask HistoryDeleteRawModifiedAsync(
+ ServerSystemContext context,
+ ArrayOf nodesToUpdate,
+ IList results,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache,
+ CancellationToken cancellationToken = default)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+
+ NodeState source = await ValidateNodeAsync(
+ context, handle, cache, cancellationToken).ConfigureAwait(false);
+ if (source == null)
+ {
+ continue;
+ }
+
+ if (GetHistoryDataSource(handle.NodeId) is not IHistoryDataSource file)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ DeleteRawModifiedDetails details = nodesToUpdate[handle.Index];
+
+ // Reject IsDeleteModified — we don't track modified history separately.
+ if (details.IsDeleteModified)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ file.DeleteRaw(details.StartTime.ToDateTime(), details.EndTime.ToDateTime());
+ errors[handle.Index] = ServiceResult.Good;
+ }
+ }
+
+ ///
+ /// Deletes raw history values at specific timestamps.
+ ///
+ protected override async ValueTask HistoryDeleteAtTimeAsync(
+ ServerSystemContext context,
+ ArrayOf nodesToUpdate,
+ IList results,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache,
+ CancellationToken cancellationToken = default)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+
+ NodeState source = await ValidateNodeAsync(
+ context, handle, cache, cancellationToken).ConfigureAwait(false);
+ if (source == null)
+ {
+ continue;
+ }
+
+ if (GetHistoryDataSource(handle.NodeId) is not IHistoryDataSource file)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ DeleteAtTimeDetails details = nodesToUpdate[handle.Index];
+ HistoryUpdateResult result = results[handle.Index];
+
+ var perResult = new List();
+ if (!details.ReqTimes.IsNull)
+ {
+ foreach (DateTimeUtc t in details.ReqTimes)
+ {
+ perResult.Add(file.DeleteAtTime(t.ToDateTime()));
+ }
+ }
+ result.OperationResults = perResult;
+ errors[handle.Index] = ServiceResult.Good;
+ }
+ }
+
+ #endregion
private static readonly ArrayOf s_doubleArray =
[
diff --git a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs
index 6db712f768..cdac4184bc 100644
--- a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs
+++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs
@@ -29,7 +29,10 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Gds.Server;
@@ -102,6 +105,21 @@ public ReferenceServer(ITelemetryContext telemetry)
///
public bool ProvisioningMode { get; set; }
+ ///
+ /// If true, the server creates the optional node managers used by the
+ /// conformance test suite (Role/RoleSet, Part 17 AliasName, FileSystem
+ /// node manager). These materially grow the address space — only
+ /// enable them in tests / hosts that exercise those features. Default
+ /// is false so the standard test fixtures keep a small,
+ /// browse-friendly address space.
+ ///
+ public bool EnableConformanceNodeManagers { get; set; }
+
+ ///
+ /// The user database used for credential verification and user management.
+ ///
+ public IUserDatabase UserDatabase => m_userDatabase;
+
///
/// Creates the node managers for the server.
///
@@ -141,6 +159,7 @@ protected override IMasterNodeManager CreateMasterNodeManager(
UseSamplingGroupsInReferenceNodeManager);
#pragma warning restore CA2000
asyncNodeManagers = [referenceNodeManager];
+ m_referenceNodeManager = referenceNodeManager;
referenceNodeManager = null;
}
finally
@@ -162,9 +181,44 @@ protected override IMasterNodeManager CreateMasterNodeManager(
}
}
+ // Create role management handler and node manager so that
+ // role methods and properties are registered via external
+ // references and visible to Browse.
+ m_roleManagement = new RoleManagementHandler(server, server.Telemetry);
+ var roleNodeManager = new RoleManagementNodeManager(
+ server, configuration, m_roleManagement);
+ nodeManagers.Add(roleNodeManager);
+
+ if (EnableConformanceNodeManagers)
+ {
+ // OPC UA Part 17 — AliasName provider for the reference server.
+ var aliasNameNodeManager = new AliasNameNodeManager(server, configuration);
+ nodeManagers.Add(aliasNameNodeManager);
+
+ // FileSystem node manager — exposes host drives/directories/files
+ // under the Server object using FileDirectoryType / FileType.
+ var fileSystemNodeManager = new Quickstarts.FileSystem.FileSystemNodeManager(server, configuration);
+ nodeManagers.Add(fileSystemNodeManager);
+ }
+
return new MasterNodeManager(server, configuration, null, asyncNodeManagers, nodeManagers);
}
+ ///
+ /// Overrides the SDK default factory to plug in a
+ /// reference-server-specific
+ /// that applies a few CTT-only address-space tweaks (Server-node
+ /// RolePermissions, HasAddIn instance, optional EngineeringUnits
+ /// on AnalogItemType). Keeping these out of the SDK avoids
+ /// polluting the standard nodeset for non-CTT hosts.
+ ///
+ protected override IMainNodeManagerFactory CreateMainNodeManagerFactory(
+ IServerInternal server,
+ ApplicationConfiguration configuration)
+ {
+ return new ReferenceServerMainNodeManagerFactory(configuration, server);
+ }
+
protected override IMonitoredItemQueueFactory CreateMonitoredItemQueueFactory(
IServerInternal server,
ApplicationConfiguration configuration)
@@ -242,6 +296,8 @@ protected override void OnServerStarting(ApplicationConfiguration configuration)
m_logger.LogInformation(Utils.TraceMasks.StartStop, "The server is starting.");
+ InitializeUserDatabase();
+
// it is up to the application to decide how to validate user identity tokens.
// this function creates validator for X509 identity tokens.
CreateUserIdentityValidators(configuration);
@@ -427,29 +483,42 @@ private RoleBasedIdentity VerifyPassword(UserNameIdentityTokenHandler userTokenH
"Security token is not a valid username token. An empty password is not accepted.");
}
- if (m_userDatabase.CheckCredentials(userName, password))
+ if (!m_userDatabase.CheckCredentials(userName, password))
{
- var userIdentity = new UserIdentity(userTokenHandler);
- ICollection roles = m_userDatabase.GetUserRoles(userName);
- return new RoleBasedIdentity(
- userIdentity,
- roles,
- ServerInternal.MessageContext.NamespaceUris);
+ // construct translation object with default text.
+ var info = new TranslationInfo(
+ "InvalidPassword",
+ "en-US",
+ "Invalid username or password.",
+ userName);
+
+ // create an exception with a vendor defined sub-code.
+ throw new ServiceResultException(
+ new ServiceResult(
+ LoadServerProperties().ProductUri,
+ new StatusCode(StatusCodes.BadUserAccessDenied.Code, "InvalidPassword"),
+ new LocalizedText(info)));
}
- // construct translation object with default text.
- var info = new TranslationInfo(
- "InvalidPassword",
- "en-US",
- "Invalid username or password.",
- userName);
+ ICollection roles = m_userDatabase.GetUserRoles(userName);
+ var identity = new UserIdentity(userTokenHandler);
+ try
+ {
+ if (roles != null && roles.Contains(Role.SecurityAdmin))
+ {
+ return new SystemConfigurationIdentity(identity);
+ }
- // create an exception with a vendor defined sub-code.
- throw new ServiceResultException(
- new ServiceResult(
- LoadServerProperties().ProductUri,
- new StatusCode(StatusCodes.BadUserAccessDenied.Code, "InvalidPassword"),
- new LocalizedText(info)));
+ return new RoleBasedIdentity(
+ identity,
+ roles ?? [Role.AuthenticatedUser],
+ ServerInternal.MessageContext.NamespaceUris);
+ }
+ catch
+ {
+ // UserIdentity is no longer IDisposable; nothing to release.
+ throw;
+ }
}
///
@@ -586,7 +655,811 @@ private IUserIdentity VerifyIssuedToken(IssuedIdentityTokenHandler issuedTokenHa
}
}
+ ///
+ /// Initializes the user database with the default demo users.
+ ///
+ private void InitializeUserDatabase()
+ {
+ m_userDatabase = new LinqUserDatabase();
+
+ // User with permission to configure server
+ m_userDatabase.CreateUser(
+ "sysadmin",
+ Encoding.UTF8.GetBytes("demo"),
+ [Role.SecurityAdmin, Role.ConfigureAdmin, Role.AuthenticatedUser]);
+
+ // Standard users for CTT verification
+ m_userDatabase.CreateUser(
+ "user1",
+ Encoding.UTF8.GetBytes("password"),
+ [Role.AuthenticatedUser]);
+
+ m_userDatabase.CreateUser(
+ "user2",
+ Encoding.UTF8.GetBytes("password1"),
+ [Role.AuthenticatedUser]);
+ }
+
+ ///
+ /// Gets the role management handler for this server.
+ ///
+ public RoleManagementHandler RoleManagement => m_roleManagement;
+
+ ///
+ /// Implements the AddNodes service to allow conformance tests to
+ /// exercise the Node Management service set against the reference
+ /// server. Newly added nodes live in the writable
+ /// namespace regardless of the
+ /// parent node's namespace; an inverse reference is also added to
+ /// the parent so the new node is reachable via Browse.
+ ///
+ ///
+ /// TODO (follow-up, see PR #3750 review comment): move this
+ /// implementation into and resolve it
+ /// through so AddNodes / DeleteNodes
+ /// dispatch is centralised in the SDK rather than reimplemented in
+ /// each derived server. The current routing keeps writes constrained
+ /// to which is acceptable for the
+ /// CTT but not the desired long-term shape.
+ ///
+ public override async ValueTask AddNodesAsync(
+ SecureChannelContext secureChannelContext,
+ RequestHeader requestHeader,
+ ArrayOf nodesToAdd,
+ RequestLifetime requestLifetime)
+ {
+ OperationContext context = await ValidateRequestAsync(
+ secureChannelContext,
+ requestHeader,
+ RequestType.AddNodes,
+ requestLifetime).ConfigureAwait(false);
+
+ try
+ {
+ ValidateOperationLimits(
+ nodesToAdd,
+ ServerInternal.ServerObject.ServerCapabilities.OperationLimits
+ .MaxNodesPerNodeManagement);
+
+ var results = new AddNodesResult[nodesToAdd.Count];
+ var diagnosticInfos = new DiagnosticInfo[nodesToAdd.Count];
+ bool anyDiagnostics = false;
+
+ for (int ii = 0; ii < nodesToAdd.Count; ii++)
+ {
+ (StatusCode statusCode, NodeId addedNodeId) =
+ await TryAddNodeAsync(
+ context,
+ nodesToAdd[ii],
+ requestLifetime.CancellationToken).ConfigureAwait(false);
+
+ results[ii] = new AddNodesResult
+ {
+ StatusCode = statusCode,
+ AddedNodeId = addedNodeId
+ };
+
+ if (StatusCode.IsBad(statusCode))
+ {
+ anyDiagnostics = true;
+ diagnosticInfos[ii] = new DiagnosticInfo(
+ new ServiceResult(statusCode),
+ context.DiagnosticsMask,
+ false,
+ context.StringTable,
+ m_logger);
+ }
+ }
+
+ return new AddNodesResponse
+ {
+ Results = results.ToArrayOf(),
+ DiagnosticInfos = anyDiagnostics
+ ? diagnosticInfos.ToArrayOf()
+ : default,
+ ResponseHeader = CreateResponse(requestHeader, context.StringTable)
+ };
+ }
+ catch (ServiceResultException e)
+ {
+ lock (ServerInternal.DiagnosticsWriteLock)
+ {
+ ServerInternal.ServerDiagnostics.RejectedRequestsCount++;
+ if (IsSecurityError(e.StatusCode))
+ {
+ ServerInternal.ServerDiagnostics.SecurityRejectedRequestsCount++;
+ }
+ }
+ throw TranslateException(context, e);
+ }
+ finally
+ {
+ OnRequestComplete(context);
+ }
+ }
+
+ ///
+ /// Implements the DeleteNodes service for the reference server. Only
+ /// nodes managed by the writable
+ /// can be removed; attempts to delete nodes from other node managers
+ /// (for example, the core address space) return
+ /// .
+ ///
+ public override async ValueTask DeleteNodesAsync(
+ SecureChannelContext secureChannelContext,
+ RequestHeader requestHeader,
+ ArrayOf nodesToDelete,
+ RequestLifetime requestLifetime)
+ {
+ OperationContext context = await ValidateRequestAsync(
+ secureChannelContext,
+ requestHeader,
+ RequestType.DeleteNodes,
+ requestLifetime).ConfigureAwait(false);
+
+ try
+ {
+ ValidateOperationLimits(
+ nodesToDelete,
+ ServerInternal.ServerObject.ServerCapabilities.OperationLimits
+ .MaxNodesPerNodeManagement);
+
+ var results = new StatusCode[nodesToDelete.Count];
+ var diagnosticInfos = new DiagnosticInfo[nodesToDelete.Count];
+ bool anyDiagnostics = false;
+
+ for (int ii = 0; ii < nodesToDelete.Count; ii++)
+ {
+ StatusCode statusCode = await TryDeleteNodeAsync(
+ context,
+ nodesToDelete[ii],
+ requestLifetime.CancellationToken).ConfigureAwait(false);
+
+ results[ii] = statusCode;
+
+ if (StatusCode.IsBad(statusCode))
+ {
+ anyDiagnostics = true;
+ diagnosticInfos[ii] = new DiagnosticInfo(
+ new ServiceResult(statusCode),
+ context.DiagnosticsMask,
+ false,
+ context.StringTable,
+ m_logger);
+ }
+ }
+
+ return new DeleteNodesResponse
+ {
+ Results = results.ToArrayOf(),
+ DiagnosticInfos = anyDiagnostics
+ ? diagnosticInfos.ToArrayOf()
+ : default,
+ ResponseHeader = CreateResponse(requestHeader, context.StringTable)
+ };
+ }
+ catch (ServiceResultException e)
+ {
+ lock (ServerInternal.DiagnosticsWriteLock)
+ {
+ ServerInternal.ServerDiagnostics.RejectedRequestsCount++;
+ if (IsSecurityError(e.StatusCode))
+ {
+ ServerInternal.ServerDiagnostics.SecurityRejectedRequestsCount++;
+ }
+ }
+ throw TranslateException(context, e);
+ }
+ finally
+ {
+ OnRequestComplete(context);
+ }
+ }
+
+ ///
+ /// Implements the AddReferences service. Forward references are added
+ /// through the master node manager which dispatches the change to the
+ /// node manager that owns the source node.
+ ///
+ public override async ValueTask AddReferencesAsync(
+ SecureChannelContext secureChannelContext,
+ RequestHeader requestHeader,
+ ArrayOf referencesToAdd,
+ RequestLifetime requestLifetime)
+ {
+ OperationContext context = await ValidateRequestAsync(
+ secureChannelContext,
+ requestHeader,
+ RequestType.AddReferences,
+ requestLifetime).ConfigureAwait(false);
+
+ try
+ {
+ ValidateOperationLimits(
+ referencesToAdd,
+ ServerInternal.ServerObject.ServerCapabilities.OperationLimits
+ .MaxNodesPerNodeManagement);
+
+ var results = new StatusCode[referencesToAdd.Count];
+ var diagnosticInfos = new DiagnosticInfo[referencesToAdd.Count];
+ bool anyDiagnostics = false;
+
+ for (int ii = 0; ii < referencesToAdd.Count; ii++)
+ {
+ StatusCode statusCode = await TryAddReferenceAsync(
+ referencesToAdd[ii],
+ requestLifetime.CancellationToken).ConfigureAwait(false);
+
+ results[ii] = statusCode;
+
+ if (StatusCode.IsBad(statusCode))
+ {
+ anyDiagnostics = true;
+ diagnosticInfos[ii] = new DiagnosticInfo(
+ new ServiceResult(statusCode),
+ context.DiagnosticsMask,
+ false,
+ context.StringTable,
+ m_logger);
+ }
+ }
+
+ return new AddReferencesResponse
+ {
+ Results = results.ToArrayOf(),
+ DiagnosticInfos = anyDiagnostics
+ ? diagnosticInfos.ToArrayOf()
+ : default,
+ ResponseHeader = CreateResponse(requestHeader, context.StringTable)
+ };
+ }
+ catch (ServiceResultException e)
+ {
+ lock (ServerInternal.DiagnosticsWriteLock)
+ {
+ ServerInternal.ServerDiagnostics.RejectedRequestsCount++;
+ if (IsSecurityError(e.StatusCode))
+ {
+ ServerInternal.ServerDiagnostics.SecurityRejectedRequestsCount++;
+ }
+ }
+ throw TranslateException(context, e);
+ }
+ finally
+ {
+ OnRequestComplete(context);
+ }
+ }
+
+ ///
+ /// Implements the DeleteReferences service. The change is dispatched
+ /// to the node manager that owns the source node through the master
+ /// node manager.
+ ///
+ public override async ValueTask DeleteReferencesAsync(
+ SecureChannelContext secureChannelContext,
+ RequestHeader requestHeader,
+ ArrayOf referencesToDelete,
+ RequestLifetime requestLifetime)
+ {
+ OperationContext context = await ValidateRequestAsync(
+ secureChannelContext,
+ requestHeader,
+ RequestType.DeleteReferences,
+ requestLifetime).ConfigureAwait(false);
+
+ try
+ {
+ ValidateOperationLimits(
+ referencesToDelete,
+ ServerInternal.ServerObject.ServerCapabilities.OperationLimits
+ .MaxNodesPerNodeManagement);
+
+ var results = new StatusCode[referencesToDelete.Count];
+ var diagnosticInfos = new DiagnosticInfo[referencesToDelete.Count];
+ bool anyDiagnostics = false;
+
+ for (int ii = 0; ii < referencesToDelete.Count; ii++)
+ {
+ StatusCode statusCode = await TryDeleteReferenceAsync(
+ referencesToDelete[ii],
+ requestLifetime.CancellationToken).ConfigureAwait(false);
+
+ results[ii] = statusCode;
+
+ if (StatusCode.IsBad(statusCode))
+ {
+ anyDiagnostics = true;
+ diagnosticInfos[ii] = new DiagnosticInfo(
+ new ServiceResult(statusCode),
+ context.DiagnosticsMask,
+ false,
+ context.StringTable,
+ m_logger);
+ }
+ }
+
+ return new DeleteReferencesResponse
+ {
+ Results = results.ToArrayOf(),
+ DiagnosticInfos = anyDiagnostics
+ ? diagnosticInfos.ToArrayOf()
+ : default,
+ ResponseHeader = CreateResponse(requestHeader, context.StringTable)
+ };
+ }
+ catch (ServiceResultException e)
+ {
+ lock (ServerInternal.DiagnosticsWriteLock)
+ {
+ ServerInternal.ServerDiagnostics.RejectedRequestsCount++;
+ if (IsSecurityError(e.StatusCode))
+ {
+ ServerInternal.ServerDiagnostics.SecurityRejectedRequestsCount++;
+ }
+ }
+ throw TranslateException(context, e);
+ }
+ finally
+ {
+ OnRequestComplete(context);
+ }
+ }
+
+ ///
+ /// Validates a single AddNodes item and creates the node in the
+ /// reference node manager when the request is acceptable.
+ ///
+ private async ValueTask<(StatusCode statusCode, NodeId addedNodeId)> TryAddNodeAsync(
+ OperationContext context,
+ AddNodesItem item,
+ CancellationToken cancellationToken)
+ {
+ ReferenceNodeManager nodeManager = m_referenceNodeManager;
+ if (nodeManager == null)
+ {
+ return (StatusCodes.BadNotSupported, NodeId.Null);
+ }
+
+ if (item == null || item.BrowseName.IsNull)
+ {
+ return (StatusCodes.BadBrowseNameInvalid, NodeId.Null);
+ }
+
+ if (!IsSupportedNodeClass(item.NodeClass))
+ {
+ return (StatusCodes.BadNodeClassInvalid, NodeId.Null);
+ }
+
+ // Validate the parent node id and resolve it to the local server.
+ NodeId parentNodeId = ExpandedNodeId.ToNodeId(
+ item.ParentNodeId,
+ ServerInternal.NamespaceUris);
+
+ if (parentNodeId.IsNull)
+ {
+ return (StatusCodes.BadParentNodeIdInvalid, NodeId.Null);
+ }
+
+ (object parentHandle, _) = await ServerInternal.NodeManager
+ .GetManagerHandleAsync(parentNodeId, cancellationToken)
+ .ConfigureAwait(false);
+ if (parentHandle == null)
+ {
+ return (StatusCodes.BadParentNodeIdInvalid, NodeId.Null);
+ }
+
+ // Validate the reference type.
+ if (item.ReferenceTypeId.IsNull ||
+ !ServerInternal.TypeTree.IsKnown(item.ReferenceTypeId))
+ {
+ return (StatusCodes.BadReferenceTypeIdInvalid, NodeId.Null);
+ }
+
+ if (!ServerInternal.TypeTree.IsTypeOf(
+ item.ReferenceTypeId,
+ ReferenceTypeIds.HierarchicalReferences))
+ {
+ return (StatusCodes.BadReferenceNotAllowed, NodeId.Null);
+ }
+
+ // Reject client-provided NodeIds — the server assigns NodeIds.
+ if (!item.RequestedNewNodeId.IsNull)
+ {
+ return (StatusCodes.BadNodeIdRejected, NodeId.Null);
+ }
+
+ // Validate the type definition.
+ NodeId typeDefinitionId = ExpandedNodeId.ToNodeId(
+ item.TypeDefinition,
+ ServerInternal.NamespaceUris);
+
+ BaseInstanceState instance;
+ try
+ {
+ instance = CreateInstanceFromAddNodesItem(item, typeDefinitionId);
+ }
+ catch (ServiceResultException ex)
+ {
+ return (ex.StatusCode, NodeId.Null);
+ }
+
+ // Detect duplicate browse names under the same parent before adding.
+ if (await BrowseNameExistsUnderParentAsync(
+ context,
+ parentNodeId,
+ item.BrowseName,
+ item.ReferenceTypeId,
+ cancellationToken).ConfigureAwait(false))
+ {
+ return (StatusCodes.BadBrowseNameDuplicated, NodeId.Null);
+ }
+
+ try
+ {
+ NodeId addedNodeId = await nodeManager.AddInstanceNodeAsync(
+ new ServerSystemContext(ServerInternal, context),
+ parentNodeId,
+ item.ReferenceTypeId,
+ instance,
+ cancellationToken).ConfigureAwait(false);
+
+ return (StatusCodes.Good, addedNodeId);
+ }
+ catch (ServiceResultException ex)
+ {
+ return (ex.StatusCode, NodeId.Null);
+ }
+ }
+
+ ///
+ /// Validates a single DeleteNodes item and removes the node when it
+ /// is owned by the reference node manager.
+ ///
+ private async ValueTask TryDeleteNodeAsync(
+ OperationContext context,
+ DeleteNodesItem item,
+ CancellationToken cancellationToken)
+ {
+ if (item == null || item.NodeId.IsNull)
+ {
+ return StatusCodes.BadNodeIdInvalid;
+ }
+
+ (object handle, IAsyncNodeManager nodeManager) = await ServerInternal
+ .NodeManager.GetManagerHandleAsync(item.NodeId, cancellationToken)
+ .ConfigureAwait(false);
+ if (handle == null)
+ {
+ return StatusCodes.BadNodeIdUnknown;
+ }
+
+ // Only allow deletion in the writable reference namespace to avoid
+ // breaking the core address space exposed by the SDK.
+ if (nodeManager is not ReferenceNodeManager referenceNodeManager ||
+ referenceNodeManager != m_referenceNodeManager)
+ {
+ return StatusCodes.BadUserAccessDenied;
+ }
+
+ bool removed = await referenceNodeManager.DeleteNodeAsync(
+ new ServerSystemContext(ServerInternal, context),
+ item.NodeId,
+ cancellationToken).ConfigureAwait(false);
+
+ return removed ? (StatusCode)StatusCodes.Good : StatusCodes.BadNodeIdUnknown;
+ }
+
+ ///
+ /// Adds a single reference using the master node manager.
+ ///
+ private async ValueTask TryAddReferenceAsync(
+ AddReferencesItem item,
+ CancellationToken cancellationToken)
+ {
+ if (item == null ||
+ item.SourceNodeId.IsNull ||
+ item.ReferenceTypeId.IsNull)
+ {
+ return StatusCodes.BadNodeIdInvalid;
+ }
+
+ if (!ServerInternal.TypeTree.IsKnown(item.ReferenceTypeId))
+ {
+ return StatusCodes.BadReferenceTypeIdInvalid;
+ }
+
+ (object sourceHandle, _) = await ServerInternal.NodeManager
+ .GetManagerHandleAsync(item.SourceNodeId, cancellationToken)
+ .ConfigureAwait(false);
+ if (sourceHandle == null)
+ {
+ return StatusCodes.BadSourceNodeIdInvalid;
+ }
+
+ NodeId targetNodeId = ExpandedNodeId.ToNodeId(
+ item.TargetNodeId,
+ ServerInternal.NamespaceUris);
+ if (targetNodeId.IsNull)
+ {
+ return StatusCodes.BadTargetNodeIdInvalid;
+ }
+
+ (object targetHandle, _) = await ServerInternal.NodeManager
+ .GetManagerHandleAsync(targetNodeId, cancellationToken)
+ .ConfigureAwait(false);
+ if (targetHandle == null)
+ {
+ return StatusCodes.BadTargetNodeIdInvalid;
+ }
+
+ try
+ {
+ var references = new List
+ {
+ new NodeStateReference(
+ item.ReferenceTypeId,
+ !item.IsForward,
+ targetNodeId)
+ };
+
+ await ServerInternal.NodeManager.AddReferencesAsync(
+ item.SourceNodeId,
+ references,
+ cancellationToken).ConfigureAwait(false);
+
+ return StatusCodes.Good;
+ }
+ catch (ServiceResultException ex)
+ {
+ return ex.StatusCode;
+ }
+ }
+
+ ///
+ /// Deletes a single reference using the master node manager.
+ ///
+ private async ValueTask TryDeleteReferenceAsync(
+ DeleteReferencesItem item,
+ CancellationToken cancellationToken)
+ {
+ if (item == null ||
+ item.SourceNodeId.IsNull ||
+ item.ReferenceTypeId.IsNull)
+ {
+ return StatusCodes.BadNodeIdInvalid;
+ }
+
+ (object sourceHandle, IAsyncNodeManager nodeManager) = await ServerInternal
+ .NodeManager.GetManagerHandleAsync(item.SourceNodeId, cancellationToken)
+ .ConfigureAwait(false);
+ if (sourceHandle == null)
+ {
+ return StatusCodes.BadSourceNodeIdInvalid;
+ }
+
+ try
+ {
+ ServiceResult result = await nodeManager.DeleteReferenceAsync(
+ sourceHandle,
+ item.ReferenceTypeId,
+ !item.IsForward,
+ item.TargetNodeId,
+ item.DeleteBidirectional,
+ cancellationToken).ConfigureAwait(false);
+
+ return result == null ? StatusCodes.Good : result.StatusCode;
+ }
+ catch (ServiceResultException ex)
+ {
+ return ex.StatusCode;
+ }
+ }
+
+ ///
+ /// Returns true when the supplied NodeClass is one this server
+ /// permits clients to add at runtime.
+ ///
+ private static bool IsSupportedNodeClass(NodeClass nodeClass)
+ {
+ return nodeClass is NodeClass.Object or NodeClass.Variable;
+ }
+
+ ///
+ /// Creates the NodeState for an AddNodes request based on the
+ /// requested node class and provided attributes.
+ ///
+ ///
+ /// Thrown when the supplied attributes are not valid for the node
+ /// class.
+ ///
+ private static BaseInstanceState CreateInstanceFromAddNodesItem(
+ AddNodesItem item,
+ NodeId typeDefinitionId)
+ {
+ switch (item.NodeClass)
+ {
+ case NodeClass.Variable:
+ {
+ var variable = new BaseDataVariableState(null)
+ {
+ BrowseName = item.BrowseName,
+ DisplayName = new LocalizedText(item.BrowseName.Name),
+ TypeDefinitionId = typeDefinitionId.IsNull
+ ? VariableTypeIds.BaseDataVariableType
+ : typeDefinitionId,
+ AccessLevel = AccessLevels.CurrentReadOrWrite,
+ UserAccessLevel = AccessLevels.CurrentReadOrWrite,
+ DataType = DataTypeIds.BaseDataType,
+ ValueRank = ValueRanks.Scalar
+ };
+
+ if (!item.NodeAttributes.IsNull)
+ {
+ if (!item.NodeAttributes.TryGetValue(out VariableAttributes va))
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadNodeAttributesInvalid);
+ }
+ ApplyVariableAttributes(variable, va);
+ }
+
+ return variable;
+ }
+ case NodeClass.Object:
+ {
+ var instance = new BaseObjectState(null)
+ {
+ BrowseName = item.BrowseName,
+ DisplayName = new LocalizedText(item.BrowseName.Name),
+ TypeDefinitionId = typeDefinitionId.IsNull
+ ? ObjectTypeIds.BaseObjectType
+ : typeDefinitionId
+ };
+
+ if (!item.NodeAttributes.IsNull)
+ {
+ if (!item.NodeAttributes.TryGetValue(out ObjectAttributes oa))
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadNodeAttributesInvalid);
+ }
+ ApplyObjectAttributes(instance, oa);
+ }
+
+ return instance;
+ }
+ default:
+ throw new ServiceResultException(
+ StatusCodes.BadNodeClassInvalid);
+ }
+ }
+
+ private static void ApplyVariableAttributes(
+ BaseDataVariableState variable,
+ VariableAttributes attributes)
+ {
+ uint mask = attributes.SpecifiedAttributes;
+ if ((mask & (uint)NodeAttributesMask.DisplayName) != 0 &&
+ !attributes.DisplayName.IsNull)
+ {
+ variable.DisplayName = attributes.DisplayName;
+ }
+ if ((mask & (uint)NodeAttributesMask.Description) != 0 &&
+ !attributes.Description.IsNull)
+ {
+ variable.Description = attributes.Description;
+ }
+ if ((mask & (uint)NodeAttributesMask.DataType) != 0 &&
+ !attributes.DataType.IsNull)
+ {
+ variable.DataType = attributes.DataType;
+ }
+ if ((mask & (uint)NodeAttributesMask.ValueRank) != 0)
+ {
+ variable.ValueRank = attributes.ValueRank;
+ }
+ if ((mask & (uint)NodeAttributesMask.AccessLevel) != 0)
+ {
+ variable.AccessLevel = attributes.AccessLevel;
+ }
+ if ((mask & (uint)NodeAttributesMask.UserAccessLevel) != 0)
+ {
+ variable.UserAccessLevel = attributes.UserAccessLevel;
+ }
+ if ((mask & (uint)NodeAttributesMask.Historizing) != 0)
+ {
+ variable.Historizing = attributes.Historizing;
+ }
+ if ((mask & (uint)NodeAttributesMask.MinimumSamplingInterval) != 0)
+ {
+ variable.MinimumSamplingInterval = attributes.MinimumSamplingInterval;
+ }
+ if ((mask & (uint)NodeAttributesMask.Value) != 0)
+ {
+ variable.Value = attributes.Value;
+ }
+ }
+
+ private static void ApplyObjectAttributes(
+ BaseObjectState instance,
+ ObjectAttributes attributes)
+ {
+ uint mask = attributes.SpecifiedAttributes;
+ if ((mask & (uint)NodeAttributesMask.DisplayName) != 0 &&
+ !attributes.DisplayName.IsNull)
+ {
+ instance.DisplayName = attributes.DisplayName;
+ }
+ if ((mask & (uint)NodeAttributesMask.Description) != 0 &&
+ !attributes.Description.IsNull)
+ {
+ instance.Description = attributes.Description;
+ }
+ if ((mask & (uint)NodeAttributesMask.EventNotifier) != 0)
+ {
+ instance.EventNotifier = attributes.EventNotifier;
+ }
+ }
+
+ ///
+ /// Returns true if a node with the requested browse name already exists
+ /// directly under the parent for the given hierarchical reference type.
+ ///
+ private async ValueTask BrowseNameExistsUnderParentAsync(
+ OperationContext context,
+ NodeId parentNodeId,
+ QualifiedName browseName,
+ NodeId referenceTypeId,
+ CancellationToken cancellationToken)
+ {
+ var browseDescriptions = new BrowseDescription[]
+ {
+ new()
+ {
+ NodeId = parentNodeId,
+ BrowseDirection = BrowseDirection.Forward,
+ ReferenceTypeId = referenceTypeId,
+ IncludeSubtypes = true,
+ NodeClassMask = 0,
+ ResultMask = (uint)BrowseResultMask.BrowseName
+ }
+ };
+
+ try
+ {
+ (ArrayOf results, _) = await ServerInternal.NodeManager
+ .BrowseAsync(
+ context,
+ null,
+ 0,
+ browseDescriptions.ToArrayOf(),
+ cancellationToken).ConfigureAwait(false);
+
+ if (results.Count == 0 || results[0].References.IsNull)
+ {
+ return false;
+ }
+
+ foreach (ReferenceDescription reference in results[0].References)
+ {
+ if (reference.BrowseName == browseName)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ catch (ServiceResultException)
+ {
+ return false;
+ }
+ }
+
private CertificateManager m_userCertificateValidator;
- private readonly LinqUserDatabase m_userDatabase;
+ private LinqUserDatabase m_userDatabase;
+ private RoleManagementHandler m_roleManagement;
+ private ReferenceNodeManager m_referenceNodeManager;
}
}
diff --git a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServerConfigurationNodeManager.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServerConfigurationNodeManager.cs
new file mode 100644
index 0000000000..8c24231596
--- /dev/null
+++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServerConfigurationNodeManager.cs
@@ -0,0 +1,210 @@
+/* ========================================================================
+ * 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;
+using Opc.Ua;
+using Opc.Ua.Server;
+
+namespace Quickstarts.ReferenceServer
+{
+ ///
+ /// Reference-server-specific that
+ /// applies a few address-space tweaks needed by the OPC UA Conformance
+ /// Test (CTT) suite. These tweaks intentionally live outside of the SDK
+ /// because they are test-suite-specific and modify the standard nodeset:
+ ///
+ /// - Populate RolePermissions/UserRolePermissions/AccessRestrictions
+ /// on the Server node so CTT tests that read those optional
+ /// attributes get a defined value instead of BadAttributeIdInvalid.
+ /// - Expose a single HasAddIn instance under the Server
+ /// node so CTT tests that require at least one AddIn complete.
+ /// - Add an optional EngineeringUnits property to
+ /// AnalogItemType (Part 8 §5.3.2.3) so CTT browse tests that
+ /// expect this child don't skip.
+ ///
+ ///
+ public sealed class ReferenceServerConfigurationNodeManager : ConfigurationNodeManager
+ {
+ ///
+ /// Initializes the configuration node manager.
+ ///
+ public ReferenceServerConfigurationNodeManager(
+ IServerInternal server,
+ ApplicationConfiguration configuration)
+ : base(server, configuration)
+ {
+ }
+
+ ///
+ public override async ValueTask CreateAddressSpaceAsync(
+ IDictionary> externalReferences,
+ CancellationToken cancellationToken = default)
+ {
+ await base.CreateAddressSpaceAsync(externalReferences, cancellationToken)
+ .ConfigureAwait(false);
+
+ ushort diagnosticsNamespaceIndex = (ushort)Server.NamespaceUris
+ .GetIndexOrAppend(Opc.Ua.Namespaces.OpcUa + "Diagnostics");
+
+ var addedNodes = new List();
+
+ // CTT: populate RolePermissions / UserRolePermissions /
+ // AccessRestrictions on the Server node so clients that read
+ // those optional attributes (per Part 5 §6.2) get a defined
+ // value instead of BadAttributeIdInvalid. Anonymous gets
+ // Browse + Read on the metadata; SecurityAdmin and
+ // ConfigureAdmin get the full permission mask. This is purely
+ // metadata exposure — it doesn't change runtime access
+ // enforcement (which is governed by the per-child node
+ // RolePermissions in the standard nodeset). The NodeState
+ // reference is shared between the diagnostics and core node
+ // managers, so attribute mutations propagate automatically.
+ ServerObjectState serverNode = FindPredefinedNode(
+ ObjectIds.Server);
+ if (serverNode != null && serverNode.RolePermissions.IsNull)
+ {
+ const PermissionType browseAndRead =
+ PermissionType.Browse | PermissionType.Read | PermissionType.ReceiveEvents;
+ const PermissionType fullAdmin =
+ PermissionType.Browse | PermissionType.ReadRolePermissions
+ | PermissionType.WriteAttribute | PermissionType.WriteRolePermissions
+ | PermissionType.WriteHistorizing | PermissionType.Read
+ | PermissionType.Write | PermissionType.ReadHistory
+ | PermissionType.InsertHistory | PermissionType.ModifyHistory
+ | PermissionType.DeleteHistory | PermissionType.ReceiveEvents
+ | PermissionType.Call | PermissionType.AddReference
+ | PermissionType.RemoveReference | PermissionType.DeleteNode;
+ var permissions = new RolePermissionType[]
+ {
+ new()
+ {
+ RoleId = ObjectIds.WellKnownRole_Anonymous,
+ Permissions = (uint)browseAndRead
+ },
+ new()
+ {
+ RoleId = ObjectIds.WellKnownRole_AuthenticatedUser,
+ Permissions = (uint)browseAndRead
+ },
+ new()
+ {
+ RoleId = ObjectIds.WellKnownRole_SecurityAdmin,
+ Permissions = (uint)fullAdmin
+ },
+ new()
+ {
+ RoleId = ObjectIds.WellKnownRole_ConfigureAdmin,
+ Permissions = (uint)fullAdmin
+ }
+ }.ToArrayOf();
+ serverNode.RolePermissions = permissions;
+ serverNode.UserRolePermissions = permissions;
+ serverNode.AccessRestrictions = AccessRestrictionType.None;
+ }
+
+ // CTT: expose a single AddIn instance under the Server node
+ // so the conformance tests that expect at least one HasAddIn
+ // reference (Address Space Base AddIn instance / inverse / target
+ // is Object) can complete successfully. AddIn instances are an
+ // optional extensibility point per Part 5 §6.3.6 — clients may
+ // browse Server forward via HasAddIn (i=17604) and inspect the
+ // returned objects. The instance is a plain BaseObjectState.
+ if (serverNode != null)
+ {
+ var addIn = new BaseObjectState(serverNode)
+ {
+ SymbolicName = "ConformanceAddIn",
+ ReferenceTypeId = ReferenceTypeIds.HasAddIn,
+ TypeDefinitionId = ObjectTypeIds.BaseObjectType,
+ NodeId = new NodeId("ConformanceAddIn", diagnosticsNamespaceIndex),
+ BrowseName = new QualifiedName("ConformanceAddIn", diagnosticsNamespaceIndex),
+ DisplayName = LocalizedText.From("ConformanceAddIn"),
+ WriteMask = AttributeWriteMask.None,
+ UserWriteMask = AttributeWriteMask.None,
+ EventNotifier = EventNotifiers.None
+ };
+ serverNode.AddReference(ReferenceTypeIds.HasAddIn, false, addIn.NodeId);
+ addIn.AddReference(ReferenceTypeIds.HasAddIn, true, serverNode.NodeId);
+ await AddPredefinedNodeAsync(SystemContext, addIn, cancellationToken)
+ .ConfigureAwait(false);
+ addedNodes.Add(addIn);
+ }
+
+ // CTT: declare the optional EngineeringUnits property on
+ // VariableTypeIds.AnalogItemType so conformance tests that
+ // browse the type for an "EngineeringUnits" child don't skip.
+ // Per Part 8 §5.3.2.3 EngineeringUnits is an optional
+ // PropertyType child of AnalogItemType — but the standard
+ // NodeSet2.xml as shipped only declares EURange. Adding
+ // EngineeringUnits as a HasProperty Optional child here
+ // matches the spec and is harmless for other clients (the
+ // property carries a default-constructed EUInformation
+ // value).
+ BaseVariableTypeState analogItemType = FindPredefinedNode(
+ VariableTypeIds.AnalogItemType);
+ if (analogItemType != null
+ && analogItemType.FindChild(SystemContext, new QualifiedName(BrowseNames.EngineeringUnits, 0)) == null)
+ {
+ PropertyState euProperty = PropertyState
+ .With>(analogItemType);
+ euProperty.SymbolicName = BrowseNames.EngineeringUnits;
+ euProperty.ReferenceTypeId = ReferenceTypeIds.HasProperty;
+ euProperty.TypeDefinitionId = VariableTypeIds.PropertyType;
+ euProperty.ModellingRuleId = ObjectIds.ModellingRule_Optional;
+ euProperty.NodeId = new NodeId(BrowseNames.EngineeringUnits, diagnosticsNamespaceIndex);
+ euProperty.BrowseName = new QualifiedName(BrowseNames.EngineeringUnits, 0);
+ euProperty.DisplayName = LocalizedText.From(BrowseNames.EngineeringUnits);
+ euProperty.DataType = DataTypeIds.EUInformation;
+ euProperty.ValueRank = ValueRanks.Scalar;
+ euProperty.AccessLevel = AccessLevels.CurrentRead;
+ euProperty.UserAccessLevel = AccessLevels.CurrentRead;
+ euProperty.Value = new EUInformation();
+ analogItemType.AddChild(euProperty);
+ await AddPredefinedNodeAsync(SystemContext, euProperty, cancellationToken)
+ .ConfigureAwait(false);
+ addedNodes.Add(euProperty);
+ }
+
+ // Push any newly added namespace-0 nodes into the CoreNodeManager
+ // so they are reachable via Browse from the standard address
+ // space (base.CreateAddressSpaceAsync already performed the bulk
+ // import; this top-up handles the CTT-only nodes).
+ if (addedNodes.Count > 0)
+ {
+ await Server.CoreNodeManager.ImportNodesAsync(
+ SystemContext,
+ addedNodes,
+ true,
+ cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServerMainNodeManagerFactory.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServerMainNodeManagerFactory.cs
new file mode 100644
index 0000000000..e03556facf
--- /dev/null
+++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServerMainNodeManagerFactory.cs
@@ -0,0 +1,68 @@
+/* ========================================================================
+ * 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;
+using Opc.Ua.Server;
+
+namespace Quickstarts.ReferenceServer
+{
+ ///
+ /// Main node manager factory variant that returns the reference-server-specific
+ /// in place of the default
+ /// . This keeps CTT-only address-space
+ /// tweaks out of the SDK.
+ ///
+ internal sealed class ReferenceServerMainNodeManagerFactory : IMainNodeManagerFactory
+ {
+ public ReferenceServerMainNodeManagerFactory(
+ ApplicationConfiguration applicationConfiguration,
+ IServerInternal server)
+ {
+ m_applicationConfiguration = applicationConfiguration;
+ m_server = server;
+ }
+
+ ///
+ public IConfigurationNodeManager CreateConfigurationNodeManager()
+ {
+ return new ReferenceServerConfigurationNodeManager(
+ 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/Applications/Quickstarts.Servers/ReferenceServer/RoleManagementHandler.cs b/Applications/Quickstarts.Servers/ReferenceServer/RoleManagementHandler.cs
new file mode 100644
index 0000000000..a0b16d91bb
--- /dev/null
+++ b/Applications/Quickstarts.Servers/ReferenceServer/RoleManagementHandler.cs
@@ -0,0 +1,951 @@
+/* ========================================================================
+ * 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.Linq;
+using System.Threading;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Server;
+
+namespace Quickstarts.ReferenceServer
+{
+ ///
+ /// Identity criteria types as defined in OPC UA Part 3.
+ ///
+ public enum IdentityCriteriaType
+ {
+ /// Match by user name.
+ UserName = 1,
+
+ /// Match by certificate thumbprint.
+ Thumbprint = 2,
+
+ /// Match by role.
+ Role = 3,
+
+ /// Match by group identifier.
+ GroupId = 4,
+
+ /// Match anonymous users.
+ Anonymous = 5,
+
+ /// Match any authenticated user.
+ AuthenticatedUser = 6,
+
+ /// Match by application URI.
+ Application = 7
+ }
+
+ ///
+ /// Holds per-role identity, application, and endpoint configuration.
+ ///
+ public sealed class RoleConfiguration
+ {
+ ///
+ /// Gets the list of identity mappings for this role.
+ /// Each tuple contains (CriteriaType, Criteria).
+ ///
+ public List<(int CriteriaType, string Criteria)> Identities { get; } = new();
+
+ ///
+ /// Gets the list of application URIs associated with this role.
+ ///
+ public List Applications { get; } = new();
+
+ ///
+ /// Gets or sets a value indicating whether the application list
+ /// is an exclusion list.
+ ///
+ public bool ApplicationsExclude { get; set; }
+
+ ///
+ /// Gets the list of endpoint URLs associated with this role.
+ ///
+ public List Endpoints { get; } = new();
+
+ ///
+ /// Gets or sets a value indicating whether the endpoint list
+ /// is an exclusion list.
+ ///
+ public bool EndpointsExclude { get; set; }
+ }
+
+ ///
+ /// Manages OPC UA role management method call handling for
+ /// well-known roles. The node creation is handled by
+ /// ; this class only
+ /// contains the business logic for method calls and property
+ /// updates.
+ ///
+ public sealed class RoleManagementHandler : IDisposable
+ {
+ private readonly IServerInternal _server;
+ private readonly ILogger _logger;
+ private readonly Dictionary _roleConfigs;
+ private Dictionary _methodToRole;
+ private Dictionary> _properties;
+ private readonly SemaphoreSlim _lock;
+ private bool _disposed;
+
+ internal static readonly NodeId[] WellKnownRoles =
+ [
+ ObjectIds.WellKnownRole_Anonymous,
+ ObjectIds.WellKnownRole_AuthenticatedUser,
+ ObjectIds.WellKnownRole_Observer,
+ ObjectIds.WellKnownRole_Operator,
+ ObjectIds.WellKnownRole_Engineer,
+ ObjectIds.WellKnownRole_Supervisor,
+ ObjectIds.WellKnownRole_ConfigureAdmin,
+ ObjectIds.WellKnownRole_SecurityAdmin,
+ ];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The server internal interface.
+ /// The telemetry context for logging.
+ public RoleManagementHandler(
+ IServerInternal server,
+ ITelemetryContext telemetry)
+ {
+ _server = server ?? throw new ArgumentNullException(nameof(server));
+ _logger = telemetry.CreateLogger();
+ _roleConfigs = new Dictionary();
+ _methodToRole = new Dictionary();
+ _properties = new Dictionary>();
+ _lock = new SemaphoreSlim(1, 1);
+ }
+
+ ///
+ /// Gets the role configuration for the specified role node identifier.
+ /// Creates a new configuration if one does not already exist.
+ ///
+ /// The node identifier of the role.
+ /// The role configuration.
+ public RoleConfiguration GetRoleConfiguration(NodeId roleId)
+ {
+ _lock.Wait();
+ try
+ {
+ if (!_roleConfigs.TryGetValue(roleId, out var config))
+ {
+ config = new RoleConfiguration();
+ _roleConfigs[roleId] = config;
+ }
+
+ return config;
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ ///
+ /// Called by after it
+ /// creates the address space nodes. Receives the property and
+ /// method-to-role maps so this handler can update property
+ /// values and resolve method calls.
+ ///
+ ///
+ /// Per-role dictionary of property browse-name to variable state.
+ ///
+ ///
+ /// Map from method NodeId to the owning role NodeId.
+ ///
+ public void Initialize(
+ Dictionary> properties,
+ Dictionary methodToRole)
+ {
+ _properties = properties
+ ?? throw new ArgumentNullException(nameof(properties));
+ _methodToRole = methodToRole
+ ?? throw new ArgumentNullException(nameof(methodToRole));
+
+ InitializeDefaultConfigurations();
+
+ _logger.LogInformation(
+ Utils.TraceMasks.StartStop,
+ "Role management handler initialized with {Count} roles " +
+ "and {MethodCount} method handlers.",
+ _roleConfigs.Count,
+ _methodToRole.Count);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _lock.Dispose();
+ _disposed = true;
+ }
+ }
+
+ ///
+ /// Handles a role management method call. This is the
+ /// callback
+ /// registered by the node manager.
+ ///
+ public ServiceResult OnRoleMethodCalled(
+ ISystemContext context,
+ MethodState method,
+ NodeId objectId,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ NodeId roleId = ResolveRoleId(method.NodeId, objectId);
+ if (roleId.IsNull)
+ {
+ return new ServiceResult(StatusCodes.BadMethodInvalid);
+ }
+
+ var accessResult = RequireSecurityAdmin(context);
+ if (StatusCode.IsBad(accessResult.StatusCode))
+ {
+ return accessResult;
+ }
+
+ string methodName = method.BrowseName.Name;
+
+ return methodName switch
+ {
+ BrowseNames.AddIdentity =>
+ HandleAddIdentity(roleId, inputArguments),
+ BrowseNames.RemoveIdentity =>
+ HandleRemoveIdentity(roleId, inputArguments),
+ BrowseNames.AddApplication =>
+ HandleAddApplication(roleId, inputArguments),
+ BrowseNames.RemoveApplication =>
+ HandleRemoveApplication(roleId, inputArguments),
+ BrowseNames.AddEndpoint =>
+ HandleAddEndpoint(roleId, inputArguments),
+ BrowseNames.RemoveEndpoint =>
+ HandleRemoveEndpoint(roleId, inputArguments),
+ _ => new ServiceResult(StatusCodes.BadMethodInvalid),
+ };
+ }
+
+ private void InitializeDefaultConfigurations()
+ {
+ foreach (var roleId in WellKnownRoles)
+ {
+ _roleConfigs[roleId] = new RoleConfiguration();
+ }
+
+ _roleConfigs[ObjectIds.WellKnownRole_Anonymous].Identities.Add(
+ ((int)IdentityCriteriaType.Anonymous, "Anonymous"));
+
+ _roleConfigs[ObjectIds.WellKnownRole_AuthenticatedUser].Identities.Add(
+ ((int)IdentityCriteriaType.AuthenticatedUser, "AuthenticatedUser"));
+ }
+
+ private NodeId ResolveRoleId(NodeId methodId, NodeId objectId)
+ {
+ if (_methodToRole.TryGetValue(methodId, out var roleId))
+ {
+ return roleId;
+ }
+
+ foreach (var knownRoleId in WellKnownRoles)
+ {
+ if (knownRoleId == objectId)
+ {
+ return objectId;
+ }
+ }
+
+ return NodeId.Null;
+ }
+
+ private static ServiceResult RequireSecurityAdmin(ISystemContext context)
+ {
+ if (context is ISessionSystemContext sessionContext)
+ {
+ var identity = sessionContext.UserIdentity;
+ if (identity?.GrantedRoleIds.Contains(
+ ObjectIds.WellKnownRole_SecurityAdmin) == true)
+ {
+ return ServiceResult.Good;
+ }
+ }
+
+ return new ServiceResult(
+ StatusCodes.BadUserAccessDenied,
+ new LocalizedText(
+ "SecurityAdmin role is required for role management."));
+ }
+
+ #region Identity Methods
+ private ServiceResult HandleAddIdentity(
+ NodeId roleId,
+ ArrayOf inputArguments)
+ {
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(StatusCodes.BadArgumentsMissing);
+ }
+
+ if (!TryExtractIdentityRule(
+ inputArguments[0],
+ out int criteriaType,
+ out string criteria))
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ if (string.IsNullOrEmpty(criteria))
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ _lock.Wait();
+ try
+ {
+ var config = GetRoleConfigurationUnsafe(roleId);
+ if (config.Identities.Any(
+ i => i.CriteriaType == criteriaType &&
+ i.Criteria == criteria))
+ {
+ return ServiceResult.Good;
+ }
+
+ config.Identities.Add((criteriaType, criteria));
+ UpdateIdentitiesProperty(roleId, config);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+
+ _logger.LogInformation(
+ "AddIdentity: type={CriteriaType} criteria={Criteria} " +
+ "role={RoleId}.",
+ criteriaType,
+ criteria,
+ roleId);
+
+ return ServiceResult.Good;
+ }
+
+ private ServiceResult HandleRemoveIdentity(
+ NodeId roleId,
+ ArrayOf inputArguments)
+ {
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(StatusCodes.BadArgumentsMissing);
+ }
+
+ if (!TryExtractIdentityRule(
+ inputArguments[0],
+ out int criteriaType,
+ out string criteria))
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ _lock.Wait();
+ try
+ {
+ var config = GetRoleConfigurationUnsafe(roleId);
+ int removed = config.Identities.RemoveAll(
+ i => i.CriteriaType == criteriaType &&
+ i.Criteria == criteria);
+
+ if (removed == 0)
+ {
+ return new ServiceResult(StatusCodes.BadNoMatch);
+ }
+
+ UpdateIdentitiesProperty(roleId, config);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+
+ _logger.LogInformation(
+ "RemoveIdentity: type={CriteriaType} criteria={Criteria} " +
+ "role={RoleId}.",
+ criteriaType,
+ criteria,
+ roleId);
+
+ return ServiceResult.Good;
+ }
+ #endregion
+
+ #region Application Methods
+ private ServiceResult HandleAddApplication(
+ NodeId roleId,
+ ArrayOf inputArguments)
+ {
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(StatusCodes.BadArgumentsMissing);
+ }
+
+ if (!inputArguments[0].TryGetValue(out string applicationUri) ||
+ string.IsNullOrEmpty(applicationUri))
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ _lock.Wait();
+ try
+ {
+ var config = GetRoleConfigurationUnsafe(roleId);
+ if (!config.Applications.Contains(applicationUri))
+ {
+ config.Applications.Add(applicationUri);
+ UpdateApplicationsProperty(roleId, config);
+ }
+ }
+ finally
+ {
+ _lock.Release();
+ }
+
+ _logger.LogInformation(
+ "AddApplication: uri={ApplicationUri} role={RoleId}.",
+ applicationUri,
+ roleId);
+
+ return ServiceResult.Good;
+ }
+
+ private ServiceResult HandleRemoveApplication(
+ NodeId roleId,
+ ArrayOf inputArguments)
+ {
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(StatusCodes.BadArgumentsMissing);
+ }
+
+ if (!inputArguments[0].TryGetValue(out string applicationUri) ||
+ string.IsNullOrEmpty(applicationUri))
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ _lock.Wait();
+ try
+ {
+ var config = GetRoleConfigurationUnsafe(roleId);
+ if (!config.Applications.Remove(applicationUri))
+ {
+ return new ServiceResult(StatusCodes.BadNoMatch);
+ }
+
+ UpdateApplicationsProperty(roleId, config);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+
+ _logger.LogInformation(
+ "RemoveApplication: uri={ApplicationUri} role={RoleId}.",
+ applicationUri,
+ roleId);
+
+ return ServiceResult.Good;
+ }
+ #endregion
+
+ #region Endpoint Methods
+ private ServiceResult HandleAddEndpoint(
+ NodeId roleId,
+ ArrayOf inputArguments)
+ {
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(StatusCodes.BadArgumentsMissing);
+ }
+
+ string endpointUrl = ExtractEndpointUrl(inputArguments[0]);
+ if (string.IsNullOrEmpty(endpointUrl))
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ _lock.Wait();
+ try
+ {
+ var config = GetRoleConfigurationUnsafe(roleId);
+ if (!config.Endpoints.Contains(endpointUrl))
+ {
+ config.Endpoints.Add(endpointUrl);
+ UpdateEndpointsProperty(roleId, config);
+ }
+ }
+ finally
+ {
+ _lock.Release();
+ }
+
+ _logger.LogInformation(
+ "AddEndpoint: url={EndpointUrl} role={RoleId}.",
+ endpointUrl,
+ roleId);
+
+ return ServiceResult.Good;
+ }
+
+ private ServiceResult HandleRemoveEndpoint(
+ NodeId roleId,
+ ArrayOf inputArguments)
+ {
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(StatusCodes.BadArgumentsMissing);
+ }
+
+ string endpointUrl = ExtractEndpointUrl(inputArguments[0]);
+ if (string.IsNullOrEmpty(endpointUrl))
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ _lock.Wait();
+ try
+ {
+ var config = GetRoleConfigurationUnsafe(roleId);
+ if (!config.Endpoints.Remove(endpointUrl))
+ {
+ return new ServiceResult(StatusCodes.BadNoMatch);
+ }
+
+ UpdateEndpointsProperty(roleId, config);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+
+ _logger.LogInformation(
+ "RemoveEndpoint: url={EndpointUrl} role={RoleId}.",
+ endpointUrl,
+ roleId);
+
+ return ServiceResult.Good;
+ }
+ #endregion
+
+ #region Decoding Helpers
+ private bool TryExtractIdentityRule(
+ Variant variant,
+ out int criteriaType,
+ out string criteria)
+ {
+ criteriaType = 0;
+ criteria = null;
+
+ if (variant.TryGetValue(out ExtensionObject extensionObject))
+ {
+ return TryDecodeFromExtensionObject(
+ extensionObject, out criteriaType, out criteria);
+ }
+
+ return false;
+ }
+
+ private bool TryDecodeFromExtensionObject(
+ ExtensionObject extensionObject,
+ out int criteriaType,
+ out string criteria)
+ {
+ criteriaType = 0;
+ criteria = null;
+
+ if (extensionObject.TryGetValue(out IEncodeable encodeable))
+ {
+ return TryDecodeFromEncodeable(
+ encodeable, out criteriaType, out criteria);
+ }
+
+ if (extensionObject.TryGetAsBinary(out ByteString binary))
+ {
+ return TryDecodeFromBinary(
+ binary.ToArray(), out criteriaType, out criteria);
+ }
+
+ return false;
+ }
+
+ private static bool TryDecodeFromEncodeable(
+ IEncodeable encodeable,
+ out int criteriaType,
+ out string criteria)
+ {
+ criteriaType = 0;
+ criteria = null;
+
+ // Cast to the canonical generated type. AOT/trim-safe — no
+ // reflection, the type is statically referenced via the
+ // sourcegen output. The previous reflection-based fallback
+ // tripped IL2075 on the publish-AOT job.
+ if (encodeable is IdentityMappingRuleType rule)
+ {
+ criteriaType = (int)rule.CriteriaType;
+ criteria = rule.Criteria;
+ return criteria != null;
+ }
+
+ return false;
+ }
+
+ private bool TryDecodeFromBinary(
+ byte[] body,
+ out int criteriaType,
+ out string criteria)
+ {
+ criteriaType = 0;
+ criteria = null;
+
+ try
+ {
+ using var decoder = new BinaryDecoder(
+ body, _server.MessageContext);
+ criteriaType = decoder.ReadInt32("CriteriaType");
+ criteria = decoder.ReadString("Criteria");
+ return true;
+ }
+ catch (Exception ex) when (
+ ex is ServiceResultException or
+ FormatException or
+ InvalidOperationException)
+ {
+ return false;
+ }
+ }
+
+ private static string ExtractEndpointUrl(Variant variant)
+ {
+ if (variant.TryGetValue(out string url))
+ {
+ return url;
+ }
+
+ // Cast to the canonical generated EndpointType. AOT/trim-safe —
+ // no reflection, the type is statically referenced via the
+ // sourcegen output. The previous Object.GetType().GetProperty
+ // path tripped IL2075 on the publish-AOT job.
+ if (variant.TryGetValue(out ExtensionObject ext) &&
+ ext.TryGetValue(out IEncodeable encodeable) &&
+ encodeable is EndpointType endpoint)
+ {
+ return endpoint.EndpointUrl;
+ }
+
+ return null;
+ }
+ #endregion
+
+ #region Property Updates
+ private void UpdateIdentitiesProperty(
+ NodeId roleId,
+ RoleConfiguration config)
+ {
+ if (!_properties.TryGetValue(roleId, out var props) ||
+ !props.TryGetValue("Identities", out var node))
+ {
+ return;
+ }
+
+ var identityStrings = config.Identities
+ .Select(i =>
+ $"{(IdentityCriteriaType)i.CriteriaType}:{i.Criteria}")
+ .ToArray();
+
+ node.Value = new Variant(
+ (ArrayOf)identityStrings);
+ node.ClearChangeMasks(_server.DefaultSystemContext, true);
+ }
+
+ private void UpdateApplicationsProperty(
+ NodeId roleId,
+ RoleConfiguration config)
+ {
+ if (!_properties.TryGetValue(roleId, out var props))
+ {
+ return;
+ }
+
+ if (props.TryGetValue("Applications", out var appNode))
+ {
+ appNode.Value = new Variant(
+ (ArrayOf)config.Applications.ToArray());
+ appNode.ClearChangeMasks(
+ _server.DefaultSystemContext, true);
+ }
+
+ if (props.TryGetValue("ApplicationsExclude", out var exclNode))
+ {
+ exclNode.Value = new Variant(config.ApplicationsExclude);
+ exclNode.ClearChangeMasks(
+ _server.DefaultSystemContext, true);
+ }
+ }
+
+ private void UpdateEndpointsProperty(
+ NodeId roleId,
+ RoleConfiguration config)
+ {
+ if (!_properties.TryGetValue(roleId, out var props))
+ {
+ return;
+ }
+
+ if (props.TryGetValue("Endpoints", out var epNode))
+ {
+ epNode.Value = new Variant(
+ (ArrayOf)config.Endpoints.ToArray());
+ epNode.ClearChangeMasks(
+ _server.DefaultSystemContext, true);
+ }
+
+ if (props.TryGetValue("EndpointsExclude", out var exclNode))
+ {
+ exclNode.Value = new Variant(config.EndpointsExclude);
+ exclNode.ClearChangeMasks(
+ _server.DefaultSystemContext, true);
+ }
+ }
+ #endregion
+
+ ///
+ /// Gets the role configuration without acquiring the lock.
+ /// Caller must hold .
+ ///
+ private RoleConfiguration GetRoleConfigurationUnsafe(NodeId roleId)
+ {
+ if (!_roleConfigs.TryGetValue(roleId, out var config))
+ {
+ config = new RoleConfiguration();
+ _roleConfigs[roleId] = config;
+ }
+
+ return config;
+ }
+ }
+
+ ///
+ /// A that registers role management
+ /// method and property nodes in namespace 0 using external references
+ /// so that the MasterNodeManager correctly routes Browse and Call
+ /// requests.
+ ///
+ public sealed class RoleManagementNodeManager : CustomNodeManager2
+ {
+ private readonly RoleManagementHandler _handler;
+ private readonly Dictionary _methodToRole;
+ private readonly Dictionary> _properties;
+
+ private static readonly (string Name, uint Offset)[] s_methodDefs =
+ [
+ (BrowseNames.AddIdentity, 100u),
+ (BrowseNames.RemoveIdentity, 101u),
+ (BrowseNames.AddApplication, 102u),
+ (BrowseNames.RemoveApplication, 103u),
+ (BrowseNames.AddEndpoint, 104u),
+ (BrowseNames.RemoveEndpoint, 105u),
+ ];
+
+ private static readonly (string Name, uint Offset, NodeId DataType, int ValueRank, Variant Default)[] s_propertyDefs =
+ [
+ ("Identities", 200u, DataTypeIds.Structure, ValueRanks.OneDimension, new Variant(Array.Empty())),
+ ("Applications", 201u, DataTypeIds.String, ValueRanks.OneDimension, new Variant(Array.Empty())),
+ ("ApplicationsExclude", 202u, DataTypeIds.Boolean, ValueRanks.Scalar, new Variant(false)),
+ ("Endpoints", 203u, DataTypeIds.String, ValueRanks.OneDimension, new Variant(Array.Empty())),
+ ("EndpointsExclude", 204u, DataTypeIds.Boolean, ValueRanks.Scalar, new Variant(false)),
+ ];
+
+ ///
+ /// Initializes a new instance of the
+ /// class.
+ ///
+ /// The server internal interface.
+ /// The application configuration.
+ ///
+ /// The handler that owns the business logic for role method calls.
+ ///
+ public RoleManagementNodeManager(
+ IServerInternal server,
+ ApplicationConfiguration configuration,
+ RoleManagementHandler handler)
+ : base(server, configuration,
+ new string[] { Opc.Ua.Namespaces.OpcUa })
+ {
+ _handler = handler
+ ?? throw new ArgumentNullException(nameof(handler));
+ _methodToRole = new Dictionary();
+ _properties = new Dictionary>();
+ }
+
+ ///
+ /// Creates the address space by adding method and property nodes
+ /// for each well-known role and registering external references
+ /// back to the role objects owned by the CoreNodeManager.
+ ///
+ public override void CreateAddressSpace(
+ IDictionary> externalReferences)
+ {
+ base.CreateAddressSpace(externalReferences);
+
+ foreach (var roleId in RoleManagementHandler.WellKnownRoles)
+ {
+ if (!roleId.TryGetValue(out uint baseId))
+ {
+ continue;
+ }
+
+ var roleProps = new Dictionary();
+
+ foreach (var (name, offset) in s_methodDefs)
+ {
+ var methodId = new NodeId(baseId + offset);
+ var method = new MethodState(null)
+ {
+ NodeId = methodId,
+ BrowseName = new QualifiedName(name),
+ DisplayName = LocalizedText.From(name),
+ ReferenceTypeId = ReferenceTypeIds.HasComponent,
+ Executable = true,
+ UserExecutable = true,
+ OnCallMethod2 =
+ new GenericMethodCalledEventHandler2(
+ _handler.OnRoleMethodCalled)
+ };
+
+ CreateInputArguments(method, name, baseId + offset);
+
+ AddExternalReference(
+ roleId,
+ ReferenceTypeIds.HasComponent,
+ false,
+ methodId,
+ externalReferences);
+
+ AddPredefinedNode(SystemContext, method);
+ _methodToRole[methodId] = roleId;
+ }
+
+ foreach (var (name, offset, dataType, valueRank, defaultVal) in s_propertyDefs)
+ {
+ var propId = new NodeId(baseId + offset);
+ var prop = new PropertyState(null)
+ {
+ NodeId = propId,
+ BrowseName = new QualifiedName(name),
+ DisplayName = LocalizedText.From(name),
+ DataType = dataType,
+ ValueRank = valueRank,
+ Value = defaultVal,
+ ReferenceTypeId = ReferenceTypeIds.HasProperty,
+ TypeDefinitionId = VariableTypeIds.PropertyType,
+ };
+
+ AddExternalReference(
+ roleId,
+ ReferenceTypeIds.HasProperty,
+ false,
+ propId,
+ externalReferences);
+
+ AddPredefinedNode(SystemContext, prop);
+ roleProps[name] = prop;
+ }
+
+ _properties[roleId] = roleProps;
+ }
+
+ _handler.Initialize(_properties, _methodToRole);
+ }
+
+ private static void CreateInputArguments(
+ MethodState method,
+ string methodName,
+ uint methodNumericId)
+ {
+ string argName;
+ NodeId argType;
+
+ if (methodName.Contains("Identity"))
+ {
+ argName = "Rule";
+ argType = DataTypeIds.Structure;
+ }
+ else if (methodName.Contains("Application"))
+ {
+ argName = "ApplicationUri";
+ argType = DataTypeIds.String;
+ }
+ else if (methodName.Contains("Endpoint"))
+ {
+ argName = "Endpoint";
+ argType = DataTypeIds.String;
+ }
+ else
+ {
+ return;
+ }
+
+ method.InputArguments =
+ new PropertyState>
+ .Implementation>(method)
+ {
+ NodeId = new NodeId(methodNumericId + 1000u),
+ BrowseName = QualifiedName.From(
+ BrowseNames.InputArguments),
+ DisplayName = LocalizedText.From(
+ BrowseNames.InputArguments),
+ TypeDefinitionId = VariableTypeIds.PropertyType,
+ ReferenceTypeId = ReferenceTypeIds.HasProperty,
+ DataType = DataTypeIds.Argument,
+ ValueRank = ValueRanks.OneDimension,
+ };
+
+ method.InputArguments.Value = new Argument[]
+ {
+ new Argument
+ {
+ Name = argName,
+ Description = LocalizedText.From(argName),
+ DataType = argType,
+ ValueRank = ValueRanks.Scalar
+ }
+ }.ToArrayOf();
+ }
+ }
+}
diff --git a/Applications/Quickstarts.Servers/TestData/HistoryFile.cs b/Applications/Quickstarts.Servers/TestData/HistoryFile.cs
index 83ba7f22fc..0f2ba83d5a 100644
--- a/Applications/Quickstarts.Servers/TestData/HistoryFile.cs
+++ b/Applications/Quickstarts.Servers/TestData/HistoryFile.cs
@@ -27,9 +27,11 @@
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
+using System;
using System.Collections.Generic;
using System.Threading;
using Opc.Ua;
+using Opc.Ua.Server;
namespace TestData
{
@@ -140,6 +142,157 @@ public DataValue NextRaw(
}
}
+ ///
+ /// Inserts a new value into the archive at the value's source timestamp.
+ /// Returns BadEntryExists if a non-modified entry already exists at that
+ /// timestamp.
+ ///
+ public StatusCode InsertRaw(DataValue value)
+ {
+ if (value == null)
+ {
+ return StatusCodes.BadInvalidArgument;
+ }
+
+ lock (m_lock)
+ {
+ int existing = FindBySourceTimestamp(value.SourceTimestamp.ToDateTime());
+ if (existing >= 0)
+ {
+ return StatusCodes.BadEntryExists;
+ }
+ InsertSorted(value);
+ return StatusCodes.GoodEntryInserted;
+ }
+ }
+
+ ///
+ /// Replaces an existing value at the given source timestamp. Returns
+ /// BadNoEntryExists when nothing matches.
+ ///
+ public StatusCode ReplaceRaw(DataValue value)
+ {
+ if (value == null)
+ {
+ return StatusCodes.BadInvalidArgument;
+ }
+
+ lock (m_lock)
+ {
+ int existing = FindBySourceTimestamp(value.SourceTimestamp.ToDateTime());
+ if (existing < 0)
+ {
+ return StatusCodes.BadNoEntryExists;
+ }
+ m_entries[existing] = NewEntry(value, isModified: true);
+ return StatusCodes.GoodEntryReplaced;
+ }
+ }
+
+ ///
+ /// Inserts the value if no entry exists at the timestamp; otherwise
+ /// replaces it. Mirrors PerformUpdateType.Update semantics.
+ ///
+ public StatusCode UpsertRaw(DataValue value)
+ {
+ if (value == null)
+ {
+ return StatusCodes.BadInvalidArgument;
+ }
+
+ lock (m_lock)
+ {
+ int existing = FindBySourceTimestamp(value.SourceTimestamp.ToDateTime());
+ if (existing >= 0)
+ {
+ m_entries[existing] = NewEntry(value, isModified: true);
+ return StatusCodes.GoodEntryReplaced;
+ }
+ InsertSorted(value);
+ return StatusCodes.GoodEntryInserted;
+ }
+ }
+
+ ///
+ /// Deletes raw entries whose source timestamp is in [startTime, endTime).
+ ///
+ public StatusCode DeleteRaw(DateTime startTime, DateTime endTime)
+ {
+ lock (m_lock)
+ {
+ int removed = m_entries.RemoveAll(e =>
+ e.Value.SourceTimestamp.ToDateTime() >= startTime
+ && e.Value.SourceTimestamp.ToDateTime() < endTime);
+ return removed > 0
+ ? StatusCodes.Good
+ : StatusCodes.GoodNoData;
+ }
+ }
+
+ ///
+ /// Deletes a single entry at the specified source timestamp. Returns
+ /// BadNoEntryExists when no entry matches.
+ ///
+ public StatusCode DeleteAtTime(DateTime sourceTimestamp)
+ {
+ lock (m_lock)
+ {
+ int existing = FindBySourceTimestamp(sourceTimestamp);
+ if (existing < 0)
+ {
+ return StatusCodes.BadNoEntryExists;
+ }
+ m_entries.RemoveAt(existing);
+ return StatusCodes.Good;
+ }
+ }
+
+ private int FindBySourceTimestamp(DateTime sourceTimestamp)
+ {
+ for (int i = 0; i < m_entries.Count; i++)
+ {
+ if (m_entries[i].Value.SourceTimestamp.ToDateTime() == sourceTimestamp)
+ {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void InsertSorted(DataValue value)
+ {
+ HistoryEntry entry = NewEntry(value, isModified: false);
+
+ // Maintain sort by source timestamp (ascending).
+ int idx = m_entries.Count;
+ for (int i = 0; i < m_entries.Count; i++)
+ {
+ if (m_entries[i].Value.SourceTimestamp.ToDateTime() > entry.Value.SourceTimestamp.ToDateTime())
+ {
+ idx = i;
+ break;
+ }
+ }
+ m_entries.Insert(idx, entry);
+ }
+
+ private static HistoryEntry NewEntry(DataValue value, bool isModified)
+ {
+ return new HistoryEntry
+ {
+ Value = new DataValue
+ {
+ WrappedValue = value.WrappedValue,
+ SourceTimestamp = value.SourceTimestamp,
+ ServerTimestamp = value.ServerTimestamp != DateTime.MinValue
+ ? value.ServerTimestamp
+ : DateTime.UtcNow,
+ StatusCode = value.StatusCode
+ },
+ IsModified = isModified
+ };
+ }
+
private readonly Lock m_lock = new();
private readonly List m_entries;
}
diff --git a/Applications/Quickstarts.Servers/TestData/TestDataSystem.cs b/Applications/Quickstarts.Servers/TestData/TestDataSystem.cs
index 6830e4c5b1..0859168449 100644
--- a/Applications/Quickstarts.Servers/TestData/TestDataSystem.cs
+++ b/Applications/Quickstarts.Servers/TestData/TestDataSystem.cs
@@ -33,6 +33,7 @@
using System.Threading;
using Microsoft.Extensions.Logging;
using Opc.Ua;
+using Opc.Ua.Server;
namespace TestData
{
diff --git a/Directory.Packages.props b/Directory.Packages.props
index dd734e766f..51cfbac338 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -30,6 +30,7 @@
+
diff --git a/Libraries/Opc.Ua.Lds.Server/LdsServer.cs b/Libraries/Opc.Ua.Lds.Server/LdsServer.cs
new file mode 100644
index 0000000000..d1d2d0c342
--- /dev/null
+++ b/Libraries/Opc.Ua.Lds.Server/LdsServer.cs
@@ -0,0 +1,536 @@
+/* ========================================================================
+ * 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.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Opc.Ua.Bindings;
+using Opc.Ua.Security.Certificates;
+
+namespace Opc.Ua.Lds.Server
+{
+ ///
+ /// OPC UA Local Discovery Server (LDS / LDS-ME) implementation per Part 12.
+ ///
+ ///
+ /// Subclasses so we get the discovery
+ /// service entry points (FindServers, FindServersOnNetwork, GetEndpoints,
+ /// RegisterServer, RegisterServer2) without inheriting Session,
+ /// Subscription, or NodeManager infrastructure from
+ /// SessionServerBase/StandardServer.
+ ///
+ public class LdsServer : DiscoveryServerBase
+ {
+ private readonly ITelemetryContext m_telemetry;
+ private ILogger m_log;
+ private SemaphoreSlim m_lock;
+ private MulticastDiscovery m_multicast;
+
+ ///
+ /// Creates a new LDS server.
+ ///
+ /// Telemetry context for logging.
+ public LdsServer(ITelemetryContext telemetry = null)
+ : base(telemetry)
+ {
+ m_telemetry = telemetry;
+ m_lock = new SemaphoreSlim(1, 1);
+ Store = new RegisteredServerStore();
+ }
+
+ ///
+ /// In-memory database of registered servers and network records.
+ /// Exposed for tests so they can deterministically seed state.
+ ///
+ public RegisteredServerStore Store { get; }
+
+ ///
+ /// Optional multicast discovery layer (LDS-ME). Null when multicast
+ /// is disabled.
+ ///
+ public MulticastDiscovery Multicast => m_multicast;
+
+ ///
+ /// Optional hook tests use to plug in a multicast layer prior to
+ /// .
+ ///
+ public Func MulticastFactory { get; set; }
+
+ ///
+ protected override void OnServerStarting(ApplicationConfiguration configuration)
+ {
+ base.OnServerStarting(configuration);
+
+ m_log = m_telemetry?.CreateLogger()
+ ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
+
+ // mark capabilities so this server self-advertises as an LDS / LDS-ME.
+ if (ServerCapabilities.IsNull || ServerCapabilities.Count == 0)
+ {
+ ServerCapabilities = new[] { "LDS" };
+ }
+
+ // wire up the optional multicast layer.
+ if (MulticastFactory != null)
+ {
+ m_multicast = MulticastFactory(this);
+ }
+ }
+
+ ///
+ protected override async ValueTask StartApplicationAsync(
+ ApplicationConfiguration configuration,
+ CancellationToken cancellationToken = default)
+ {
+ await base.StartApplicationAsync(configuration, cancellationToken).ConfigureAwait(false);
+
+ if (m_multicast != null)
+ {
+ IList capabilities = ServerCapabilities.IsNull
+ ? new List()
+ : ServerCapabilities.ToList();
+ IList baseUris = BaseAddresses
+ .Select(b => b.Url?.ToString())
+ .Where(u => !string.IsNullOrEmpty(u))
+ .ToList();
+ await m_multicast
+ .StartAsync(configuration.ApplicationUri, baseUris, capabilities, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ ///
+ protected override async ValueTask OnServerStoppingAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (m_multicast != null)
+ {
+ await m_multicast.StopAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ Store.Dispose();
+ await base.OnServerStoppingAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ public override async ValueTask FindServersAsync(
+ SecureChannelContext secureChannelContext,
+ RequestHeader requestHeader,
+ string endpointUrl,
+ ArrayOf localeIds,
+ ArrayOf serverUris,
+ RequestLifetime requestLifetime)
+ {
+ ValidateRequest(requestHeader);
+
+ var servers = new List();
+
+ await m_lock.WaitAsync(requestLifetime.CancellationToken).ConfigureAwait(false);
+ try
+ {
+ IList baseAddresses = BaseAddresses;
+
+ Uri parsedEndpointUrl = Utils.ParseUri(endpointUrl);
+ if (parsedEndpointUrl != null)
+ {
+ baseAddresses = FilterByEndpointUrl(parsedEndpointUrl, baseAddresses);
+ }
+
+ ICollection uriFilter = serverUris.IsNull
+ ? Array.Empty()
+ : (ICollection)serverUris.ToList();
+
+ // include the LDS itself unless filtered out.
+ if (baseAddresses.Count > 0
+ && (uriFilter.Count == 0 || uriFilter.Contains(ServerDescription.ApplicationUri)))
+ {
+ servers.Add(TranslateApplicationDescription(
+ parsedEndpointUrl,
+ ServerDescription,
+ baseAddresses,
+ ServerDescription.ApplicationName));
+ }
+
+ ICollection requestedLocales = localeIds.IsNull
+ ? Array.Empty()
+ : (ICollection)localeIds.ToList();
+
+ // append registered servers that pass the filter.
+ foreach (ApplicationDescription registered in Store.Find(uriFilter, requestedLocales))
+ {
+ servers.Add(registered);
+ }
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+
+ return new FindServersResponse
+ {
+ ResponseHeader = CreateResponse(requestHeader, StatusCodes.Good),
+ Servers = servers
+ };
+ }
+
+ ///
+ public override async ValueTask GetEndpointsAsync(
+ SecureChannelContext secureChannelContext,
+ RequestHeader requestHeader,
+ string endpointUrl,
+ ArrayOf localeIds,
+ ArrayOf profileUris,
+ RequestLifetime requestLifetime)
+ {
+ ValidateRequest(requestHeader);
+
+ ArrayOf endpoints;
+
+ await m_lock.WaitAsync(requestLifetime.CancellationToken).ConfigureAwait(false);
+ try
+ {
+ IList baseAddresses = FilterByProfile(profileUris, BaseAddresses);
+ endpoints = BuildEndpointDescriptions(endpointUrl, baseAddresses);
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+
+ return new GetEndpointsResponse
+ {
+ ResponseHeader = CreateResponse(requestHeader, StatusCodes.Good),
+ Endpoints = endpoints
+ };
+ }
+
+ ///
+ public override async ValueTask RegisterServerAsync(
+ SecureChannelContext secureChannelContext,
+ RequestHeader requestHeader,
+ RegisteredServer server,
+ RequestLifetime requestLifetime)
+ {
+ ValidateRequest(requestHeader);
+
+ ServiceResult validation = ValidateRegistration(secureChannelContext, server);
+ if (ServiceResult.IsBad(validation))
+ {
+ throw new ServiceResultException(validation);
+ }
+
+ _ = await Store
+ .RegisterAsync(server, mdnsConfig: null, requestLifetime.CancellationToken)
+ .ConfigureAwait(false);
+
+ return new RegisterServerResponse
+ {
+ ResponseHeader = CreateResponse(requestHeader, StatusCodes.Good)
+ };
+ }
+
+ ///
+ public override async ValueTask RegisterServer2Async(
+ SecureChannelContext secureChannelContext,
+ RequestHeader requestHeader,
+ RegisteredServer server,
+ ArrayOf discoveryConfiguration,
+ RequestLifetime requestLifetime)
+ {
+ ValidateRequest(requestHeader);
+
+ ServiceResult validation = ValidateRegistration(secureChannelContext, server);
+ if (ServiceResult.IsBad(validation))
+ {
+ throw new ServiceResultException(validation);
+ }
+
+ var configResults = new List();
+
+ // gather mdns configurations; non-mdns configs are returned as Good but ignored.
+ var mdnsConfigs = new List();
+ if (!discoveryConfiguration.IsNull)
+ {
+ foreach (ExtensionObject ext in discoveryConfiguration)
+ {
+ if (!ext.IsNull && ext.TryGetValue(out MdnsDiscoveryConfiguration mdns))
+ {
+ mdnsConfigs.Add(mdns);
+ }
+ configResults.Add(StatusCodes.Good);
+ }
+ }
+
+ if (mdnsConfigs.Count == 0)
+ {
+ // no MdnsDiscoveryConfiguration provided — fall back to a plain registration.
+ _ = await Store
+ .RegisterAsync(server, mdnsConfig: null, requestLifetime.CancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ foreach (MdnsDiscoveryConfiguration mdns in mdnsConfigs)
+ {
+ _ = await Store
+ .RegisterAsync(server, mdns, requestLifetime.CancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ return new RegisterServer2Response
+ {
+ ResponseHeader = CreateResponse(requestHeader, StatusCodes.Good),
+ ConfigurationResults = configResults
+ };
+ }
+
+ ///
+ public override async ValueTask FindServersOnNetworkAsync(
+ SecureChannelContext secureChannelContext,
+ RequestHeader requestHeader,
+ uint startingRecordId,
+ uint maxRecordsToReturn,
+ ArrayOf serverCapabilityFilter,
+ RequestLifetime requestLifetime)
+ {
+ ValidateRequest(requestHeader);
+
+ await Task.Yield();
+
+ ICollection capFilter = serverCapabilityFilter.IsNull
+ ? Array.Empty()
+ : (ICollection)serverCapabilityFilter.ToList();
+
+ (IList records, DateTime lastReset) =
+ Store.ListOnNetwork(startingRecordId, maxRecordsToReturn, capFilter);
+
+ return new FindServersOnNetworkResponse
+ {
+ ResponseHeader = CreateResponse(requestHeader, StatusCodes.Good),
+ LastCounterResetTime = lastReset,
+ Servers = records.ToArray()
+ };
+ }
+
+ ///
+ /// Validates a against Part 12 §6.4.2/§6.4.5
+ /// requirements, returning the appropriate status code on failure.
+ ///
+ protected virtual ServiceResult ValidateRegistration(
+ SecureChannelContext secureChannelContext,
+ RegisteredServer server)
+ {
+ if (server == null)
+ {
+ throw new ArgumentNullException(nameof(server));
+ }
+
+ // Per Part 12 §6.4.2: registration must occur on a secure channel
+ // (Sign or SignAndEncrypt). None is rejected.
+ MessageSecurityMode mode = secureChannelContext?.EndpointDescription?.SecurityMode
+ ?? MessageSecurityMode.Invalid;
+ if (mode == MessageSecurityMode.None || mode == MessageSecurityMode.Invalid)
+ {
+ return new ServiceResult(StatusCodes.BadSecurityChecksFailed,
+ new LocalizedText("RegisterServer requires a signed secure channel."));
+ }
+
+ if (string.IsNullOrEmpty(server.ServerUri))
+ {
+ return new ServiceResult(StatusCodes.BadServerUriInvalid,
+ new LocalizedText("ServerUri is empty."));
+ }
+
+ if (server.ServerNames.IsNull || server.ServerNames.Count == 0)
+ {
+ return new ServiceResult(StatusCodes.BadServerNameMissing,
+ new LocalizedText("ServerNames is empty."));
+ }
+
+ if (server.DiscoveryUrls.IsNull || server.DiscoveryUrls.Count == 0)
+ {
+ return new ServiceResult(StatusCodes.BadDiscoveryUrlMissing,
+ new LocalizedText("DiscoveryUrls is empty."));
+ }
+
+ if (server.ServerType == ApplicationType.Client)
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument,
+ new LocalizedText("ServerType=Client is not allowed."));
+ }
+
+ if ((int)server.ServerType is < 0 or > (int)ApplicationType.DiscoveryServer)
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument,
+ new LocalizedText("ServerType is out of range."));
+ }
+
+ // Match the client cert ApplicationUri against the ServerUri.
+ if (secureChannelContext?.ClientChannelCertificate is { Length: > 0 } certBytes)
+ {
+ try
+ {
+ using Certificate cert = Certificate.FromRawData(certBytes);
+ IReadOnlyList applicationUris = X509Utils.GetApplicationUrisFromCertificate(cert);
+ if (applicationUris.Count > 0
+ && !applicationUris.Any(uri => string.Equals(uri, server.ServerUri, StringComparison.Ordinal)))
+ {
+ return new ServiceResult(StatusCodes.BadServerUriInvalid,
+ new LocalizedText("ServerUri does not match the certificate ApplicationUri."));
+ }
+ }
+ catch (Exception ex)
+ {
+ m_log?.LogDebug(ex, "Failed to inspect client cert ApplicationUri.");
+ }
+ }
+
+ return ServiceResult.Good;
+ }
+
+ private ArrayOf BuildEndpointDescriptions(
+ string endpointUrl,
+ IList baseAddresses)
+ {
+ Uri parsedEndpointUrl = Utils.ParseUri(endpointUrl);
+ if (parsedEndpointUrl != null)
+ {
+ baseAddresses = FilterByEndpointUrl(parsedEndpointUrl, baseAddresses);
+ }
+
+ if (baseAddresses.Count == 0)
+ {
+ return new ArrayOf(Array.Empty());
+ }
+
+ ApplicationDescription application = TranslateApplicationDescription(
+ parsedEndpointUrl,
+ ServerDescription,
+ baseAddresses,
+ ServerDescription.ApplicationName);
+
+ return TranslateEndpointDescriptions(
+ parsedEndpointUrl,
+ baseAddresses,
+ Endpoints,
+ application);
+ }
+
+ ///
+ protected override IList InitializeServiceHosts(
+ ApplicationConfiguration configuration,
+ ITransportListenerBindings bindingFactory,
+ out ApplicationDescription serverDescription,
+ out ArrayOf endpoints)
+ {
+ var hosts = new Dictionary();
+
+ // Mirror StandardServer's defaults so the LDS opens a real listener.
+ if (configuration.ServerConfiguration.SecurityPolicies.IsEmpty)
+ {
+ configuration.ServerConfiguration.SecurityPolicies =
+ configuration.ServerConfiguration.SecurityPolicies.AddItem(new ServerSecurityPolicy());
+ }
+
+ if (configuration.ServerConfiguration.UserTokenPolicies.IsEmpty)
+ {
+ var userTokenPolicy = new UserTokenPolicy { TokenType = UserTokenType.Anonymous };
+ userTokenPolicy.PolicyId = userTokenPolicy.TokenType.ToString();
+
+ configuration.ServerConfiguration.UserTokenPolicies += userTokenPolicy;
+ }
+
+ serverDescription = new ApplicationDescription
+ {
+ ApplicationUri = configuration.ApplicationUri,
+ ApplicationName = new LocalizedText("en-US", configuration.ApplicationName),
+ ApplicationType = configuration.ApplicationType,
+ ProductUri = configuration.ProductUri,
+ DiscoveryUrls = GetDiscoveryUrls()
+ };
+
+ var endpointsList = new List();
+ ArrayOf baseAddresses = configuration.ServerConfiguration.BaseAddresses;
+ foreach (string scheme in Utils.DefaultUriSchemes)
+ {
+ bool hasAddress = false;
+ foreach (string a in baseAddresses)
+ {
+ if (a.StartsWith(scheme, StringComparison.Ordinal))
+ {
+ hasAddress = true;
+ break;
+ }
+ }
+ if (!hasAddress)
+ {
+ continue;
+ }
+
+ ITransportListenerFactory binding = bindingFactory.GetBinding(scheme, MessageContext.Telemetry);
+ if (binding != null)
+ {
+ List endpointsForHost = binding.CreateServiceHost(
+ this,
+ hosts,
+ configuration,
+ configuration.ServerConfiguration.BaseAddresses,
+ serverDescription,
+ configuration.ServerConfiguration.SecurityPolicies,
+ CertificateManager,
+ configuration.CertificateManager);
+ endpointsList.AddRange(endpointsForHost);
+ }
+ }
+
+ endpoints = endpointsList.ToArray();
+ return hosts.Values.ToList();
+ }
+
+ ///
+ public override ServiceHost CreateServiceHost(ServerBase server, params Uri[] addresses)
+ {
+ return new ServiceHost(this, addresses);
+ }
+
+ ///
+ protected override EndpointBase GetEndpointInstance(ServerBase server)
+ {
+ return new DiscoveryEndpoint(server);
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.Lds.Server/MulticastDiscovery.cs b/Libraries/Opc.Ua.Lds.Server/MulticastDiscovery.cs
new file mode 100644
index 0000000000..baf068d9b4
--- /dev/null
+++ b/Libraries/Opc.Ua.Lds.Server/MulticastDiscovery.cs
@@ -0,0 +1,372 @@
+/* ========================================================================
+ * 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.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using Makaretu.Dns;
+using Microsoft.Extensions.Logging;
+
+namespace Opc.Ua.Lds.Server
+{
+ ///
+ /// Multicast DNS wrapper that announces this LDS as an
+ /// _opcua-tcp._tcp service per OPC UA Part 12 §6.4.6 and observes
+ /// peer announcements, surfacing them to the
+ /// .
+ ///
+ public sealed class MulticastDiscovery : IDisposable
+ {
+ ///
+ /// Standard mDNS service type for OPC UA discovery per Part 12.
+ ///
+ public const string OpcUaServiceType = "_opcua-tcp._tcp";
+
+ private readonly RegisteredServerStore m_store;
+ private readonly ILogger m_logger;
+ private readonly bool m_loopbackOnly;
+ private MulticastService m_service;
+ private ServiceDiscovery m_discovery;
+ private readonly List m_profiles = [];
+ private bool m_started;
+ private bool m_disposed;
+
+ ///
+ /// Creates a new multicast wrapper.
+ ///
+ /// Store to publish observed peers into.
+ /// When true, restricts announcements and
+ /// queries to the loopback NIC. Used by in-process tests.
+ /// Optional logger.
+ public MulticastDiscovery(
+ RegisteredServerStore store,
+ bool loopbackOnly = false,
+ ILogger logger = null)
+ {
+ if (store == null)
+ {
+ throw new ArgumentNullException(nameof(store));
+ }
+ m_store = store;
+ m_loopbackOnly = loopbackOnly;
+ m_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
+ }
+
+ ///
+ /// True after has run.
+ ///
+ public bool IsRunning => m_started;
+
+ ///
+ /// Starts mDNS announcement and discovery.
+ ///
+ /// The LDS's ApplicationUri (used as the
+ /// default mDNS instance name).
+ /// The LDS's discovery URLs.
+ /// The LDS's server capabilities (e.g. LDS, LDS-ME).
+ /// Cancellation token.
+ public Task StartAsync(
+ string applicationUri,
+ IList discoveryUrls,
+ IList capabilities,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrEmpty(applicationUri))
+ {
+ throw new ArgumentException("Application URI must be provided.", nameof(applicationUri));
+ }
+ if (discoveryUrls == null)
+ {
+ throw new ArgumentNullException(nameof(discoveryUrls));
+ }
+ if (capabilities == null)
+ {
+ throw new ArgumentNullException(nameof(capabilities));
+ }
+
+ if (m_started)
+ {
+ return Task.CompletedTask;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ Func, IEnumerable> filter = m_loopbackOnly
+ ? nics => nics.Where(nic => nic.NetworkInterfaceType == NetworkInterfaceType.Loopback)
+ : null;
+
+ m_service = new MulticastService(filter);
+
+ m_discovery = new ServiceDiscovery(m_service);
+ m_discovery.ServiceInstanceDiscovered += OnServiceInstanceDiscovered;
+
+ foreach (string url in discoveryUrls)
+ {
+ ServiceProfile profile = TryBuildProfile(applicationUri, url, capabilities);
+ if (profile != null)
+ {
+ m_profiles.Add(profile);
+ m_discovery.Advertise(profile);
+ }
+ }
+
+ m_service.Start();
+ m_started = true;
+
+ // proactively probe for other LDS / OPC UA servers on the network.
+ try
+ {
+ m_discovery.QueryServiceInstances(OpcUaServiceType);
+ }
+ catch (Exception ex)
+ {
+ m_logger.LogDebug(ex, "Multicast initial query failed.");
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Stops mDNS announcement (sends goodbye packets where supported).
+ ///
+ public Task StopAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!m_started)
+ {
+ return Task.CompletedTask;
+ }
+
+ try
+ {
+ foreach (ServiceProfile profile in m_profiles)
+ {
+ try
+ {
+ m_discovery.Unadvertise(profile);
+ }
+ catch (Exception ex)
+ {
+ m_logger.LogDebug(ex, "Multicast unadvertise failed.");
+ }
+ }
+ m_profiles.Clear();
+ }
+ finally
+ {
+ m_discovery?.Dispose();
+ m_discovery = null;
+
+ m_service?.Stop();
+ m_service?.Dispose();
+ m_service = null;
+ m_started = false;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (m_disposed)
+ {
+ return;
+ }
+ m_disposed = true;
+ try
+ {
+ StopAsync().GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ m_logger.LogDebug(ex, "Multicast dispose failed.");
+ }
+ }
+
+ private void OnServiceInstanceDiscovered(object sender, ServiceInstanceDiscoveryEventArgs e)
+ {
+ try
+ {
+ Message msg = e.Message;
+ if (msg == null)
+ {
+ return;
+ }
+
+ // Extract SRV (host+port), A/AAAA (address), TXT (path/caps).
+ SRVRecord srv = msg.AdditionalRecords.OfType().FirstOrDefault()
+ ?? msg.Answers.OfType().FirstOrDefault();
+ if (srv == null)
+ {
+ return;
+ }
+
+ string instanceName = e.ServiceInstanceName?.ToString() ?? srv.Name?.ToString();
+ if (string.IsNullOrEmpty(instanceName))
+ {
+ return;
+ }
+
+ IPAddress address = msg.AdditionalRecords.OfType().FirstOrDefault()?.Address
+ ?? msg.Answers.OfType().FirstOrDefault()?.Address
+ ?? msg.AdditionalRecords.OfType().FirstOrDefault()?.Address
+ ?? IPAddress.Loopback;
+
+ TXTRecord txt = msg.AdditionalRecords.OfType().FirstOrDefault()
+ ?? msg.Answers.OfType().FirstOrDefault();
+
+ string path = "/";
+ List caps = [];
+ if (txt != null)
+ {
+ foreach (string str in txt.Strings)
+ {
+ if (str.StartsWith("path=", StringComparison.Ordinal))
+ {
+ path = str.Substring("path=".Length);
+ }
+ else if (str.StartsWith("caps=", StringComparison.Ordinal))
+ {
+ foreach (string raw in str.Substring("caps=".Length)
+ .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ string trimmed = raw.Trim();
+ if (trimmed.Length > 0)
+ {
+ caps.Add(trimmed);
+ }
+ }
+ }
+ }
+ }
+
+ string discoveryUrl = $"opc.tcp://{address}:{srv.Port}{path}";
+
+ m_store.UpsertMulticastRecord(
+ serverUri: null,
+ serverName: instanceName,
+ discoveryUrl: discoveryUrl,
+ capabilities: caps);
+ }
+ catch (Exception ex)
+ {
+ m_logger.LogDebug(ex, "Failed to process mDNS service instance.");
+ }
+ }
+
+ private ServiceProfile TryBuildProfile(
+ string applicationUri,
+ string discoveryUrl,
+ IList capabilities)
+ {
+ try
+ {
+ Uri parsed = new(discoveryUrl, UriKind.Absolute);
+ if (parsed.Port <= 0)
+ {
+ return null;
+ }
+
+ IEnumerable addrs = ResolveLocalAddresses();
+ string instanceName = SanitizeInstanceName(applicationUri);
+
+ var profile = new ServiceProfile(
+ instanceName,
+ OpcUaServiceType,
+ (ushort)parsed.Port,
+ addrs);
+
+ string path = string.IsNullOrEmpty(parsed.AbsolutePath) ? "/" : parsed.AbsolutePath;
+ profile.AddProperty("path", path);
+ if (capabilities.Count > 0)
+ {
+ profile.AddProperty("caps", string.Join(",", capabilities));
+ }
+
+ return profile;
+ }
+ catch (Exception ex)
+ {
+ m_logger.LogDebug(ex, "Failed to build mDNS profile for {Url}.", discoveryUrl);
+ return null;
+ }
+ }
+
+ private IEnumerable ResolveLocalAddresses()
+ {
+ if (m_loopbackOnly)
+ {
+ yield return IPAddress.Loopback;
+ yield break;
+ }
+
+ foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ if (nic.OperationalStatus != OperationalStatus.Up)
+ {
+ continue;
+ }
+
+ IPInterfaceProperties ipProps = nic.GetIPProperties();
+ foreach (UnicastIPAddressInformation u in ipProps.UnicastAddresses)
+ {
+ if (u.Address.AddressFamily == AddressFamily.InterNetwork
+ || u.Address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ yield return u.Address;
+ }
+ }
+ }
+ }
+
+ private static string SanitizeInstanceName(string applicationUri)
+ {
+ if (string.IsNullOrEmpty(applicationUri))
+ {
+ return "OpcUaLds";
+ }
+
+ // mDNS instance names should be friendly UTF-8 strings; strip URI scheme.
+ int schemeIdx = applicationUri.IndexOf("://", StringComparison.Ordinal);
+ string trimmed = schemeIdx >= 0 ? applicationUri.Substring(schemeIdx + 3) : applicationUri;
+ // limit length and replace separators that confuse some browsers.
+ string sanitized = trimmed
+ .Replace('/', '-')
+ .Replace(':', '-')
+ .Replace('?', '-');
+ return sanitized.Length > 63 ? sanitized.Substring(0, 63) : sanitized;
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.Lds.Server/Opc.Ua.Lds.Server.csproj b/Libraries/Opc.Ua.Lds.Server/Opc.Ua.Lds.Server.csproj
new file mode 100644
index 0000000000..2398a569e2
--- /dev/null
+++ b/Libraries/Opc.Ua.Lds.Server/Opc.Ua.Lds.Server.csproj
@@ -0,0 +1,28 @@
+
+
+ $(LibTargetFrameworks)
+ $(PackagePrefix).Opc.Ua.Lds.Server
+ $(AssemblyPrefix).Lds.Server
+ Opc.Ua.Lds.Server
+ OPC UA Local Discovery Server (LDS / LDS-ME) Class Library
+ PackageReference
+ true
+ true
+ true
+
+
+
+
+
+
+ $(PackageId).Debug
+
+
+
+
+
+
+
+
+
+
diff --git a/Libraries/Opc.Ua.Lds.Server/RegisteredServerStore.cs b/Libraries/Opc.Ua.Lds.Server/RegisteredServerStore.cs
new file mode 100644
index 0000000000..032b2aeaea
--- /dev/null
+++ b/Libraries/Opc.Ua.Lds.Server/RegisteredServerStore.cs
@@ -0,0 +1,549 @@
+/* ========================================================================
+ * 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 System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Opc.Ua.Lds.Server
+{
+ ///
+ /// Thread-safe in-memory database of servers registered with the LDS plus
+ /// network records advertised via LDS-ME.
+ ///
+ ///
+ /// Per OPC UA Part 12 §6.4.5.1, registered servers should re-register
+ /// periodically; entries are pruned when they have not been refreshed
+ /// within . mDNS-observed records
+ /// follow their own TTL via .
+ ///
+ public sealed class RegisteredServerStore : IDisposable
+ {
+ private readonly SemaphoreSlim m_lock = new(1, 1);
+ private readonly Dictionary m_byUri
+ = new(StringComparer.Ordinal);
+ private readonly List m_records = [];
+ private readonly ILogger m_logger;
+ private uint m_nextRecordId = 1;
+ private DateTime m_lastCounterResetTime;
+ private Timer m_pruneTimer;
+ private bool m_disposed;
+
+ ///
+ /// Creates a new in-memory store.
+ ///
+ public RegisteredServerStore(ILogger logger = null)
+ {
+ m_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
+ m_lastCounterResetTime = DateTime.UtcNow;
+ }
+
+ ///
+ /// Lifetime for explicit RegisterServer registrations. After this period
+ /// without a refresh, the registration is considered stale and removed.
+ /// Default: 10 minutes (informational guidance from Part 12; servers in
+ /// practice re-register every 30 seconds).
+ ///
+ public TimeSpan RegistrationLifetime { get; set; } = TimeSpan.FromMinutes(10);
+
+ ///
+ /// TTL for mDNS-observed network records. Defaults to 75 seconds, which
+ /// matches the standard mDNS service-record TTL.
+ ///
+ public TimeSpan MulticastRecordLifetime { get; set; } = TimeSpan.FromSeconds(75);
+
+ ///
+ /// Most recent time the network record id counter was reset. Exposed via
+ /// queries for client cursor logic.
+ ///
+ public DateTime LastCounterResetTime
+ {
+ get
+ {
+ m_lock.Wait();
+ try
+ {
+ return m_lastCounterResetTime;
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+ }
+
+ ///
+ /// Test/diagnostic helper: snapshot of all current registrations.
+ ///
+ public IReadOnlyList Snapshot()
+ {
+ m_lock.Wait();
+ try
+ {
+ return m_byUri.Values.Select(Clone).ToList();
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+
+ ///
+ /// Test/diagnostic helper: snapshot of all current network records.
+ ///
+ public IReadOnlyList SnapshotNetworkRecords()
+ {
+ m_lock.Wait();
+ try
+ {
+ return m_records.ConvertAll(Clone);
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+
+ ///
+ /// Starts the background pruning timer. Tests typically skip this and
+ /// drive prune deterministically via .
+ ///
+ public void StartPruneTimer(TimeSpan? interval = null)
+ {
+ TimeSpan tick = interval ?? TimeSpan.FromSeconds(30);
+ m_pruneTimer?.Dispose();
+ m_pruneTimer = new Timer(_ =>
+ {
+ try
+ {
+ Prune(DateTime.UtcNow);
+ }
+ catch (Exception ex)
+ {
+ m_logger.LogWarning(ex, "RegisteredServerStore prune failed.");
+ }
+ }, null, tick, tick);
+ }
+
+ ///
+ /// Adds, updates, or removes a registration based on
+ /// 's state. Returns the live registration
+ /// (post-merge) or null if it was removed.
+ ///
+ public async Task RegisterAsync(
+ RegisteredServer server,
+ MdnsDiscoveryConfiguration mdnsConfig,
+ CancellationToken cancellationToken = default)
+ {
+ if (server == null)
+ {
+ throw new ArgumentNullException(nameof(server));
+ }
+
+ await m_lock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ string uri = server.ServerUri;
+
+ // semaphore file: if path is set but file is missing, drop the registration
+ bool semaphoreValid = string.IsNullOrEmpty(server.SemaphoreFilePath)
+ || File.Exists(server.SemaphoreFilePath);
+
+ if (!server.IsOnline || !semaphoreValid)
+ {
+ if (m_byUri.Remove(uri))
+ {
+ m_logger.LogInformation(
+ "LDS: removed registration for {Uri} (IsOnline={Online}, SemaphoreOk={Sem}).",
+ uri,
+ server.IsOnline,
+ semaphoreValid);
+ }
+ RemoveNetworkRecordsForUriCore(uri);
+ return null;
+ }
+
+ if (!m_byUri.TryGetValue(uri, out RegistrationEntry entry))
+ {
+ entry = new RegistrationEntry { ServerUri = uri };
+ m_byUri[uri] = entry;
+ }
+
+ entry.ProductUri = server.ProductUri;
+ entry.ServerNames = server.ServerNames.IsNull
+ ? new List()
+ : server.ServerNames.ToList();
+ entry.ServerType = server.ServerType;
+ entry.GatewayServerUri = server.GatewayServerUri;
+ entry.DiscoveryUrls = server.DiscoveryUrls.IsNull
+ ? new List()
+ : server.DiscoveryUrls.ToList();
+ entry.SemaphoreFilePath = server.SemaphoreFilePath;
+ entry.IsOnline = true;
+ entry.LastSeenUtc = DateTime.UtcNow;
+
+ if (mdnsConfig != null)
+ {
+ entry.MdnsServerName = mdnsConfig.MdnsServerName;
+ entry.ServerCapabilities = mdnsConfig.ServerCapabilities.IsNull
+ ? new List()
+ : mdnsConfig.ServerCapabilities.ToList();
+
+ UpdateNetworkRecordsCore(entry);
+ }
+ else if (entry.MdnsServerName != null && entry.ServerCapabilities.Count > 0)
+ {
+ // refresh existing mDNS records' LastSeen
+ UpdateNetworkRecordsCore(entry);
+ }
+
+ return Clone(entry);
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+
+ ///
+ /// Returns translated entries for
+ /// every active registration whose ServerUri passes .
+ ///
+ public IList Find(
+ ICollection serverUriFilter,
+ ICollection requestedLocaleIds)
+ {
+ m_lock.Wait();
+ try
+ {
+ var result = new List(m_byUri.Count);
+ foreach (RegistrationEntry e in m_byUri.Values)
+ {
+ if (serverUriFilter is { Count: > 0 }
+ && !serverUriFilter.Contains(e.ServerUri))
+ {
+ continue;
+ }
+
+ LocalizedText name = SelectName(e.ServerNames, requestedLocaleIds);
+
+ result.Add(new ApplicationDescription
+ {
+ ApplicationUri = e.ServerUri,
+ ProductUri = e.ProductUri,
+ ApplicationName = name,
+ ApplicationType = e.ServerType,
+ GatewayServerUri = e.GatewayServerUri,
+ DiscoveryUrls = [.. e.DiscoveryUrls]
+ });
+ }
+ return result;
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+
+ ///
+ /// Returns paginated records honoring
+ /// , ,
+ /// and .
+ ///
+ public (IList records, DateTime lastCounterResetTime) ListOnNetwork(
+ uint startingRecordId,
+ uint maxRecordsToReturn,
+ ICollection serverCapabilityFilter)
+ {
+ m_lock.Wait();
+ try
+ {
+ IEnumerable source = m_records
+ .Where(r => r.RecordId >= startingRecordId)
+ .OrderBy(r => r.RecordId);
+
+ if (serverCapabilityFilter is { Count: > 0 })
+ {
+ source = source.Where(r =>
+ serverCapabilityFilter.All(cap => r.ServerCapabilities.Contains(cap)));
+ }
+
+ if (maxRecordsToReturn > 0)
+ {
+ source = source.Take((int)maxRecordsToReturn);
+ }
+
+ IList dto = source
+ .Select(r => new ServerOnNetwork
+ {
+ RecordId = r.RecordId,
+ ServerName = r.ServerName,
+ DiscoveryUrl = r.DiscoveryUrl,
+ ServerCapabilities = [.. r.ServerCapabilities]
+ })
+ .ToList();
+
+ return (dto, m_lastCounterResetTime);
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+
+ ///
+ /// Adds or refreshes mDNS-observed peer records. Called by
+ /// when service discoveries occur.
+ ///
+ public void UpsertMulticastRecord(
+ string serverUri,
+ string serverName,
+ string discoveryUrl,
+ IEnumerable capabilities)
+ {
+ if (string.IsNullOrEmpty(discoveryUrl))
+ {
+ throw new ArgumentException("DiscoveryUrl must be provided.", nameof(discoveryUrl));
+ }
+
+ m_lock.Wait();
+ try
+ {
+ ServerOnNetworkRecord existing = m_records.FirstOrDefault(r =>
+ r.ObservedViaMulticast
+ && string.Equals(r.DiscoveryUrl, discoveryUrl, StringComparison.Ordinal));
+
+ if (existing != null)
+ {
+ existing.LastSeenUtc = DateTime.UtcNow;
+ existing.ServerName = serverName;
+ existing.ServerCapabilities = capabilities?.ToList() ?? [];
+ return;
+ }
+
+ m_records.Add(new ServerOnNetworkRecord
+ {
+ RecordId = m_nextRecordId++,
+ ServerUri = serverUri,
+ ServerName = serverName,
+ DiscoveryUrl = discoveryUrl,
+ ServerCapabilities = capabilities?.ToList() ?? [],
+ LastSeenUtc = DateTime.UtcNow,
+ ObservedViaMulticast = true
+ });
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+
+ ///
+ /// Removes stale registrations and stale mDNS-observed records.
+ ///
+ public void Prune(DateTime nowUtc)
+ {
+ m_lock.Wait();
+ try
+ {
+ List staleUris = m_byUri.Values
+ .Where(e =>
+ nowUtc - e.LastSeenUtc > RegistrationLifetime
+ || (!string.IsNullOrEmpty(e.SemaphoreFilePath) && !File.Exists(e.SemaphoreFilePath)))
+ .Select(e => e.ServerUri)
+ .ToList();
+
+ foreach (string uri in staleUris)
+ {
+ m_byUri.Remove(uri);
+ RemoveNetworkRecordsForUriCore(uri);
+ }
+
+ m_records.RemoveAll(r =>
+ r.ObservedViaMulticast
+ && nowUtc - r.LastSeenUtc > MulticastRecordLifetime);
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+
+ ///
+ /// Test seam: directly insert a registration without protocol validation.
+ /// Used by the LdsTestFixture to deterministically populate state.
+ ///
+ internal void SeedRegistration(RegistrationEntry entry)
+ {
+ if (entry == null)
+ {
+ throw new ArgumentNullException(nameof(entry));
+ }
+ if (string.IsNullOrEmpty(entry.ServerUri))
+ {
+ throw new ArgumentException("ServerUri must be set.", nameof(entry));
+ }
+
+ m_lock.Wait();
+ try
+ {
+ m_byUri[entry.ServerUri] = entry;
+ if (!string.IsNullOrEmpty(entry.MdnsServerName)
+ && entry.ServerCapabilities is { Count: > 0 })
+ {
+ UpdateNetworkRecordsCore(entry);
+ }
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+
+ ///
+ /// Test seam: drop all state and reset counters.
+ ///
+ internal void Clear()
+ {
+ m_lock.Wait();
+ try
+ {
+ m_byUri.Clear();
+ m_records.Clear();
+ m_nextRecordId = 1;
+ m_lastCounterResetTime = DateTime.UtcNow;
+ }
+ finally
+ {
+ m_lock.Release();
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (m_disposed)
+ {
+ return;
+ }
+ m_disposed = true;
+ m_pruneTimer?.Dispose();
+ m_lock.Dispose();
+ }
+
+ private void UpdateNetworkRecordsCore(RegistrationEntry entry)
+ {
+ // Replace any prior records for this ServerUri originating from
+ // explicit registration (preserve mDNS-observed records).
+ m_records.RemoveAll(r =>
+ !r.ObservedViaMulticast
+ && string.Equals(r.ServerUri, entry.ServerUri, StringComparison.Ordinal));
+
+ foreach (string url in entry.DiscoveryUrls)
+ {
+ LocalizedText firstName = entry.ServerNames.Count > 0
+ ? entry.ServerNames[0]
+ : LocalizedText.Null;
+
+ m_records.Add(new ServerOnNetworkRecord
+ {
+ RecordId = m_nextRecordId++,
+ ServerUri = entry.ServerUri,
+ ServerName = entry.MdnsServerName ?? firstName.Text,
+ DiscoveryUrl = url,
+ ServerCapabilities = [.. entry.ServerCapabilities],
+ LastSeenUtc = DateTime.UtcNow,
+ ObservedViaMulticast = false
+ });
+ }
+ }
+
+ private void RemoveNetworkRecordsForUriCore(string serverUri)
+ {
+ m_records.RemoveAll(r =>
+ !r.ObservedViaMulticast
+ && string.Equals(r.ServerUri, serverUri, StringComparison.Ordinal));
+ }
+
+ private static LocalizedText SelectName(
+ IList names,
+ ICollection requestedLocaleIds)
+ {
+ if (names == null || names.Count == 0)
+ {
+ return new LocalizedText(string.Empty);
+ }
+
+ if (requestedLocaleIds is { Count: > 0 })
+ {
+ foreach (string locale in requestedLocaleIds)
+ {
+ LocalizedText match = names.FirstOrDefault(n =>
+ string.Equals(n.Locale, locale, StringComparison.OrdinalIgnoreCase));
+ if (!match.IsNullOrEmpty)
+ {
+ return match;
+ }
+ }
+ }
+
+ return names[0];
+ }
+
+ private static RegistrationEntry Clone(RegistrationEntry e) => new()
+ {
+ ServerUri = e.ServerUri,
+ ProductUri = e.ProductUri,
+ ServerNames = [.. e.ServerNames],
+ ServerType = e.ServerType,
+ GatewayServerUri = e.GatewayServerUri,
+ DiscoveryUrls = [.. e.DiscoveryUrls],
+ SemaphoreFilePath = e.SemaphoreFilePath,
+ IsOnline = e.IsOnline,
+ LastSeenUtc = e.LastSeenUtc,
+ ServerCapabilities = [.. e.ServerCapabilities],
+ MdnsServerName = e.MdnsServerName
+ };
+
+ private static ServerOnNetworkRecord Clone(ServerOnNetworkRecord r) => new()
+ {
+ RecordId = r.RecordId,
+ ServerUri = r.ServerUri,
+ ServerName = r.ServerName,
+ DiscoveryUrl = r.DiscoveryUrl,
+ ServerCapabilities = [.. r.ServerCapabilities],
+ LastSeenUtc = r.LastSeenUtc,
+ ObservedViaMulticast = r.ObservedViaMulticast
+ };
+ }
+}
diff --git a/Libraries/Opc.Ua.Lds.Server/RegistrationEntry.cs b/Libraries/Opc.Ua.Lds.Server/RegistrationEntry.cs
new file mode 100644
index 0000000000..33c18bc622
--- /dev/null
+++ b/Libraries/Opc.Ua.Lds.Server/RegistrationEntry.cs
@@ -0,0 +1,105 @@
+/* ========================================================================
+ * 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;
+
+namespace Opc.Ua.Lds.Server
+{
+ ///
+ /// In-memory record of a server registered with the LDS via
+ /// or
+ /// .
+ ///
+ ///
+ /// Registrations are keyed by . Setting
+ /// to false in a subsequent register call removes the entry from the store.
+ ///
+ public sealed class RegistrationEntry
+ {
+ ///
+ /// The server's ApplicationUri. Acts as the unique key for the registration.
+ ///
+ public string ServerUri { get; set; }
+
+ ///
+ /// The server's product URI.
+ ///
+ public string ProductUri { get; set; }
+
+ ///
+ /// The server's localized application names.
+ ///
+ public IList ServerNames { get; set; } = new List();
+
+ ///
+ /// The server's . LDS rejects
+ /// registrations.
+ ///
+ public ApplicationType ServerType { get; set; } = ApplicationType.Server;
+
+ ///
+ /// Optional gateway server URI for non-OPC UA server registrations.
+ ///
+ public string GatewayServerUri { get; set; }
+
+ ///
+ /// One or more discovery endpoint URLs.
+ ///
+ public IList DiscoveryUrls { get; set; } = new List();
+
+ ///
+ /// Optional file path used to keep the registration alive while the file exists.
+ ///
+ public string SemaphoreFilePath { get; set; }
+
+ ///
+ /// Whether the server is currently online and accepting connections.
+ ///
+ public bool IsOnline { get; set; }
+
+ ///
+ /// Wall-clock timestamp of the most recent register call.
+ ///
+ public DateTime LastSeenUtc { get; set; }
+
+ ///
+ /// The capabilities advertised by the registered server, derived from the most
+ /// recent MdnsDiscoveryConfiguration if any. Empty for plain
+ /// RegisterServer calls.
+ ///
+ public IList ServerCapabilities { get; set; } = new List();
+
+ ///
+ /// The mDNS server name advertised by the most recent
+ /// MdnsDiscoveryConfiguration. Null for plain RegisterServer calls.
+ ///
+ public string MdnsServerName { get; set; }
+ }
+}
diff --git a/Libraries/Opc.Ua.Lds.Server/ServerOnNetworkRecord.cs b/Libraries/Opc.Ua.Lds.Server/ServerOnNetworkRecord.cs
new file mode 100644
index 0000000000..547cf9f7b1
--- /dev/null
+++ b/Libraries/Opc.Ua.Lds.Server/ServerOnNetworkRecord.cs
@@ -0,0 +1,81 @@
+/* ========================================================================
+ * 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;
+
+namespace Opc.Ua.Lds.Server
+{
+ ///
+ /// Network-level record exposed to clients via
+ /// . Each
+ /// attached to a register call
+ /// produces one record; observed mDNS peers also surface as records.
+ ///
+ public sealed class ServerOnNetworkRecord
+ {
+ ///
+ /// The monotonic record id assigned by the store.
+ ///
+ public uint RecordId { get; internal set; }
+
+ ///
+ /// The server's ApplicationUri. Used to correlate the record back
+ /// to a when one exists.
+ ///
+ public string ServerUri { get; set; }
+
+ ///
+ /// The mDNS server name (display string) for this record.
+ ///
+ public string ServerName { get; set; }
+
+ ///
+ /// One discovery URL the client can use to reach the server.
+ ///
+ public string DiscoveryUrl { get; set; }
+
+ ///
+ /// The capabilities advertised by the server.
+ ///
+ public IList ServerCapabilities { get; set; } = new List();
+
+ ///
+ /// Wall-clock timestamp of the most recent observation. Used for TTL-based
+ /// pruning of mDNS-observed records.
+ ///
+ public DateTime LastSeenUtc { get; internal set; }
+
+ ///
+ /// True if the record originated from an mDNS network observation as
+ /// opposed to an explicit RegisterServer2 call.
+ ///
+ public bool ObservedViaMulticast { get; set; }
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs b/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs
index b03c0c9091..aa4b461952 100644
--- a/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs
+++ b/Libraries/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs
@@ -140,25 +140,49 @@ public override async ValueTask CreateAddressSpaceAsync(
{
await base.CreateAddressSpaceAsync(externalReferences, cancellationToken).ConfigureAwait(false);
- // sampling interval diagnostics not supported by the server.
+ // SamplingIntervalDiagnosticsArray is part of the
+ // standard nodeset; rather than deleting the node (which makes
+ // reads return BadNodeIdUnknown), leave it in place with a
+ // default empty value. Per Part 5 §6.4.7 the array is optional
+ // and an empty array is a valid representation of "no per-
+ // sampling-interval diagnostics tracked".
ServerDiagnosticsState serverDiagnosticsNode = FindPredefinedNode(
ObjectIds.Server_ServerDiagnostics);
- if (serverDiagnosticsNode != null)
- {
- NodeState samplingDiagnosticsArrayNode = serverDiagnosticsNode.FindChild(
- SystemContext,
- QualifiedName.From(BrowseNames.SamplingIntervalDiagnosticsArray));
-
- if (samplingDiagnosticsArrayNode != null)
- {
- await DeleteNodeAsync(
- SystemContext,
- VariableIds.Server_ServerDiagnostics_SamplingIntervalDiagnosticsArray,
- cancellationToken).ConfigureAwait(false);
- serverDiagnosticsNode.SamplingIntervalDiagnosticsArray = null;
- }
- }
+ if (serverDiagnosticsNode != null
+ && serverDiagnosticsNode.SamplingIntervalDiagnosticsArray != null)
+ {
+ serverDiagnosticsNode.SamplingIntervalDiagnosticsArray.Value =
+ System.Array.Empty().ToArrayOf();
+ serverDiagnosticsNode.SamplingIntervalDiagnosticsArray.StatusCode =
+ StatusCodes.Good;
+ serverDiagnosticsNode.SamplingIntervalDiagnosticsArray.Timestamp =
+ DateTime.UtcNow;
+ serverDiagnosticsNode.SamplingIntervalDiagnosticsArray.MinimumSamplingInterval = 1000;
+ }
+
+ // Issue #3720: the standard NodeSet shipped at Stack/Opc.Ua.Core/
+ // Schema/Opc.Ua.NodeSet2.xml omits the GeneratesEvent reference on
+ // StateMachineType (i=2299) and FiniteStateMachineType (i=2771)
+ // even though Part 5 §6.4.2 requires instances to surface the
+ // events emitted on state changes (TransitionEventType i=2311).
+ // Inject the missing forward reference at load time so subtype
+ // instances inherit it via the type chain.
+ //
+ // Idempotent: NodeState.AddReference dedupes on (refType, isInverse,
+ // targetId) so re-running this on a hot-reload is a no-op.
+ BaseObjectTypeState stateMachineType = FindPredefinedNode(
+ ObjectTypeIds.StateMachineType);
+ stateMachineType?.AddReference(
+ ReferenceTypeIds.GeneratesEvent,
+ isInverse: false,
+ ObjectTypeIds.TransitionEventType);
+ BaseObjectTypeState finiteStateMachineType = FindPredefinedNode(
+ ObjectTypeIds.FiniteStateMachineType);
+ finiteStateMachineType?.AddReference(
+ ReferenceTypeIds.GeneratesEvent,
+ isInverse: false,
+ ObjectTypeIds.TransitionEventType);
// The nodes are now loaded by the DiagnosticsNodeManager from the file
// output by the ModelDesigner V2. These nodes are added to the CoreNodeManager
diff --git a/Applications/Quickstarts.Servers/TestData/IHistoryDataSource.cs b/Libraries/Opc.Ua.Server/HistoricalAccess/IHistoryDataSource.cs
similarity index 59%
rename from Applications/Quickstarts.Servers/TestData/IHistoryDataSource.cs
rename to Libraries/Opc.Ua.Server/HistoricalAccess/IHistoryDataSource.cs
index 6ba3960396..a8758b24d0 100644
--- a/Applications/Quickstarts.Servers/TestData/IHistoryDataSource.cs
+++ b/Libraries/Opc.Ua.Server/HistoricalAccess/IHistoryDataSource.cs
@@ -27,12 +27,20 @@
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
-using Opc.Ua;
+using System;
-namespace TestData
+namespace Opc.Ua.Server
{
///
- /// An interface to an object which can access historical data for a variable.
+ /// Extensibility surface for OPC UA Part 11 historical data access. A
+ /// node manager that wants to expose history for a variable returns an
+ /// from its history-read / history-update
+ /// callbacks; the framework then drives reads and updates through this
+ /// interface. The default in-process implementation backing the
+ /// Quickstarts.Servers reference server is HistoryFile in
+ /// the TestData namespace; integrators can plug their own historian
+ /// (database, time-series store, on-disk archive, …) by implementing this
+ /// interface and returning an instance from their node manager.
///
public interface IHistoryDataSource
{
@@ -42,7 +50,7 @@ public interface IHistoryDataSource
/// The starting time for the search.
/// Whether to search forward in time.
/// Whether to return modified data.
- /// A index that must be passed to the NextRaw call.
+ /// A index that must be passed to the NextRaw call.
/// The DataValue.
DataValue FirstRaw(
DateTimeUtc startTime,
@@ -59,5 +67,33 @@ DataValue FirstRaw(
/// A index previously returned by the reader.
/// The DataValue.
DataValue NextRaw(DateTimeUtc lastTime, bool isForward, bool isReadModified, ref int position);
+
+ ///
+ /// Inserts a new value at the value's source timestamp; returns
+ /// BadEntryExists if a non-modified entry already occupies that slot.
+ ///
+ StatusCode InsertRaw(DataValue value);
+
+ ///
+ /// Replaces an existing value at the source timestamp; returns
+ /// BadNoEntryExists when nothing matches.
+ ///
+ StatusCode ReplaceRaw(DataValue value);
+
+ ///
+ /// Inserts the value when no entry exists at the timestamp;
+ /// otherwise replaces it. Mirrors PerformUpdateType.Update.
+ ///
+ StatusCode UpsertRaw(DataValue value);
+
+ ///
+ /// Deletes raw entries whose source timestamp is in [startTime, endTime).
+ ///
+ StatusCode DeleteRaw(DateTime startTime, DateTime endTime);
+
+ ///
+ /// Deletes a single entry at the specified source timestamp.
+ ///
+ StatusCode DeleteAtTime(DateTime sourceTimestamp);
}
}
diff --git a/Libraries/Opc.Ua.Server/RoleBasedUserManagement/IRoleManager.cs b/Libraries/Opc.Ua.Server/RoleBasedUserManagement/IRoleManager.cs
new file mode 100644
index 0000000000..5b59e6c45a
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/RoleBasedUserManagement/IRoleManager.cs
@@ -0,0 +1,151 @@
+/* ========================================================================
+ * 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 Opc.Ua.Security.Certificates;
+
+namespace Opc.Ua.Server
+{
+ ///
+ /// Extensibility surface for OPC UA Part 18 role / identity-mapping
+ /// management. The default in-process implementation lives in
+ /// ; integrators that want to back roles with a
+ /// custom store (e.g. an LDAP directory or a database) implement this
+ /// interface and inject an instance via
+ /// .
+ ///
+ ///
+ /// The interface is intentionally narrow — it covers the operations the
+ /// RoleStateBinding and the RoleSet service handlers need
+ /// to read/mutate per-role state and resolve granted roles for a session.
+ /// Implementations are expected to be thread-safe; callers may invoke
+ /// these members from multiple session threads concurrently.
+ ///
+ public interface IRoleManager
+ {
+ ///
+ /// Ensures a exists for .
+ /// Idempotent.
+ ///
+ RoleEntry EnsureRole(NodeId roleId);
+
+ ///
+ /// Registered role NodeIds.
+ ///
+ IReadOnlyList RoleIds { get; }
+
+ ///
+ /// Adds an identity-mapping rule to the role. Idempotent — duplicate
+ /// rules are silently dropped.
+ ///
+ ServiceResult AddIdentity(NodeId roleId, IdentityMappingRuleType rule);
+
+ ///
+ /// Removes a previously added identity-mapping rule. Returns
+ /// BadNotFound if the rule isn't present.
+ ///
+ ServiceResult RemoveIdentity(NodeId roleId, IdentityMappingRuleType rule);
+
+ ///
+ /// Adds an application URI to the role's Applications list.
+ ///
+ ServiceResult AddApplication(NodeId roleId, string applicationUri);
+
+ ///
+ /// Removes an application URI; returns BadNotFound if absent.
+ ///
+ ServiceResult RemoveApplication(NodeId roleId, string applicationUri);
+
+ ///
+ /// Adds an endpoint description to the role's Endpoints list.
+ ///
+ ServiceResult AddEndpoint(NodeId roleId, EndpointType endpoint);
+
+ ///
+ /// Removes a previously added endpoint; returns BadNotFound if absent.
+ ///
+ ServiceResult RemoveEndpoint(NodeId roleId, EndpointType endpoint);
+
+ ///
+ /// Read-only snapshot of the role's identities. Used by Variable read
+ /// handlers.
+ ///
+ IList SnapshotIdentities(NodeId roleId);
+
+ ///
+ /// Read-only snapshot of the role's application URIs.
+ ///
+ IList SnapshotApplications(NodeId roleId, out bool exclude);
+
+ ///
+ /// Read-only snapshot of the role's endpoints.
+ ///
+ IList SnapshotEndpoints(NodeId roleId, out bool exclude);
+
+ ///
+ /// Sets the ApplicationsExclude flag (true = role is granted to apps
+ /// NOT in the list).
+ ///
+ void SetApplicationsExclude(NodeId roleId, bool exclude);
+
+ ///
+ /// Sets the EndpointsExclude flag (true = role is granted on endpoints
+ /// NOT in the list).
+ ///
+ void SetEndpointsExclude(NodeId roleId, bool exclude);
+
+ ///
+ /// Computes the set of additional roles to grant a session given its
+ /// identity, client cert, and endpoint per Part 18 §4.4.4.
+ ///
+ IList ResolveGrantedRoles(
+ IUserIdentity identity,
+ Certificate clientCertificate,
+ EndpointDescription endpoint);
+
+ ///
+ /// Namespace index used to issue NodeIds for dynamically created
+ /// roles. Initialized by from the
+ /// diagnostics node manager's namespace.
+ ///
+ ushort DynamicRoleNamespaceIndex { get; set; }
+
+ ///
+ /// Adds a new dynamically-created role per the
+ /// RoleSetType.AddRole method.
+ ///
+ ServiceResult AddRole(string roleName, string namespaceUri, out NodeId newRoleId);
+
+ ///
+ /// Removes a dynamically-created role per the
+ /// RoleSetType.RemoveRole method.
+ ///
+ ServiceResult RemoveRole(NodeId roleId);
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/RoleBasedUserManagement/RoleManager.cs b/Libraries/Opc.Ua.Server/RoleBasedUserManagement/RoleManager.cs
new file mode 100644
index 0000000000..8773ea0ed9
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/RoleBasedUserManagement/RoleManager.cs
@@ -0,0 +1,696 @@
+/* ========================================================================
+ * 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.Linq;
+using System.Threading;
+using Opc.Ua.Security.Certificates;
+
+namespace Opc.Ua.Server
+{
+ ///
+ /// Per-role mutable state plus identity-mapping algorithm per OPC UA Part 18 §4.4
+ /// (RoleType) and §6.4 (RoleSetType). Each well-known role in the address space
+ /// gets a backing here; the RoleStateBinding (in this
+ /// namespace) surfaces method calls and variable reads to/from this manager.
+ ///
+ ///
+ /// All state is in-memory; rules added at runtime do not persist across server
+ /// restart. This is spec-allowed (Part 18 §6.4: "the management of these Roles
+ /// is server-specific"). Integrators that need persistence should implement
+ /// directly and inject the instance via
+ /// .
+ ///
+ public sealed class RoleManager : IRoleManager
+ {
+ private readonly ReaderWriterLockSlim m_lock = new(LockRecursionPolicy.NoRecursion);
+ private readonly Dictionary m_roles
+ = new(EqualityComparer.Default);
+ private readonly HashSet m_dynamicRoles
+ = new(EqualityComparer.Default);
+ private uint m_nextDynamicRoleId = 1;
+
+ ///