mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
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:
@@ -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><ClassName>Log</c> class allocates its event ids relative to the
|
||||
/// offset constant below, using <c>offset + <zero-based message index></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)
|
||||
Reference in New Issue
Block a user