/* ======================================================================== * 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 { /// /// Wraps connect testing functionality /// public sealed class ConnectTester : IAsyncDisposable { public ConnectTester( ITelemetryContext telemetry, ManualResetEvent? quitEvent = null) { m_quitEvent = quitEvent; m_telemetry = telemetry; m_logger = telemetry.CreateLogger(); } /// public async ValueTask DisposeAsync() { m_reconnectHandler?.Dispose(); ISession? session = m_wrapper?.Session; if (session != null) { await session.DisposeAsync().ConfigureAwait(false); } GC.SuppressFinalize(this); } /// /// Run the tests until cancelled or quit /// /// /// /// 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 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 { 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 { 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 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 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 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 CreateUserIdentityAsync( IClientIdentityProvider provider, EndpointDescription endpointDescription, CancellationToken ct) { return provider.AcquireIdentityAsync( endpointDescription, m_configuration.CreateMessageContext(), ct); } private async Task 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> 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; } /// /// Handles a keep alive event from a session and triggers a reconnect if necessary. /// 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); } }