Bound reconnect cleanup and organize samples by domain (#4276)

# Description

This PR fixes bounded ManagedSession reconnect cleanup and reorganizes
the sample applications into domain-focused folders.

## Reconnect cleanup

- Bounds reconnect integration cleanup with independent 30-second close
and dispose limits.
- Sends `CloseSession` through the awaited transport request path during
`Session.DisposeAsync`.
- Preserves explicit `Session.CloseAsync` behavior that waits for or
cancels outstanding publish requests.
- Covers the disposal request path with a regression test.

## Sample organization

- `samples/OpenUsd`: `GeneratorServer`, `SiteCompositionServer`
- `samples/Redundancy`: `RedundantClient`, `RedundantServer`,
`RedundantPubSub`
- `samples/Isa95`: `MinimalIsa95Server`
- `samples/MinimalApi`: `MinimalBoilerServer`, `MinimalCalcServer`,
`MinimalClient`
- `samples/DI`: `PumpDeviceIntegrationServer`
- `samples/Lds`: `ConsoleLdsServer`
- `samples/Reference`: `ConsoleReferenceClient`,
`ConsoleReferenceServer`
- `samples/PubSub`: `ConsoleReferencePubSubClient`

`UA.slnx` now exposes the same requested domain groups. Project
references, tests, runtime sample probes, CI workflows, Dockerfiles,
Compose files, helper scripts, and documentation were updated to the new
paths. `samples/Robotics/MinimalRobotServer` remains under `Robotics`.

## Validation

- `dotnet build UA.slnx`
- 3 Redundancy short-haul sample tests
- 70 DI sample tests
- 914 OpenUSD tests
- 1 WotCon model-materialization test
- 137 AOT tests
- `docker compose config` for all 6 moved Redundancy Compose files

## Related Issues

- Fixes #4273

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce new build or
analyzer warnings.
- [x] I built the complete `UA.slnx` solution locally.
- [ ] I ran all tests locally against at least .NET Framework and .NET
10.
- [ ] I fixed all failing and flaky tests in the CI pipelines and all
CodeQL warnings.
- [x] I have addressed all PR feedback received.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: Marc Schier <mschier@microsoft.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Copilot-Session: a2c2838f-74ae-4997-a1d4-50574d9e0636
This commit is contained in:
Copilot
2026-08-21 04:35:47 +00:00
committed by GitHub
parent b7331a11f2
commit d525bc3f28
713 changed files with 944 additions and 374 deletions
@@ -0,0 +1,207 @@
/* ========================================================================
* 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.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.Alarms;
using Opc.Ua.Client.Subscriptions.Streaming;
using Quickstarts.ConsoleReferenceClient;
namespace Quickstarts
{
/// <summary>
/// Sample code demonstrating Part 9 Alarms and Conditions client
/// API: <see cref="AlarmClient"/> for typed alarm operations and
/// the streaming subscription API for alarm event monitoring.
/// </summary>
public class AlarmClientSample
{
private readonly ILogger m_logger;
private readonly ITelemetryContext m_telemetry;
/// <summary>
/// Constructs a new sample.
/// </summary>
public AlarmClientSample(ILogger logger, ITelemetryContext telemetry)
{
m_logger = logger ?? throw new ArgumentNullException(nameof(logger));
m_telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry));
}
/// <summary>
/// Subscribes to alarm events from the Server notifier and prints
/// a typed summary for each received alarm record.
/// </summary>
/// <param name="session">An active managed session.</param>
/// <param name="durationMs">How long to listen for alarms.</param>
/// <param name="ct">Cancellation token.</param>
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <c>null</c>.</exception>
public async Task SubscribeToAlarmsAsync(
ManagedSession session,
int durationMs,
CancellationToken ct = default)
{
if (session == null)
{
throw new ArgumentNullException(nameof(session));
}
Console.WriteLine("Subscribing to alarms via streaming subscription...");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(durationMs);
IStreamingSubscription streaming = session.DefaultStreaming;
try
{
await foreach (ConditionTypeRecord record in streaming
.SubscribeAlarmsAsync(ObjectIds.Server, ct: timeout.Token)
.ConfigureAwait(false))
{
PrintAlarm(record);
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Alarm subscription window elapsed.");
}
}
/// <summary>
/// Demonstrates acknowledging an alarm by EventId after waiting
/// for an active record using the TakeUntilAsync helper.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <c>null</c>.</exception>
public async Task WaitForAndAcknowledgeAsync(
ManagedSession session,
NodeId conditionId,
CancellationToken ct = default)
{
if (session == null)
{
throw new ArgumentNullException(nameof(session));
}
AlarmClient alarms = session.GetAlarmClient(m_telemetry);
IStreamingSubscription streaming = session.DefaultStreaming;
Console.WriteLine($"Waiting for next active record from {conditionId}...");
await foreach (ConditionTypeRecord record in streaming
.SubscribeAlarmsAsync(ObjectIds.Server, ct: ct)
.TakeUntilAsync(r =>
r is AlarmConditionTypeRecord ar &&
ar.ConditionId == conditionId &&
ar.ActiveStateId == true, ct)
.ConfigureAwait(false))
{
if (record is AlarmConditionTypeRecord alarm &&
alarm.ConditionId == conditionId &&
alarm.ActiveStateId == true)
{
Console.WriteLine($"Acknowledging {alarm.ConditionName} EventId {alarm.EventId}");
try
{
await alarms.AcknowledgeAsync(
alarm.ConditionId,
alarm.EventId,
new LocalizedText("en", "Acknowledged by sample"),
ct).ConfigureAwait(false);
Console.WriteLine("Acknowledge succeeded.");
}
catch (Exception ex)
{
m_logger.AcknowledgeFailed(ex);
}
}
}
}
/// <summary>
/// Demonstrates calling shelving methods on an alarm.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <c>null</c>.</exception>
public async Task ShelveAlarmAsync(
ManagedSession session,
NodeId conditionId,
double shelvingTimeMs,
CancellationToken ct = default)
{
if (session == null)
{
throw new ArgumentNullException(nameof(session));
}
AlarmClient alarms = session.GetAlarmClient(m_telemetry);
Console.WriteLine($"Shelving {conditionId} for {shelvingTimeMs}ms...");
await alarms.TimedShelveAsync(conditionId, shelvingTimeMs, ct).ConfigureAwait(false);
Console.WriteLine("Alarm shelved.");
}
private static void PrintAlarm(ConditionTypeRecord record)
{
switch (record)
{
case AlarmConditionTypeRecord alarm:
Console.WriteLine(
$"[ALARM] {alarm.SourceName} sev={alarm.Severity} " +
$"active={alarm.ActiveStateId} acked={alarm.AckedStateId} " +
$"suppressed={alarm.SuppressedStateId} latched={alarm.LatchedStateId}");
break;
case DialogConditionTypeRecord dialog:
Console.WriteLine(
$"[DIALOG] {dialog.SourceName} prompt='{dialog.Prompt}' " +
$"options={dialog.ResponseOptionSet?.Length ?? 0}");
break;
case AcknowledgeableConditionTypeRecord ack:
Console.WriteLine(
$"[CONDITION] {ack.SourceName} sev={ack.Severity} acked={ack.AckedStateId}");
break;
default:
Console.WriteLine(
$"[CONDITION] {record.SourceName} sev={record.Severity}");
break;
}
}
}
internal static partial class AlarmClientSampleLog
{
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.AlarmClientSample + 0, Level = LogLevel.Error,
Message = "Acknowledge failed")]
public static partial void AcknowledgeFailed(this ILogger logger, Exception ex);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,614 @@
/* ========================================================================
* 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;
#if NET8_0_OR_GREATER
using System.Linq;
#endif
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Opc.Ua.Identity;
using Opc.Ua.Security.Certificates;
using Quickstarts.ConsoleReferenceClient;
namespace Quickstarts
{
/// <summary>
/// Wraps connect testing functionality
/// </summary>
public sealed class ConnectTester : IAsyncDisposable
{
public ConnectTester(
ITelemetryContext telemetry,
ManualResetEvent? quitEvent = null)
{
m_quitEvent = quitEvent;
m_telemetry = telemetry;
m_logger = telemetry.CreateLogger<ConnectTester>();
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
m_reconnectHandler?.Dispose();
ISession? session = m_wrapper?.Session;
if (session != null)
{
await session.DisposeAsync().ConfigureAwait(false);
}
GC.SuppressFinalize(this);
}
/// <summary>
/// Run the tests until cancelled or quit
/// </summary>
/// <param name="ct"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task RunAsync(CancellationToken ct)
{
try
{
m_reconnectHandler = new SessionReconnectHandler(
m_telemetry,
true,
kReconnectPeriodExponentialBackoff);
m_logger.OpcUaSecurityTestClient();
// The application name and config file names
const string applicationName = "ConsoleReferenceClient";
const string configSectionName = "Quickstarts.ReferenceClient";
// Define the UA Client application
var passwordProvider = new CertificatePasswordProvider([]);
var application = new ApplicationInstance(m_telemetry)
{
ApplicationName = applicationName,
ApplicationType = ApplicationType.Client,
ConfigSectionName = configSectionName,
CertificatePasswordProvider = passwordProvider
};
await using (application.ConfigureAwait(false))
{
// load the application configuration.
m_configuration = await application
.LoadApplicationConfigurationAsync(silent: false, ct: ct)
.ConfigureAwait(false);
m_configuration.CertificateManager.AcceptError = AcceptCertificate;
// check the application certificate.
bool haveAppCertificate = await application
.CheckApplicationInstanceCertificatesAsync(false, ct: ct)
.ConfigureAwait(false);
if (!haveAppCertificate)
{
throw new InvalidOperationException("Application instance certificate invalid!");
}
}
m_logger.ConnectingTo(kServerUrl);
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync(
m_configuration,
kServerUrl,
ct).ConfigureAwait(false);
var endpointConfiguration = EndpointConfiguration.Create(m_configuration);
var sessionFactory = new DefaultSessionFactory(m_telemetry);
IClientIdentityProvider userNameProvider = await CreateUserNameProviderAsync(ct)
.ConfigureAwait(false);
foreach (EndpointDescription ii in endpoints.ToArray()!)
{
string securityPolicyUri = ii.SecurityPolicyUri!;
string userCertificateFile = GetUserCertificateFile(securityPolicyUri);
X509Certificate2 x509 = X509CertificateLoader.LoadPkcs12FromFile(
Path.Combine("..\\..\\pki\\trustedUser\\private",
userCertificateFile),
"password");
string thumbprint = x509.Thumbprint!;
IClientIdentityProvider certificateProvider = await LoadUserCertificateProviderAsync(
thumbprint,
"password",
ct).ConfigureAwait(false);
var identityProviders = new List<IClientIdentityProvider>
{
new AnonymousIdentityProvider()
};
if (!string.IsNullOrEmpty(kUserName))
{
identityProviders.Add(userNameProvider);
}
if (kSupportsX509)
{
identityProviders.Add(certificateProvider);
}
foreach (IClientIdentityProvider identityProvider in identityProviders)
{
IUserIdentity identity = await CreateUserIdentityAsync(
identityProvider,
ii,
ct).ConfigureAwait(false);
try
{
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SeparatorLine(new string('=', 80));
}
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SecurityPolicy(
SecurityPolicies.Default.GetDisplayName(securityPolicyUri),
ii.SecurityMode);
}
m_logger.Identity(
identity.DisplayName,
identity.TokenType);
SessionWrapper wrapper = m_wrapper = await RunTestAsync(
endpointConfiguration,
sessionFactory,
ii,
identity,
ct).ConfigureAwait(false);
m_logger.WaitingForSecureChannelRenew();
await wrapper.Session.UpdateSessionAsync(identity, default, ct).ConfigureAwait(false);
for (int count = 0; count < 1; count++)
{
ReadResponse result = await wrapper.Session.ReadAsync(
null,
0,
TimestampsToReturn.Neither,
new List<ReadValueId>
{
new() {
NodeId = VariableIds.Server_ServerStatus_CurrentTime,
AttributeId = Attributes.Value
}
},
ct).ConfigureAwait(false);
m_logger.CurrentTime(result.Results[0].WrappedValue.GetDateTime());
await Task.Delay(5000, ct).ConfigureAwait(false);
}
await wrapper.Session.UpdateSessionAsync(identity, default, ct).ConfigureAwait(false);
await wrapper.Session.CloseAsync(true, ct: ct).ConfigureAwait(false);
}
catch (Exception e)
{
Console.WriteLine("Exception: {0}", e.Message);
Console.WriteLine("StackTrace: {0}", e.StackTrace);
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SecurityPolicy(
SecurityPolicies.Default.GetDisplayName(securityPolicyUri),
ii.SecurityMode);
}
m_logger.Identity(
identity.DisplayName,
identity.TokenType);
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SeparatorLine(new string('=', 80));
}
}
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.TestComplete(
SecurityPolicies.Default.GetDisplayName(securityPolicyUri),
ii.SecurityMode);
}
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SeparatorLine(new string('=', 80));
}
}
}
Console.WriteLine("Ctrl-C to stop.");
m_quitEvent!.WaitOne();
}
catch (Exception e)
{
m_logger.ExceptionMessage(e.Message);
m_logger.StackTrace(e.StackTrace);
}
}
internal async Task<SessionWrapper> RunTestAsync(
EndpointConfiguration endpointConfiguration,
DefaultSessionFactory sessionFactory,
EndpointDescription endpointDescription,
IUserIdentity identity,
CancellationToken ct)
{
var endpoint = new ConfiguredEndpoint(
null,
endpointDescription,
endpointConfiguration);
// Create the session
ISession isession = await sessionFactory
.CreateAsync(
m_configuration,
endpoint,
false,
false,
m_configuration.ApplicationName!,
600000,
endpointDescription.SecurityMode != MessageSecurityMode.None
? identity
: await CreateUserIdentityAsync(
new AnonymousIdentityProvider(),
endpointDescription,
ct).ConfigureAwait(false),
default,
ct
)
.ConfigureAwait(false);
bool ownsSession = true;
try
{
SessionWrapper wrapper = m_wrapper = new SessionWrapper { Session = isession };
ownsSession = false;
// Assign the created session
if (!wrapper.Session.Connected)
{
throw new InvalidOperationException("Could not connect to server at " + kServerUrl);
}
wrapper.Session.KeepAliveInterval = 10000;
wrapper.Session.KeepAlive += Session_KeepAlive;
var samples = new ClientSamples(m_telemetry, null, m_quitEvent);
ArrayOf<ReferenceDescription> nodes = await samples.BrowseFullAddressSpaceAsync(
wrapper,
ObjectIds.ObjectsFolder,
null,
ct).ConfigureAwait(false);
return wrapper;
}
finally
{
if (ownsSession)
{
await isession.DisposeAsync().ConfigureAwait(false);
}
}
}
private static async ValueTask<UserNamePasswordIdentityProvider> CreateUserNameProviderAsync(
CancellationToken ct)
{
var passwordStore = new InMemorySecretStore();
var passwordId = new SecretIdentifier(
"connect-tester-password",
passwordStore.StoreType);
await passwordStore
.SetAsync(passwordId, new UTF8Encoding(false).GetBytes(kPassword), ct)
.ConfigureAwait(false);
return new UserNamePasswordIdentityProvider(
kUserName,
new SecretRegistry(passwordStore),
passwordId);
}
private ValueTask<IUserIdentity> CreateUserIdentityAsync(
IClientIdentityProvider provider,
EndpointDescription endpointDescription,
CancellationToken ct)
{
return provider.AcquireIdentityAsync(
endpointDescription,
m_configuration.CreateMessageContext(),
ct);
}
private async Task<IClientIdentityProvider> LoadUserCertificateProviderAsync(
string thumbprint,
string password,
CancellationToken ct)
{
CertificateTrustList store = m_configuration.SecurityConfiguration.TrustedUserCertificates!;
#if NET8_0_OR_GREATER
// get user certificate with matching thumbprint
using CertificateCollection certificates =
await store.GetCertificatesAsync(m_telemetry, ct).ConfigureAwait(false);
using Certificate hit = certificates
.Find(X509FindType.FindByThumbprint, thumbprint, false)
.FirstOrDefault()!;
// create Certificate Identifier
var cid = new CertificateIdentifier
{
Thumbprint = hit.Thumbprint,
SubjectName = hit.Subject,
StorePath = store.StorePath,
StoreType = store.StoreType
};
return new X509ClientIdentityProvider(
cid,
new CertificatePasswordProvider(new UTF8Encoding(false).GetBytes(password)),
m_configuration.CertificateManager.CertificateProvider);
#else
throw new NotSupportedException("User certificate identity requires net8.0 or greater.");
#endif
}
private static async ValueTask<ArrayOf<EndpointDescription>> GetEndpointsAsync(
ApplicationConfiguration application,
string discoveryUrl,
CancellationToken ct = default)
{
var endpointConfiguration = EndpointConfiguration.Create(application);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
application,
new Uri(discoveryUrl),
endpointConfiguration,
ct: ct).ConfigureAwait(false);
return await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
}
private bool AcceptCertificate(Certificate certificate, ServiceResult error)
{
// ****
// Implement a custom logic to decide if the certificate should be
// accepted. Return true to accept, false to reject.
// ***
m_logger.ServiceResult(error);
bool certificateAccepted = error.StatusCode == StatusCodes.BadCertificateUntrusted;
if (certificateAccepted)
{
m_logger.UntrustedCertificateAccepted(certificate.Subject);
}
else
{
m_logger.UntrustedCertificateRejected(certificate.Subject);
}
return certificateAccepted;
}
/// <summary>
/// Handles a keep alive event from a session and triggers a reconnect if necessary.
/// </summary>
private void Session_KeepAlive(ISession session, KeepAliveEventArgs e)
{
try
{
// check for events from discarded sessions.
if (m_wrapper == null || !m_wrapper.Session.Equals(session))
{
return;
}
// start reconnect sequence on communication error.
if (ServiceResult.IsBad(e.Status))
{
SessionReconnectHandler.ReconnectState state = m_reconnectHandler!
.BeginReconnect(
m_wrapper.Session,
null,
kReconnectPeriod,
Client_ReconnectComplete
);
if (state == SessionReconnectHandler.ReconnectState.Triggered)
{
m_logger.KeepAliveReconnectStatusWithPeriod(
e.Status,
state,
kReconnectPeriod);
}
else
{
m_logger.KeepAliveReconnectStatus(
e.Status,
state);
}
// cancel sending a new keep alive request, because reconnect is triggered.
e.CancelKeepAlive = true;
}
}
catch (Exception exception)
{
m_logger.ErrorInOnKeepAlive(exception);
}
}
private void Client_ReconnectComplete(object? sender, EventArgs e)
{
// ignore callbacks from discarded objects.
if (!ReferenceEquals(sender, m_reconnectHandler))
{
return;
}
lock (m_lock)
{
// if session recovered, Session property is null
if (m_reconnectHandler!.Session != null)
{
// ensure only a new instance is disposed
// after reactivate, the same session instance may be returned
if (!ReferenceEquals(m_wrapper!.Session, m_reconnectHandler.Session))
{
m_logger.ReconnectedToNewSession(m_reconnectHandler.Session.SessionId);
ISession session = m_wrapper.Session;
m_wrapper = new SessionWrapper { Session = m_reconnectHandler.Session };
session?.Dispose();
}
else
{
m_logger.ReactivatedSession(m_reconnectHandler.Session.SessionId);
}
}
else
{
m_logger.ReconnectKeepAliveRecovered();
}
}
}
private static string GetUserCertificateFile(string securityPolicyUri)
{
// GetInfo returns null only for null/empty URI; caller passes a non-empty value.
SecurityPolicyInfo securityPolicy = SecurityPolicies.Default.GetInfo(securityPolicyUri)!;
switch (securityPolicy.CertificateKeyAlgorithm)
{
case CertificateKeyAlgorithm.BrainpoolP256r1:
return "iama.tester.brainpoolP256r1.pfx";
case CertificateKeyAlgorithm.BrainpoolP384r1:
return "iama.tester.brainpoolP384r1.pfx";
case CertificateKeyAlgorithm.NistP256:
return "iama.tester.nistP256.pfx";
case CertificateKeyAlgorithm.NistP384:
return "iama.tester.nistP384.pfx";
default:
return "iama.tester.rsa.pfx";
}
}
internal sealed class SessionWrapper : IUAClient
{
public required ISession Session { get; init; }
}
private readonly Lock m_lock = new();
private SessionReconnectHandler? m_reconnectHandler;
private ApplicationConfiguration m_configuration = null!;
private SessionWrapper? m_wrapper;
private readonly ILogger m_logger;
private readonly ITelemetryContext m_telemetry;
private readonly ManualResetEvent? m_quitEvent;
private const string kServerUrl = "opc.tcp://localhost:62541";
private const string kUserName = "sysadmin";
private const string kPassword = "demo";
private const bool kSupportsX509 = true;
private const int kReconnectPeriod = 1000;
private const int kReconnectPeriodExponentialBackoff = 15000;
}
internal static partial class ConnectTesterLog
{
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 0, Level = LogLevel.Information,
Message = "OPC UA Security Test Client")]
public static partial void OpcUaSecurityTestClient(this ILogger logger);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 1, Level = LogLevel.Information,
Message = "Connecting to... {ServerUrl}")]
public static partial void ConnectingTo(this ILogger logger, string serverUrl);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 2, Level = LogLevel.Warning,
Message = "{Line}")]
public static partial void SeparatorLine(this ILogger logger, string line);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 3, Level = LogLevel.Warning,
Message = "SECURITY-POLICY={SecurityPolicyUri} {SecurityMode}")]
public static partial void SecurityPolicy(
this ILogger logger,
string? securityPolicyUri,
MessageSecurityMode securityMode);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 4, Level = LogLevel.Warning,
Message = "IDENTITY={DisplayName} {TokenType}")]
public static partial void Identity(
this ILogger logger,
string displayName,
UserTokenType tokenType);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 5, Level = LogLevel.Warning,
Message = "Waiting for SecureChannel renew")]
public static partial void WaitingForSecureChannelRenew(this ILogger logger);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 6, Level = LogLevel.Warning,
Message = "CurrentTime: {CurrentTime}")]
public static partial void CurrentTime(this ILogger logger, DateTimeUtc currentTime);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 7, Level = LogLevel.Warning,
Message = "TEST COMPLETE: {SecurityPolicyUri} {SecurityMode}")]
public static partial void TestComplete(
this ILogger logger,
string? securityPolicyUri,
MessageSecurityMode securityMode);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 8, Level = LogLevel.Error,
Message = "Exception: {Message}")]
public static partial void ExceptionMessage(this ILogger logger, string message);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 9, Level = LogLevel.Trace,
Message = "StackTrace: {StackTrace}")]
public static partial void StackTrace(this ILogger logger, string? stackTrace);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 10, Level = LogLevel.Information,
Message = "{ServiceResult}")]
public static partial void ServiceResult(this ILogger logger, ServiceResult serviceResult);
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(AppTargetFrameWorks)</TargetFrameworks>
<AssemblyName>ConsoleReferenceClient</AssemblyName>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<PackageId>ConsoleReferenceClient</PackageId>
<Company>OPC Foundation</Company>
<Description>.NET Console Reference Client</Description>
<Copyright>Copyright © 2004-2024 OPC Foundation, Inc</Copyright>
<RootNamespace>Quickstarts.ConsoleReferenceClient</RootNamespace>
<PublishAot Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0'))">true</PublishAot>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\ConsoleReferenceServer\ConsoleUtils.cs" Exclude="bin\**;obj\**;**\*.xproj;packages\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="System.CommandLine" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.Debug" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Opc.Ua.Core\Opc.Ua.Core.csproj" />
<ProjectReference Include="..\..\..\src\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
<ProjectReference Include="..\..\..\src\Opc.Ua.Client\Opc.Ua.Client.csproj" />
<ProjectReference Include="..\..\..\src\Opc.Ua.Client.ComplexTypes\Opc.Ua.Client.ComplexTypes.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Quickstarts.ReferenceClient.Config.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,51 @@
/* ========================================================================
* Copyright (c) 2005-2026 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.ConsoleReferenceClient
{
/// <summary>
/// Centrally managed event id offsets for the source-generated log messages of the
/// ConsoleReferenceClient assembly.
/// </summary>
/// <remarks>
/// Each per-file <c>&lt;ClassName&gt;Log</c> class allocates its event ids relative to the
/// offset constant below, using <c>offset + &lt;zero-based message index&gt;</c>. Every block
/// reserves at least five spare slots for future messages and is rounded up to the next
/// multiple of ten so that ids can be documented and managed from this single location.
/// </remarks>
internal static class ConsoleReferenceClientEventIds
{
public const int AlarmClientSample = 0;
public const int ClientCertificate = 10;
public const int ClientReconnect = 20;
public const int ClientSamples = 40;
public const int ConnectTester = 110;
public const int UAClient = 130;
}
}
@@ -0,0 +1,866 @@
/* ========================================================================
* 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.CommandLine;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.ComplexTypes;
using Opc.Ua.Configuration;
using Opc.Ua.Identity;
using Opc.Ua.Security.Certificates;
namespace Quickstarts.ConsoleReferenceClient
{
/// <summary>
/// The program.
/// </summary>
public static class Program
{
/// <summary>
/// Main entry point.
/// </summary>
/// <exception cref="ErrorExitException"></exception>
public static Task<int> Main(string[] args)
{
Console.WriteLine("OPC UA Console Reference Client");
Console.WriteLine(
"OPC UA library: {0} @ {1} -- {2}",
Utils.GetAssemblyBuildNumber(),
Utils.GetAssemblyTimestamp().ToString("G", CultureInfo.InvariantCulture),
Utils.GetAssemblySoftwareVersion()
);
// The application name and config file names
const string applicationName = "ConsoleReferenceClient";
const string configSectionName = "Quickstarts.ReferenceClient";
// command line options
var autoAcceptOption = new Option<bool>("--autoaccept", "-a")
{
Description = "auto accept certificates (for testing only)"
};
var noSecurityOption = new Option<bool>("--nosecurity", "--nsec")
{
Description = "select endpoint with security NONE, least secure if unavailable"
};
var usernameOption = new Option<string>("--username", "--un")
{
Description = "the name of the user identity for the connection"
};
var userPasswordOption = new Option<string>("--userpassword", "--up")
{
Description = "the password of the user identity for the connection"
};
var userCertificateOption = new Option<string>("--usercertificate", "--uc")
{
Description = "the thumbprint of the user certificate for the user identity"
};
var userCertificatePasswordOption = new Option<string>("--usercertificatepassword", "--ucp")
{
Description = "the password of the user certificate for the user identity"
};
var consoleOption = new Option<bool>("--console", "-c") { Description = "log to console" };
var logOption = new Option<bool>("--log", "-l") { Description = "log app output" };
var fileOption = new Option<bool>("--file", "-f") { Description = "log to file" };
var passwordOption = new Option<string>("--password", "-p")
{
Description = "optional password for private key"
};
var renewOption = new Option<bool>("--renew", "-r")
{
Description = "renew application certificate"
};
var timeoutOption = new Option<int>("--timeout", "-t")
{
Description = "timeout in seconds to exit application",
DefaultValueFactory = _ => Timeout.Infinite
};
var logFileOption = new Option<string>("--logfile")
{
Description = "custom file name for log output"
};
var loadTypesOption = new Option<bool>("--loadtypes", "--lt")
{
Description = "Load custom types"
};
var managedBrowseAllOption = new Option<bool>("--managedbrowseall", "-m")
{
Description = "Browse all references using the MangedBrowseAsync method"
};
var browseAllOption = new Option<bool>("--browseall", "-b")
{
Description = "Browse all references"
};
var exportOption = new Option<bool>("--export", "-e")
{
Description = "Export all fetched nodes into Nodeset2 xml per default"
};
var exportNamespaceOption = new Option<string[]>("--namespace", "--ns")
{
Description =
"Restrict --export to nodes whose namespace URI is in the supplied list. " +
"Repeat the option to add more, e.g. --namespace http://opcfoundation.org/UA/Boiler/ " +
"--namespace urn:other. When omitted, all non-OPC-UA-base namespaces are exported."
};
var fetchAllOption = new Option<bool>("--fetchall", "--fa")
{
Description = "Fetch all nodes"
};
var jsonOption = new Option<bool>("--json", "-j")
{
Description = "Output all Values as JSON"
};
var verboseOption = new Option<bool>("--verbose", "-v") { Description = "Verbose output" };
var subscribeOption = new Option<bool>("--subscribe", "-s") { Description = "Subscribe" };
var testallEndpointsOption = new Option<bool>("--testall", "--ea") { Description = "Test All Endpoints" };
var reverseConnectOption = new Option<string>("--reverseconnect", "--rc")
{
Description = "Connect using the reverse connect endpoint. (e.g. --rc opc.tcp://localhost:65300)"
};
var foreverOption = new Option<bool>("--forever")
{
Description = "run inner connect/disconnect loop forever"
};
var leakChannelsOption = new Option<bool>("--leakchannels")
{
Description = "Leave a channel leak open when disconnecting a session."
};
var durableSubscriptionOption = new Option<bool>("--durablesubscription", "--ds")
{
Description = "SetDurableSubscription example"
};
var serverUrlArgument = new Argument<string>("serverUrl")
{
Description = "The OPC UA server URL to connect to",
DefaultValueFactory = _ => "opc.tcp://localhost:62541/Quickstarts/ReferenceServer"
};
var rootCommand = new RootCommand($"Usage: dotnet {applicationName}.dll [OPTIONS] [serverUrl]")
{
autoAcceptOption,
noSecurityOption,
usernameOption,
userPasswordOption,
userCertificateOption,
userCertificatePasswordOption,
consoleOption,
logOption,
fileOption,
passwordOption,
renewOption,
timeoutOption,
logFileOption,
loadTypesOption,
managedBrowseAllOption,
browseAllOption,
exportOption,
exportNamespaceOption,
fetchAllOption,
jsonOption,
verboseOption,
subscribeOption,
testallEndpointsOption,
reverseConnectOption,
foreverOption,
leakChannelsOption,
durableSubscriptionOption,
serverUrlArgument
};
rootCommand.SetAction(async (parseResult, cancellationToken) =>
{
bool autoAccept = parseResult.GetValue(autoAcceptOption);
bool noSecurity = parseResult.GetValue(noSecurityOption);
string? username = parseResult.GetValue(usernameOption);
byte[]? userpassword =
parseResult.GetValue(userPasswordOption) is string upStr ?
Encoding.UTF8.GetBytes(upStr) : null;
string? userCertificateThumbprint =
parseResult.GetValue(userCertificateOption);
byte[]? userCertificatePassword =
parseResult.GetValue(userCertificatePasswordOption) is string ucpStr ?
Encoding.UTF8.GetBytes(ucpStr) : null;
bool logConsole = parseResult.GetValue(consoleOption);
bool appLog = parseResult.GetValue(logOption);
bool fileLog = parseResult.GetValue(fileOption);
byte[]? pfxPassword =
parseResult.GetValue(passwordOption) is string pfxStr ?
Encoding.UTF8.GetBytes(pfxStr) : null;
bool renewCertificate = parseResult.GetValue(renewOption);
int timeout = parseResult.GetValue(timeoutOption);
if (timeout > 0)
{
timeout *= 1000;
}
string? logFile = parseResult.GetValue(logFileOption);
bool loadTypes = parseResult.GetValue(loadTypesOption);
bool managedbrowseall =
parseResult.GetValue(managedBrowseAllOption);
bool browseall = parseResult.GetValue(browseAllOption);
bool exportNodes = parseResult.GetValue(exportOption);
string[]? exportNamespacesRaw = parseResult.GetValue(exportNamespaceOption);
HashSet<string>? exportNamespaces = exportNamespacesRaw is { Length: > 0 }
? new HashSet<string>(exportNamespacesRaw, StringComparer.OrdinalIgnoreCase)
: null;
bool fetchall = parseResult.GetValue(fetchAllOption);
bool jsonvalues = parseResult.GetValue(jsonOption);
bool verbose = parseResult.GetValue(verboseOption);
bool subscribe = parseResult.GetValue(subscribeOption);
string? reverseConnectUrlString =
parseResult.GetValue(reverseConnectOption);
bool forever = parseResult.GetValue(foreverOption);
bool leakChannels = parseResult.GetValue(leakChannelsOption);
bool enableDurableSubscriptions =
parseResult.GetValue(durableSubscriptionOption);
// serverUrlArgument has DefaultValueFactory so GetValue returns non-null at runtime.
var serverUrl = new Uri(parseResult.GetValue(serverUrlArgument)!);
bool testallEndpoints = parseResult.GetValue(testallEndpointsOption);
ReverseConnectManager? reverseConnectManager = null;
using var telemetry = new ConsoleTelemetry();
ILogger logger = LoggerUtils.Null.Logger;
try
{
// log console output to logger
if (logConsole && appLog)
{
logger = telemetry.CreateLogger("Main");
}
// Define the UA Client application
ApplicationInstance.MessageDlg = new ApplicationMessageDlg();
// Lib param is annotated non-null but the impl tolerates null at runtime.
var passwordProvider = new CertificatePasswordProvider(pfxPassword!);
var application = new ApplicationInstance(telemetry)
{
ApplicationName = applicationName,
ApplicationType = ApplicationType.Client,
ConfigSectionName = configSectionName,
CertificatePasswordProvider = passwordProvider
};
// load the application configuration.
ApplicationConfiguration config = await application
.LoadApplicationConfigurationAsync(silent: false, ct: cancellationToken)
.ConfigureAwait(false);
// After LoadApplicationConfigurationAsync the property is set; cache the
// non-null reference so we don't repeat the null-forgiving operator.
ApplicationConfiguration appConfig = application.ApplicationConfiguration!;
// override logfile
if (logFile != null)
{
string? logFilePath = config.TraceConfiguration!.OutputFilePath;
string filename = Path.GetFileNameWithoutExtension(logFilePath)!;
config.TraceConfiguration.OutputFilePath = logFilePath!.Replace(
filename,
logFile,
StringComparison.Ordinal
);
config.TraceConfiguration.DeleteOnLoad = true;
}
// setup the logging
telemetry.ConfigureLogging(
config,
applicationName,
logConsole,
fileLog,
appLog,
LogLevel.Information);
// delete old certificate
if (renewCertificate)
{
await application.DeleteApplicationInstanceCertificateAsync(ct: cancellationToken)
.ConfigureAwait(false);
}
// check the application certificate.
bool haveAppCertificate = await application
.CheckApplicationInstanceCertificatesAsync(false, ct: cancellationToken)
.ConfigureAwait(false);
if (!haveAppCertificate)
{
throw new ErrorExitException(
"Application instance certificate invalid!",
ExitCode.ErrorCertificate
);
}
if (reverseConnectUrlString != null)
{
// start the reverse connection manager
Console.WriteLine($"Create reverse connection endpoint at {reverseConnectUrlString}.");
reverseConnectManager = new ReverseConnectManager(telemetry);
reverseConnectManager.AddEndpoint(new Uri(reverseConnectUrlString));
await reverseConnectManager.StartServiceAsync(config, cancellationToken)
.ConfigureAwait(false);
}
// wait for timeout or Ctrl-C
var quitCTS = new CancellationTokenSource();
CancellationToken ct = quitCTS.Token;
ManualResetEvent quitEvent = ConsoleUtils.CtrlCHandler(quitCTS);
// handle connect all endpoints test.
if (testallEndpoints)
{
var tester = new ConnectTester(telemetry, quitEvent);
await using (tester.ConfigureAwait(false))
{
await tester.RunAsync(ct).ConfigureAwait(false);
}
return;
}
var identityProviders = new List<IClientIdentityProvider>();
// set user identity of type username/pw
if (!string.IsNullOrEmpty(username))
{
if (userpassword == null)
{
Console.WriteLine($"No password provided for user {username}, using empty password.");
}
var passwordStore = new InMemorySecretStore();
var passwordId = new SecretIdentifier(
"console-reference-client-password",
passwordStore.StoreType);
await passwordStore
.SetAsync(passwordId, userpassword ?? [], ct)
.ConfigureAwait(false);
identityProviders.Add(new UserNamePasswordIdentityProvider(
username!,
new SecretRegistry(passwordStore),
passwordId));
Console.WriteLine($"Connect with user identity for user {username}");
}
// set user identity of type certificate
if (!string.IsNullOrEmpty(userCertificateThumbprint))
{
CertificateIdentifier? userCertificateIdentifier =
await FindUserCertificateIdentifierAsync(
userCertificateThumbprint!,
appConfig.SecurityConfiguration
.TrustedUserCertificates!,
telemetry,
ct).ConfigureAwait(true);
if (userCertificateIdentifier != null)
{
identityProviders.Insert(0, new X509ClientIdentityProvider(
userCertificateIdentifier,
new CertificatePasswordProvider(userCertificatePassword!),
application.ApplicationConfiguration!.CertificateManager!.CertificateProvider));
Console.WriteLine($"Connect with user certificate with Thumbprint {userCertificateThumbprint}");
}
else
{
Console.WriteLine($"Failed to load user certificate with Thumbprint {userCertificateThumbprint}");
}
}
identityProviders.Add(new AnonymousIdentityProvider());
var identityProvider = new CompositeClientIdentityProvider(identityProviders);
// connect to a server until application stops
bool quit = false;
DateTime start = DateTime.UtcNow;
int waitTime = int.MaxValue;
do
{
if (timeout > 0)
{
waitTime = timeout - (int)DateTime.UtcNow.Subtract(start).TotalMilliseconds;
if (waitTime <= 0)
{
if (!forever)
{
break;
}
waitTime = 0;
}
if (forever)
{
start = DateTime.UtcNow;
}
}
// create the UA Client object and connect to configured server.
using var uaClient = new UAClient(
appConfig,
reverseConnectManager,
telemetry,
null
)
{
AutoAccept = autoAccept,
SessionLifeTime = 60_000,
IdentityProvider = identityProvider
};
if (enableDurableSubscriptions)
{
uaClient.ReconnectPeriodExponentialBackoff = 60000;
}
bool connected = await uaClient
.ConnectAsync(serverUrl.ToString(), !noSecurity, ct)
.ConfigureAwait(false);
if (connected)
{
Console.WriteLine("Connected! Ctrl-C to quit.");
// enable subscription transfer
uaClient.ReconnectPeriod = 1000;
uaClient.ReconnectPeriodExponentialBackoff = 10000;
uaClient.Session.MinPublishRequestCount = 3;
uaClient.Session.TransferSubscriptionsOnReconnect = true;
var samples = new ClientSamples(
telemetry,
null,
quitEvent,
verbose);
if (loadTypes)
{
var complexTypeSystem = ComplexTypeSystem.Create(uaClient.Session, telemetry);
await samples.LoadTypeSystemAsync(complexTypeSystem, ct).ConfigureAwait(false);
}
if (browseall || fetchall || jsonvalues || managedbrowseall)
{
List<NodeId>? variableIds = null;
List<NodeId>? variableIdsManagedBrowse = null;
ArrayOf<ReferenceDescription> referenceDescriptions = default;
ArrayOf<ReferenceDescription> referenceDescriptionsFromManagedBrowse = default;
if (browseall)
{
Console.WriteLine("Browse the full address space.");
referenceDescriptions = await samples
.BrowseFullAddressSpaceAsync(uaClient, ObjectIds.RootFolder, ct: ct)
.ConfigureAwait(false);
variableIds =
[
.. referenceDescriptions
.Filter(r =>
r.NodeClass == NodeClass.Variable &&
r.TypeDefinition.NamespaceIndex != 0
)
.ConvertAll(r => ExpandedNodeId.ToNodeId(
r.NodeId,
uaClient.Session.NamespaceUris))
];
}
if (managedbrowseall)
{
Console.WriteLine("ManagedBrowse the full address space.");
referenceDescriptionsFromManagedBrowse = await samples
.ManagedBrowseFullAddressSpaceAsync(
uaClient,
ObjectIds.RootFolder,
ct: ct)
.ConfigureAwait(false);
variableIdsManagedBrowse =
[
.. referenceDescriptionsFromManagedBrowse
.Filter(r =>
r.NodeClass == NodeClass.Variable &&
r.TypeDefinition.NamespaceIndex != 0
)
.ConvertAll(r => ExpandedNodeId.ToNodeId(
r.NodeId,
uaClient.Session.NamespaceUris))
];
}
// treat managedBrowseall result like browseall results if the latter is missing
if (!browseall && managedbrowseall)
{
referenceDescriptions = referenceDescriptionsFromManagedBrowse;
browseall = managedbrowseall;
}
IList<INode>? allNodes = null;
if (fetchall)
{
allNodes = await samples
.FetchAllNodesNodeCacheAsync(
uaClient,
ObjectIds.RootFolder,
true,
true,
false,
ct: ct)
.ConfigureAwait(false);
variableIds =
[
.. allNodes
.Where(r =>
r.NodeClass == NodeClass.Variable &&
r is VariableNode v &&
v.DataType.NamespaceIndex != 0
)
.Select(r => ExpandedNodeId.ToNodeId(
r.NodeId,
uaClient.Session.NamespaceUris))
];
if (exportNodes)
{
await samples.ExportNodesToNodeSet2PerNamespaceAsync(
uaClient.Session,
allNodes,
Environment.CurrentDirectory,
exportNamespaces,
cancellationToken)
.ConfigureAwait(false);
}
}
if (jsonvalues && variableIds != null)
{
(
ArrayOf<DataValue> allValues,
ArrayOf<ServiceResult> results
) = await samples
.ReadAllValuesAsync(uaClient, variableIds.ToArrayOf(), ct)
.ConfigureAwait(false);
}
if (subscribe && (browseall || fetchall))
{
// subscribe to 1000 random variables
const int maxVariables = 1000;
var variables = new List<Node>();
if (fetchall)
{
variables.AddRange(
allNodes!
.Where(r =>
r.NodeClass == NodeClass.Variable &&
r.NodeId.NamespaceIndex > 1
)
.Cast<VariableNode>()
.OrderBy(o => UnsecureRandom.Shared.Next())
.Take(maxVariables)
);
}
else if (browseall)
{
var variableReferences = referenceDescriptions
.ToList()
.Where(r => r.NodeClass == NodeClass.Variable &&
r.NodeId.NamespaceIndex > 1)
.Select(r => r.NodeId)
.OrderBy(o => UnsecureRandom.Shared.Next())
.Take(maxVariables)
.ToList();
variables.AddRange(
(await uaClient.Session.NodeCache.FindAsync(variableReferences, ct)
.ConfigureAwait(false))
.ToList()
.Cast<Node>()
);
}
await samples
.SubscribeAllValuesAsync(
uaClient,
variableIds: [.. variables],
samplingInterval: 100,
publishingInterval: 1000,
queueSize: 10,
lifetimeCount: 60,
keepAliveCount: 2,
ct: ct
)
.ConfigureAwait(false);
// Wait for DataChange notifications from MonitoredItems
Console.WriteLine($"Subscribed to {maxVariables} variables. Press Ctrl-C to exit.");
// free unused memory
uaClient.Session.NodeCache.Clear();
waitTime = timeout -
(int)DateTime.UtcNow.Subtract(start).TotalMilliseconds;
DateTime endTime =
waitTime > 0
? DateTime.UtcNow.Add(TimeSpan.FromMilliseconds(waitTime))
: DateTime.MaxValue;
List<Node>.Enumerator variableIterator = variables.GetEnumerator();
while (!quit && endTime > DateTime.UtcNow)
{
if (variableIterator.MoveNext())
{
try
{
DataValue value = await uaClient
.Session.ReadValueAsync(
variableIterator.Current.NodeId, ct)
.ConfigureAwait(false);
Console.WriteLine($"Value of {variableIterator.Current.NodeId} is {value}");
}
catch (Exception ex)
{
Console.WriteLine($"Error reading value of {variableIterator.Current.NodeId}: {ex.Message}");
}
}
else
{
variableIterator = variables.GetEnumerator();
}
quit = quitEvent.WaitOne(500);
}
}
else
{
quit = true;
}
}
else
{
int quitTimeout = 65_000;
if (enableDurableSubscriptions)
{
quitTimeout = 150_000;
uaClient.ReconnectPeriod = 500_000;
}
NodeId sessionNodeId = uaClient.Session.SessionId;
// Run tests for available methods on reference server.
await samples.ReadNodesAsync(
uaClient.Session,
ct).ConfigureAwait(false);
await samples.WriteNodesAsync(
uaClient.Session,
ct).ConfigureAwait(false);
await samples.BrowseAsync(
uaClient.Session,
ct).ConfigureAwait(false);
await samples.CallMethodAsync(
uaClient.Session,
ct).ConfigureAwait(false);
await samples.EnableEventsAsync(
uaClient.Session,
(uint)quitTimeout,
ct).ConfigureAwait(false);
await samples.SubscribeToDataChangesAsync(
uaClient.Session,
60_000,
enableDurableSubscriptions,
ct).ConfigureAwait(false);
Console.WriteLine("Waiting...");
// Wait for some DataChange notifications from MonitoredItems
int waitCounters = 0;
const int checkForWaitTime = 1000;
const int closeSessionTime = checkForWaitTime * 15;
const int restartSessionTime = checkForWaitTime * 45;
const bool stopNotQuit = false;
int stopCount = 0;
while (!quit && !stopNotQuit && waitCounters < quitTimeout)
{
quit = quitEvent.WaitOne(checkForWaitTime);
waitCounters += checkForWaitTime;
if (enableDurableSubscriptions)
{
if (waitCounters == closeSessionTime &&
uaClient.Session.SubscriptionCount == 1)
{
Console.WriteLine($"Closing Session (CurrentTime: {DateTime.Now:T})");
await uaClient.Session.CloseAsync(closeChannel: false, ct: ct)
.ConfigureAwait(false);
}
if (waitCounters == restartSessionTime)
{
Console.WriteLine($"Restarting Session (CurrentTime: {DateTime.Now:T})");
await uaClient
.DurableSubscriptionTransferAsync(
serverUrl.ToString(),
useSecurity: !noSecurity,
ct
)
.ConfigureAwait(true);
}
if (waitCounters is > closeSessionTime and < restartSessionTime)
{
Console.WriteLine(
"No Communication Interval " +
stopCount.ToString(CultureInfo.InvariantCulture)
);
stopCount++;
}
}
}
}
Console.WriteLine("Client disconnected.");
await uaClient.DisconnectAsync(leakChannels, ct).ConfigureAwait(false);
}
else
{
Console.WriteLine("Could not connect to server! Retry in 10 seconds or Ctrl-C to quit.");
quit = quitEvent.WaitOne(Math.Min(10_000, waitTime));
}
} while (!quit);
Console.WriteLine("Client stopped.");
}
catch (Exception ex)
{
Console.WriteLine($"{ex.Message}");
}
finally
{
if (reverseConnectManager != null)
{
await reverseConnectManager.DisposeAsync().ConfigureAwait(false);
}
}
});
args = ConsoleUtils.MergeEnvironmentArgs(args, "REFCLIENT", rootCommand);
ParseResult cmdParseResult = rootCommand.Parse(args);
return cmdParseResult
.InvokeAsync(new InvocationConfiguration(), CancellationToken.None);
}
/// <summary>
/// returns a CertificateIdentifier of the Certificate with the specified thumbprint
/// if it is found in the trustedUserCertificates TrustList
/// </summary>
/// <param name="thumbprint">the thumbprint of the certificate to select</param>
/// <param name="trustedUserCertificates">the trustlist of the user certificates</param>
/// <param name="ct">the cancellation token</param>
/// <returns>Certificate Identifier</returns>
private static async Task<CertificateIdentifier?> FindUserCertificateIdentifierAsync(
string thumbprint,
CertificateTrustList trustedUserCertificates,
ITelemetryContext telemetry,
CancellationToken ct = default)
{
CertificateIdentifier? userCertificateIdentifier = null;
using CertificateCollection certificates =
await trustedUserCertificates.GetCertificatesAsync(telemetry, ct).ConfigureAwait(false);
// get user certificate with matching thumbprint
using CertificateCollection userCertificatesWithMatchingThumbprint =
certificates.Find(X509FindType.FindByThumbprint, thumbprint, false);
// create Certificate Identifier
if (userCertificatesWithMatchingThumbprint.Count == 1)
{
Certificate userCert = userCertificatesWithMatchingThumbprint[0];
userCertificateIdentifier = new CertificateIdentifier
{
Thumbprint = userCert.Thumbprint,
SubjectName = userCert.Subject,
StorePath = trustedUserCertificates.StorePath,
StoreType = trustedUserCertificates.StoreType
};
}
return userCertificateIdentifier;
}
/// <summary>
/// A dialog which asks for user input.
/// </summary>
public class ApplicationMessageDlg : IApplicationMessageDlg
{
private readonly TextWriter m_output;
private string m_message = string.Empty;
private bool m_ask;
public ApplicationMessageDlg(TextWriter? output = null)
{
m_output = output ?? Console.Out;
}
public override void Message(string text, bool ask)
{
m_message = text;
m_ask = ask;
}
public override async Task<bool> ShowAsync()
{
if (m_ask)
{
var message = new StringBuilder(m_message);
message.Append(" (y/n, default y): ");
m_output.Write(message.ToString());
try
{
ConsoleKeyInfo result = Console.ReadKey();
m_output.WriteLine();
return await Task.FromResult(result.KeyChar is 'y' or 'Y' or '\r')
.ConfigureAwait(false);
}
catch
{
// intentionally fall through
}
}
else
{
m_output.WriteLine(m_message);
}
return await Task.FromResult(true).ConfigureAwait(false);
}
}
}
}
@@ -0,0 +1,32 @@
/* ========================================================================
* 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;
[assembly: CLSCompliant(false)]
@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<ApplicationConfiguration
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:ua="http://opcfoundation.org/UA/2008/02/Types.xsd"
xmlns="http://opcfoundation.org/UA/SDK/Configuration.xsd"
>
<ApplicationName>Quickstart Console Reference Client</ApplicationName>
<ApplicationUri>urn:localhost:UA:Quickstarts:ReferenceClient</ApplicationUri>
<ProductUri>uri:opcfoundation.org:Quickstarts:ReferenceClient</ProductUri>
<ApplicationType>Client_1</ApplicationType>
<SecurityConfiguration>
<!-- Where the application instance certificate is stored (MachineDefault) -->
<ApplicationCertificates>
<CertificateIdentifier>
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/own</StorePath>
<SubjectName>CN=Quickstart Reference Client, C=US, S=Arizona, O=OPC Foundation, DC=localhost</SubjectName>
<CertificateTypeString>RsaSha256</CertificateTypeString>
</CertificateIdentifier>
<CertificateIdentifier>
<!-- <TypeId>NistP256</TypeId> -->
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/own</StorePath>
<SubjectName>CN=Quickstart Reference Client, C=US, S=Arizona, O=OPC Foundation, DC=localhost</SubjectName>
<CertificateTypeString>NistP256</CertificateTypeString>
</CertificateIdentifier>
<CertificateIdentifier>
<!-- <TypeId>NistP384</TypeId> -->
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/own</StorePath>
<SubjectName>CN=Quickstart Reference client, C=US, S=Arizona, O=OPC Foundation, DC=localhost</SubjectName>
<CertificateTypeString>NistP384</CertificateTypeString>
</CertificateIdentifier>
<CertificateIdentifier>
<!-- <TypeId>BrainpoolP256r1</TypeId> -->
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/own</StorePath>
<SubjectName>CN=Quickstart Reference Client, C=US, S=Arizona, O=OPC Foundation, DC=localhost</SubjectName>
<CertificateTypeString>BrainpoolP256r1</CertificateTypeString>
</CertificateIdentifier>
<CertificateIdentifier>
<!-- <TypeId>BrainpoolP384r1</TypeId> -->
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/own</StorePath>
<SubjectName>CN=Quickstart Reference Client, C=US, S=Arizona, O=OPC Foundation, DC=localhost</SubjectName>
<CertificateTypeString>BrainpoolP384r1</CertificateTypeString>
</CertificateIdentifier>
</ApplicationCertificates>
<!-- Where the issuer certificate are stored (certificate authorities) -->
<TrustedIssuerCertificates>
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/issuer</StorePath>
</TrustedIssuerCertificates>
<!-- Where the trust list is stored -->
<TrustedPeerCertificates>
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/trusted</StorePath>
</TrustedPeerCertificates>
<!-- The directory used to store invalid certificates for later review by the administrator. -->
<RejectedCertificateStore>
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/rejected</StorePath>
</RejectedCertificateStore>
<MaxRejectedCertificates>5</MaxRejectedCertificates>
<!-- WARNING: The following setting (to automatically accept untrusted certificates) should be used
for easy debugging purposes ONLY and turned off for production deployments! -->
<AutoAcceptUntrustedCertificates>false</AutoAcceptUntrustedCertificates>
<!-- WARNING: SHA1 signed certificates are by default rejected and should be phased out.
only nano and embedded profiles are allowed to use sha1 signed certificates. -->
<RejectSHA1SignedCertificates>true</RejectSHA1SignedCertificates>
<RejectUnknownRevocationStatus>true</RejectUnknownRevocationStatus>
<MinimumCertificateKeySize>2048</MinimumCertificateKeySize>
<AddAppCertToTrustedStore>false</AddAppCertToTrustedStore>
<SendCertificateChain>true</SendCertificateChain>
<!-- Where the User issers list is stored-->
<UserIssuerCertificates>
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/userIssuer</StorePath>
</UserIssuerCertificates>
<!-- Where the User trust list is stored-->
<TrustedUserCertificates>
<StoreType>Directory</StoreType>
<StorePath>%LocalApplicationData%/OPC Foundation/pki/trustedUser</StorePath>
</TrustedUserCertificates>
</SecurityConfiguration>
<TransportConfigurations></TransportConfigurations>
<TransportQuotas>
<OperationTimeout>120000</OperationTimeout>
<MaxStringLength>4194304</MaxStringLength>
<MaxByteStringLength>4194304</MaxByteStringLength>
<MaxArrayLength>65535</MaxArrayLength>
<MaxMessageSize>4194304</MaxMessageSize>
<MaxBufferSize>65535</MaxBufferSize>
<ChannelLifetime>300000</ChannelLifetime>
<SecurityTokenLifetime>3600000</SecurityTokenLifetime>
</TransportQuotas>
<ClientConfiguration>
<DefaultSessionTimeout>60000</DefaultSessionTimeout>
<WellKnownDiscoveryUrls>
<ua:String>opc.tcp://{0}:4840</ua:String>
<ua:String>http://{0}:52601/UADiscovery</ua:String>
<ua:String>http://{0}/UADiscovery/Default.svc</ua:String>
</WellKnownDiscoveryUrls>
<DiscoveryServers></DiscoveryServers>
<MinSubscriptionLifetime>10000</MinSubscriptionLifetime>
<OperationLimits>
<MaxNodesPerRead>2500</MaxNodesPerRead>
<MaxNodesPerHistoryReadData>1000</MaxNodesPerHistoryReadData>
<MaxNodesPerHistoryReadEvents>1000</MaxNodesPerHistoryReadEvents>
<MaxNodesPerWrite>2500</MaxNodesPerWrite>
<MaxNodesPerHistoryUpdateData>1000</MaxNodesPerHistoryUpdateData>
<MaxNodesPerHistoryUpdateEvents>1000</MaxNodesPerHistoryUpdateEvents>
<MaxNodesPerMethodCall>2500</MaxNodesPerMethodCall>
<MaxNodesPerBrowse>2500</MaxNodesPerBrowse>
<MaxNodesPerRegisterNodes>2500</MaxNodesPerRegisterNodes>
<MaxNodesPerTranslateBrowsePathsToNodeIds>2500</MaxNodesPerTranslateBrowsePathsToNodeIds>
<MaxNodesPerNodeManagement>2500</MaxNodesPerNodeManagement>
<MaxMonitoredItemsPerCall>2500</MaxMonitoredItemsPerCall>
</OperationLimits>
</ClientConfiguration>
<Extensions></Extensions>
<TraceConfiguration>
<OutputFilePath>%LocalApplicationData%/OPC Foundation/Logs/Quickstarts.ReferenceClient.log.txt</OutputFilePath>
<DeleteOnLoad>true</DeleteOnLoad>
<!-- Show Only Errors -->
<!-- <TraceMasks>1</TraceMasks> -->
<!-- Show Only Security and Errors -->
<!-- <TraceMasks>513</TraceMasks> -->
<!-- Show Only Security, Errors and Trace -->
<!-- <TraceMasks>515</TraceMasks> -->
<!-- Show Only Security, COM Calls, Errors and Trace -->
<!-- <TraceMasks>771</TraceMasks> -->
<!-- Show Only Security, Service Calls, Errors and Trace -->
<!-- <TraceMasks>523</TraceMasks> -->
<!-- Show Only Security, ServiceResultExceptions, Errors and Trace -->
<!-- <TraceMasks>519</TraceMasks> -->
</TraceConfiguration>
</ApplicationConfiguration>
@@ -0,0 +1,83 @@
# OPC Foundation UA .NET Standard Reference Client
## Introduction
The console reference client can be configured using several console parameters.
Some of these parameters are explained in more detail below.
To see all available parameters call console reference client with the parameter `-h`.
## Supported transport profiles
The client picks the wire transport automatically from the `serverUrl`
scheme. The OPC UA spec aliases the URL scheme to the
[`TransportProfileUri`](https://reference.opcfoundation.org/Core/Part6/v107/docs/7.4)
on the negotiated endpoint:
| URL scheme | TransportProfileUri | OPC UA Part 6 reference |
| --- | --- | --- |
| `opc.tcp://` | `uatcp-uasc-uabinary` | §7.4.2 |
| `opc.https://` (or `https://`) with binary body | `https-uabinary` | §7.4.4 |
| `opc.https://` (or `https://`) with `application/opcua+uajson` body | `https-uajson` | §7.4.5 |
| `opc.wss://` (or `wss://`) with `opcua+uacp` sub-protocol | `wss-uasc-uabinary` | §7.5.2 |
| `opc.wss://` (or `wss://`) with `opcua+uajson` sub-protocol | `wss-uajson` | §7.5.2 |
Examples:
```bash
# UA-TCP (default)
dotnet ConsoleReferenceClient.dll opc.tcp://localhost:62541/Quickstarts/ReferenceServer
# WSS+uacp (binary UA SecureChannel over TLS WebSocket)
dotnet ConsoleReferenceClient.dll opc.wss://localhost:62543/Quickstarts/ReferenceServer
# HTTPS (binary)
dotnet ConsoleReferenceClient.dll opc.https://localhost:62543/Quickstarts/ReferenceServer
```
The reference server (`ConsoleReferenceServer`) advertises the
`opc.tcp://`, `opc.https://` and `opc.wss://` endpoints by default; see
[`docs/Transports.md`](../../../docs/Transports.md) for the full server
configuration story (TLS certificates, mutual TLS, JSON sub-protocol
restrictions, etc.).
## Reverse Connect
The OPC UA reverse connect feature allows an OPC UA server to initiate the connection to a client, rather than the traditional model where clients connect to servers. This is particularly useful in scenarios where the server is behind a firewall or NAT, making it difficult for clients to directly connect to it.
### How to use Reverse Connect
To enable reverse connect mode, specify the client endpoint URL using the `--rc` or `--reverseconnect` parameter:
```bash
dotnet ConsoleReferenceClient.dll --rc=opc.tcp://localhost:65300 opc.tcp://localhost:62541/Quickstarts/ReferenceServer
```
The client will start a reverse connect listener on the specified endpoint (e.g., `opc.tcp://localhost:65300`) and wait for the server to connect to it.
### Example: Client and Server with Reverse Connect
1. Start the client with reverse connect listener on port 65300:
```bash
dotnet ConsoleReferenceClient.dll --rc=opc.tcp://localhost:65300 opc.tcp://localhost:62541/Quickstarts/ReferenceServer
```
2. In a separate terminal, start the server with reverse connect to the client:
```bash
dotnet ConsoleReferenceServer.dll --rc=opc.tcp://localhost:65300 -a
```
The server will establish a reverse connection to the client endpoint, and the client will use this connection to communicate with the server.
### How to specify User Identity
#### Username & Password
Specify as console parameters:
`-un YourUsername`
`-up YourPassword`
#### Certificate
Place your user certificate in the TrustedUserCertificatesStore (the path can be found in the client configuration XML). Make sure to include an accessible private key with the certificate.
Specify console parameters:
`-uc Thumbprint` (of the user certificate to select)
`-ucp Password` (of the user certificates private key (optional))
@@ -0,0 +1,571 @@
/* ========================================================================
* 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.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Identity;
using Opc.Ua.Security.Certificates;
using Quickstarts.ConsoleReferenceClient;
namespace Quickstarts
{
/// <summary>
/// OPC UA Client with examples of basic functionality.
/// </summary>
public class UAClient : IUAClient, IDisposable
{
/// <summary>
/// Initializes a new instance of the UAClient class.
/// </summary>
public UAClient(
ApplicationConfiguration configuration,
ITelemetryContext telemetry,
Action<Array, Array>? validateResponse)
: this(
configuration,
null,
telemetry,
validateResponse)
{
}
/// <summary>
/// Initializes a new instance of the UAClient class for reverse connections.
/// </summary>
public UAClient(
ApplicationConfiguration configuration,
ReverseConnectManager? reverseConnectManager,
ITelemetryContext telemetry,
Action<Array, Array>? validateResponse)
{
ValidateResponse = validateResponse;
m_logger = telemetry.CreateLogger<UAClient>();
m_telemetry = telemetry;
m_configuration = configuration;
// Modern global accept hook on ICertificateManager — fires for
// every certificate validation done via this manager.
m_configuration.CertificateManager.AcceptError = AcceptCertificate;
m_reverseConnectManager = reverseConnectManager;
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Overridable Dispose pattern implementation.
/// </summary>
/// <param name="disposing"></param>
protected virtual void Dispose(bool disposing)
{
if (m_disposed)
{
return;
}
if (disposing)
{
m_reconnectHandler?.Dispose();
Session?.Dispose();
m_configuration.CertificateManager.AcceptError = null;
}
m_disposed = true;
}
/// <summary>
/// Action used
/// </summary>
internal Action<Array, Array>? ValidateResponse { get; }
/// <summary>
/// Gets the client session.
/// </summary>
/// <remarks>
/// Initialized to <c>null!</c> as late-init: the value is assigned by
/// <see cref="ConnectAsync"/> before any caller observes it, and is
/// transiently set to <c>null!</c> during reconnect/dispose. The
/// non-nullable type matches the <see cref="IUAClient"/> contract.
/// </remarks>
public ISession Session { get; private set; } = null!;
/// <summary>
/// The session keepalive interval to be used in ms.
/// </summary>
public int KeepAliveInterval { get; set; } = 5000;
/// <summary>
/// The reconnect period to be used in ms.
/// </summary>
public int ReconnectPeriod { get; set; } = 1000;
/// <summary>
/// The reconnect period exponential backoff to be used in ms.
/// </summary>
public int ReconnectPeriodExponentialBackoff { get; set; } = 15000;
/// <summary>
/// The session lifetime.
/// </summary>
public uint SessionLifeTime { get; set; } = 60 * 1000;
/// <summary>
/// The user identity provider to use to connect to the server.
/// </summary>
public IClientIdentityProvider IdentityProvider { get; set; } = new AnonymousIdentityProvider();
/// <summary>
/// Auto accept untrusted certificates.
/// </summary>
public bool AutoAccept { get; set; }
/// <summary>
/// The file to use for log output.
/// </summary>
public string? LogFile { get; set; }
/// <summary>
/// Do a Durable Subscription Transfer
/// </summary>
public async Task<bool> DurableSubscriptionTransferAsync(
string serverUrl,
bool useSecurity = true,
CancellationToken ct = default)
{
bool success = false;
SubscriptionCollection subscriptions = [.. Session.Subscriptions];
// Transiently null between sessions; ConnectAsync rehydrates.
Session = null!;
if (await ConnectAsync(serverUrl, useSecurity, ct).ConfigureAwait(false) &&
subscriptions != null &&
Session != null)
{
Console.WriteLine($"Transferring {subscriptions.Count} subscriptions from old session to new session...");
success = await Session.TransferSubscriptionsAsync(
subscriptions,
true,
ct).ConfigureAwait(false);
if (success)
{
Console.WriteLine("Subscriptions transferred.");
}
}
return success;
}
/// <summary>
/// Creates a session with the UA server
/// </summary>
/// <exception cref="ObjectDisposedException"></exception>
/// <exception cref="ArgumentNullException"><paramref name="serverUrl"/> is <c>null</c>.</exception>
/// <exception cref="ServiceResultException"></exception>
public async Task<bool> ConnectAsync(
string serverUrl,
bool useSecurity = true,
CancellationToken ct = default)
{
if (m_disposed)
{
throw new ObjectDisposedException(nameof(UAClient));
}
if (serverUrl == null)
{
throw new ArgumentNullException(nameof(serverUrl));
}
try
{
if (Session != null && Session.Connected)
{
Console.WriteLine("Session already connected!");
}
else
{
ITransportWaitingConnection? connection = null;
EndpointDescription? endpointDescription = null;
if (m_reverseConnectManager != null)
{
Console.WriteLine($"Waiting for reverse connection to.... {serverUrl}");
do
{
using var cts = new CancellationTokenSource(30_000);
using var linkedCTS = CancellationTokenSource.CreateLinkedTokenSource(
ct,
cts.Token);
connection = await m_reverseConnectManager
.WaitForConnectionAsync(new Uri(serverUrl), null, linkedCTS.Token)
.ConfigureAwait(false) ??
throw new ServiceResultException(
StatusCodes.BadTimeout,
"Waiting for a reverse connection timed out.");
if (endpointDescription == null)
{
Console.WriteLine("Discover reverse connection endpoints....");
endpointDescription = await CoreClientUtils.SelectEndpointAsync(
m_configuration,
connection,
useSecurity,
m_telemetry,
ct
).ConfigureAwait(false);
connection = null;
}
} while (connection == null);
}
else
{
Console.WriteLine($"Connecting to... {serverUrl}");
endpointDescription = await CoreClientUtils.SelectEndpointAsync(
m_configuration,
serverUrl,
useSecurity,
m_telemetry,
ct).ConfigureAwait(false);
}
// Get the endpoint by connecting to server's discovery endpoint.
// Try to find the first endopint with security.
var endpointConfiguration = EndpointConfiguration.Create(m_configuration);
var endpoint = new ConfiguredEndpoint(
null,
endpointDescription!,
endpointConfiguration);
IUserIdentity userIdentity = await CreateUserIdentityAsync(
endpointDescription!,
ct).ConfigureAwait(false);
// Create the session factory. - we could take it as parameter or as member
var sessionFactory = new DefaultSessionFactory(m_telemetry);
// Create the session
ISession session = await sessionFactory
.CreateAsync(
m_configuration,
connection!,
endpoint,
connection == null,
false,
m_configuration.ApplicationName!,
SessionLifeTime,
userIdentity,
default,
ct)
.ConfigureAwait(false);
// Assign the created session
if (session != null && session.Connected)
{
Session = session;
// override keep alive interval
Session.KeepAliveInterval = KeepAliveInterval;
// support transfer
Session.DeleteSubscriptionsOnClose = false;
Session.TransferSubscriptionsOnReconnect = true;
// set up keep alive callback.
Session.KeepAlive += Session_KeepAlive;
// prepare a reconnect handler
m_reconnectHandler = new SessionReconnectHandler(
m_telemetry,
true,
ReconnectPeriodExponentialBackoff);
// Session created successfully.
Console.WriteLine($"New Session Created with SessionName = {Session.SessionName}");
}
}
return true;
}
catch (Exception ex)
{
// Log Error
Console.WriteLine($"Create Session Error : {ex.Message}");
return false;
}
}
private ValueTask<IUserIdentity> CreateUserIdentityAsync(
EndpointDescription endpointDescription,
CancellationToken ct)
{
return IdentityProvider.AcquireIdentityAsync(
endpointDescription,
m_configuration.CreateMessageContext(),
ct);
}
/// <summary>
/// Disconnects the session.
/// </summary>
/// <param name="leaveChannelOpen">Leaves the channel open.</param>
public async Task DisconnectAsync(bool leaveChannelOpen = false, CancellationToken ct = default)
{
try
{
if (Session != null)
{
Console.WriteLine("Disconnecting...");
lock (m_lock)
{
Session.KeepAlive -= Session_KeepAlive;
m_reconnectHandler?.Dispose();
m_reconnectHandler = null;
}
await Session.CloseAsync(!leaveChannelOpen, ct).ConfigureAwait(false);
if (leaveChannelOpen)
{
// detach the channel, so it doesn't get
// closed when the session is disposed.
#pragma warning disable CS0618 // sample legacy path
Session.DetachChannel();
#pragma warning restore CS0618
}
Session.Dispose();
// Transiently null after dispose; matches pre-NRT behavior.
Session = null!;
// Log Session Disconnected event
Console.WriteLine("Session Disconnected.");
}
else
{
Console.WriteLine("Session not created!");
}
}
catch (Exception ex)
{
// Log Error
m_logger.DisconnectError(ex);
}
}
/// <summary>
/// Handles a keep alive event from a session and triggers a reconnect if necessary.
/// </summary>
private void Session_KeepAlive(ISession session, KeepAliveEventArgs e)
{
try
{
// check for events from discarded sessions.
if (Session == null || !Session.Equals(session))
{
return;
}
// start reconnect sequence on communication error.
if (ServiceResult.IsBad(e.Status))
{
if (ReconnectPeriod <= 0)
{
m_logger.KeepAliveReconnectDisabled(e.Status);
return;
}
SessionReconnectHandler.ReconnectState state = m_reconnectHandler!
.BeginReconnect(
Session,
m_reverseConnectManager,
ReconnectPeriod,
Client_ReconnectComplete
);
if (state == SessionReconnectHandler.ReconnectState.Triggered)
{
m_logger.KeepAliveReconnectStatusWithPeriod(
e.Status,
state,
ReconnectPeriod);
}
else
{
m_logger.KeepAliveReconnectStatus(
e.Status,
state);
}
// cancel sending a new keep alive request, because reconnect is triggered.
e.CancelKeepAlive = true;
}
}
catch (Exception exception)
{
m_logger.ErrorInOnKeepAlive(exception);
}
}
/// <summary>
/// Called when the reconnect attempt was successful.
/// </summary>
private void Client_ReconnectComplete(object? sender, EventArgs e)
{
// ignore callbacks from discarded objects.
if (!ReferenceEquals(sender, m_reconnectHandler))
{
return;
}
lock (m_lock)
{
// if session recovered, Session property is null
if (m_reconnectHandler!.Session != null)
{
// ensure only a new instance is disposed
// after reactivate, the same session instance may be returned
if (!ReferenceEquals(Session, m_reconnectHandler.Session))
{
m_logger.ReconnectedToNewSession(m_reconnectHandler.Session.SessionId);
ISession session = Session;
Session = m_reconnectHandler.Session;
session?.Dispose();
}
else
{
m_logger.ReactivatedSession(m_reconnectHandler.Session.SessionId);
}
}
else
{
m_logger.ReconnectKeepAliveRecovered();
}
}
}
/// <summary>
/// Per-error accept callback invoked by the new
/// <see cref="ICertificateValidatorEx.AcceptError"/> hook every time
/// an untrusted certificate is received from the server. Returns
/// <see langword="true"/> to accept the error, <see langword="false"/>
/// to reject it.
/// </summary>
protected virtual bool AcceptCertificate(Certificate certificate, ServiceResult error)
{
// ****
// Implement a custom logic to decide if the certificate should be
// accepted or not. Return true to accept, false to reject.
// ***
m_logger.CertificateError(error);
bool certificateAccepted =
error.StatusCode == StatusCodes.BadCertificateUntrusted && AutoAccept;
if (certificateAccepted)
{
m_logger.UntrustedCertificateAccepted(certificate.Subject);
}
else
{
m_logger.UntrustedCertificateRejected(certificate.Subject);
}
return certificateAccepted;
}
private readonly Lock m_lock = new();
private readonly ReverseConnectManager? m_reverseConnectManager;
private readonly ApplicationConfiguration m_configuration;
private SessionReconnectHandler? m_reconnectHandler;
private readonly ILogger m_logger;
private readonly ITelemetryContext m_telemetry;
private bool m_disposed;
}
internal static partial class ClientCertificateLog
{
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ClientCertificate + 0, Level = LogLevel.Information,
Message = "Untrusted Certificate accepted. Subject = {Subject}")]
public static partial void UntrustedCertificateAccepted(this ILogger logger, string subject);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ClientCertificate + 1, Level = LogLevel.Information,
Message = "Untrusted Certificate rejected. Subject = {Subject}")]
public static partial void UntrustedCertificateRejected(this ILogger logger, string subject);
}
internal static partial class ClientReconnectLog
{
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ClientReconnect + 0, Level = LogLevel.Information,
Message = "KeepAlive status {StatusCode}, reconnect status {State}, reconnect period {ReconnectPeriod}ms.")]
public static partial void KeepAliveReconnectStatusWithPeriod(
this ILogger logger,
ServiceResult? statusCode,
SessionReconnectHandler.ReconnectState state,
int reconnectPeriod);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ClientReconnect + 1, Level = LogLevel.Information,
Message = "KeepAlive status {StatusCode}, reconnect status {State}.")]
public static partial void KeepAliveReconnectStatus(
this ILogger logger,
ServiceResult? statusCode,
SessionReconnectHandler.ReconnectState state);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ClientReconnect + 2, Level = LogLevel.Error,
Message = "Error in OnKeepAlive.")]
public static partial void ErrorInOnKeepAlive(this ILogger logger, Exception exception);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ClientReconnect + 3, Level = LogLevel.Information,
Message = "--- RECONNECTED TO NEW SESSION --- {SessionId}")]
public static partial void ReconnectedToNewSession(this ILogger logger, NodeId sessionId);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ClientReconnect + 4, Level = LogLevel.Information,
Message = "--- REACTIVATED SESSION --- {SessionId}")]
public static partial void ReactivatedSession(this ILogger logger, NodeId sessionId);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ClientReconnect + 5, Level = LogLevel.Information,
Message = "--- RECONNECT KeepAlive recovered ---")]
public static partial void ReconnectKeepAliveRecovered(this ILogger logger);
}
internal static partial class UAClientLog
{
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.UAClient + 0, Level = LogLevel.Error,
Message = "Disconnect Error")]
public static partial void DisconnectError(this ILogger logger, Exception ex);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.UAClient + 1, Level = LogLevel.Warning,
Message = "KeepAlive status {StatusCode}, but reconnect is disabled.")]
public static partial void KeepAliveReconnectDisabled(this ILogger logger, ServiceResult? statusCode);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.UAClient + 2, Level = LogLevel.Information,
Message = "{Error}")]
public static partial void CertificateError(this ILogger logger, ServiceResult error);
}
}
@@ -0,0 +1,3 @@
REM collect a trace using the EventSource provider OPC-UA-Core
dotnet tool install --global dotnet-trace
dotnet-trace collect --name consolereferenceclient --providers OPC-UA-Core,OPC-UA-Client
@@ -0,0 +1,159 @@
# 1. Ensure directories exist
$certDir = "./bin/pki/trustedUser/certs"
$privateDir = "./bin/pki/trustedUser/private"
$curves = @(
'nistP256',
'nistP384',
'brainpoolP256r1',
'brainpoolP384r1'
)
foreach ($d in @($certDir, $privateDir)) {
if (-not (Test-Path $d)) {
New-Item -ItemType Directory -Path $d -Force | Out-Null
}
}
function New-CertificateWithAKI {
param(
[System.Security.Cryptography.X509Certificates.X509Certificate2] $SourceCert,
[string] $HashAlgorithmName = 'SHA256'
)
$hashAlg = [System.Security.Cryptography.HashAlgorithmName]::new($HashAlgorithmName)
# Determine key type and create CertificateRequest
$ecdsa = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPrivateKey($SourceCert)
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($SourceCert)
if ($ecdsa) {
$req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
$SourceCert.SubjectName, $ecdsa, $hashAlg)
}
elseif ($rsa) {
$req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
$SourceCert.SubjectName, $rsa, $hashAlg,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
}
else {
throw "Unsupported key type"
}
# Copy all extensions from source cert
foreach ($ext in $SourceCert.Extensions) {
$req.CertificateExtensions.Add($ext)
}
# Build Authority Key Identifier from Subject Key Identifier
$skiExt = $SourceCert.Extensions | Where-Object {
$_ -is [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]
}
if ($skiExt) {
$hex = $skiExt.SubjectKeyIdentifier
$keyIdBytes = [byte[]]::new($hex.Length / 2)
for ($i = 0; $i -lt $keyIdBytes.Length; $i++) {
$keyIdBytes[$i] = [System.Convert]::ToByte($hex.Substring($i * 2, 2), 16)
}
# AKI ASN.1: SEQUENCE { [0] IMPLICIT keyIdentifier }
# 30 <seqLen> 80 <keyIdLen> <keyIdBytes>
[byte[]] $akiInner = @(0x80, $keyIdBytes.Length) + $keyIdBytes
[byte[]] $akiValue = @(0x30, $akiInner.Length) + $akiInner
$akiOid = [System.Security.Cryptography.Oid]::new('2.5.29.35', 'Authority Key Identifier')
$akiExt = [System.Security.Cryptography.X509Certificates.X509Extension]::new(
$akiOid, $akiValue, $false)
$req.CertificateExtensions.Add($akiExt)
}
$notBefore = [DateTimeOffset]::UtcNow.AddMonths(-1)
$notAfter = [DateTimeOffset]::UtcNow.AddYears(1)
return $req.CreateSelfSigned($notBefore, $notAfter)
}
# 2. Create a self-signed ECC certificate (NIST P-256)
# $cert = New-SelfSignedCertificate `
# -Subject "CN=iama.tester@example.com" `
# -CertStoreLocation "Cert:\CurrentUser\My" `
# -KeyExportPolicy Exportable `
# -KeySpec Signature `
# -KeyAlgorithm ECDSA_nistP256 `
# -Curve 'CurveName' `
# -HashAlgorithm SHA256 `
# -NotAfter (Get-Date).AddYears(1)
foreach ($curve in $curves) {
Write-Host "Generating certificate for curve: $curve"
$signatureAlgorithm = if ($curve -match 'P384') { 'SHA384' } else { 'SHA256' }
# OIDs for the extensions
# 2.5.29.14 = Subject Key Identifier
# 2.5.29.35 = Authority Key Identifier
# 2.5.29.19 = Basic Constraints (Subject Type=End Entity or CA)
$name = 'CN=iama.tester@example.com,O=' + $curve;
$params = @{
Type = 'Custom'
Subject = $name
TextExtension = @(
'2.5.29.37={text}1.3.6.1.5.5.7.3.2', # Enhanced Key Usage (Client Auth)
'2.5.29.17={text}upn=iama.tester@example.com', # SAN
'2.5.29.19={text}ca=0' # Basic Constraints: Not a CA
)
KeyUsage = @('DigitalSignature', 'NonRepudiation') # Added KeyCertSign for self-signed logic
KeyAlgorithm = "ECDSA_$curve"
CurveExport = 'CurveName'
HashAlgorithm = $signatureAlgorithm
CertStoreLocation = 'Cert:\CurrentUser\My'
NotBefore = (Get-Date).AddMonths(-1)
NotAfter = (Get-Date).AddYears(1)
}
# 1. Create cert and rebuild with Authority Key Identifier
$tempCert = New-SelfSignedCertificate @params
$cert = New-CertificateWithAKI -SourceCert $tempCert -HashAlgorithmName $signatureAlgorithm
Remove-Item "Cert:\CurrentUser\My\$($tempCert.Thumbprint)" -Force
Write-Host "Certificate generated with Thumbprint: $($cert.Thumbprint)"
# 2. Export DER
$derPath = Join-Path $certDir "iama.tester.$curve.der"
[System.IO.File]::WriteAllBytes($derPath, $cert.RawData)
# 3. Export PFX with password
$pfxPath = Join-Path $privateDir "iama.tester.$curve.pfx"
$pfxBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, 'password')
[System.IO.File]::WriteAllBytes($pfxPath, $pfxBytes)
Write-Host "Finished: $curve`n"
}
Write-Host "`n=== Generating RSA-2048 ==="
$rsaParams = @{
Type = 'Custom'
Subject = 'CN=iama.tester@example.com'
TextExtension = @(
'2.5.29.37={text}1.3.6.1.5.5.7.3.2'
'2.5.29.17={text}upn=iama.tester@example.com'
)
KeyUsage = @('DigitalSignature','DataEncipherment','NonRepudiation','KeyEncipherment', 'CertSign')
KeyAlgorithm = 'RSA'
KeyLength = 2048
CertStoreLocation = 'Cert:\CurrentUser\My'
NotBefore = (Get-Date).AddMonths(-1)
NotAfter = (Get-Date).AddYears(1)
}
$tempRsaCert = New-SelfSignedCertificate @rsaParams
$rsaCert = New-CertificateWithAKI -SourceCert $tempRsaCert -HashAlgorithmName 'SHA256'
Remove-Item "Cert:\CurrentUser\My\$($tempRsaCert.Thumbprint)" -Force
$derPath = Join-Path $certDir "iama.tester.rsa.der"
[System.IO.File]::WriteAllBytes($derPath, $rsaCert.RawData)
$pfxPath = Join-Path $privateDir "iama.tester.rsa.pfx"
$pfxBytes = $rsaCert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, 'password')
[System.IO.File]::WriteAllBytes($pfxPath, $pfxBytes)