/* ======================================================================== * 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 { /// /// OPC UA Client with examples of basic functionality. /// public class UAClient : IUAClient, IDisposable { /// /// Initializes a new instance of the UAClient class. /// public UAClient( ApplicationConfiguration configuration, ITelemetryContext telemetry, Action? validateResponse) : this( configuration, null, telemetry, validateResponse) { } /// /// Initializes a new instance of the UAClient class for reverse connections. /// public UAClient( ApplicationConfiguration configuration, ReverseConnectManager? reverseConnectManager, ITelemetryContext telemetry, Action? validateResponse) { ValidateResponse = validateResponse; m_logger = telemetry.CreateLogger(); 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; } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Overridable Dispose pattern implementation. /// /// 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; } /// /// Action used /// internal Action? ValidateResponse { get; } /// /// Gets the client session. /// /// /// Initialized to null! as late-init: the value is assigned by /// before any caller observes it, and is /// transiently set to null! during reconnect/dispose. The /// non-nullable type matches the contract. /// public ISession Session { get; private set; } = null!; /// /// The session keepalive interval to be used in ms. /// public int KeepAliveInterval { get; set; } = 5000; /// /// The reconnect period to be used in ms. /// public int ReconnectPeriod { get; set; } = 1000; /// /// The reconnect period exponential backoff to be used in ms. /// public int ReconnectPeriodExponentialBackoff { get; set; } = 15000; /// /// The session lifetime. /// public uint SessionLifeTime { get; set; } = 60 * 1000; /// /// The user identity provider to use to connect to the server. /// public IClientIdentityProvider IdentityProvider { get; set; } = new AnonymousIdentityProvider(); /// /// Auto accept untrusted certificates. /// public bool AutoAccept { get; set; } /// /// The file to use for log output. /// public string? LogFile { get; set; } /// /// Do a Durable Subscription Transfer /// public async Task 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; } /// /// Creates a session with the UA server /// /// /// is null. /// public async Task 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) ?? throw NoMatchingEndpoint(useSecurity); connection = null; } } while (connection == null); } else { Console.WriteLine($"Connecting to... {serverUrl}"); endpointDescription = await CoreClientUtils.SelectEndpointAsync( m_configuration, serverUrl, useSecurity, m_telemetry, ct).ConfigureAwait(false) ?? throw NoMatchingEndpoint(useSecurity); } // 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; } } /// /// The error reported when discovery found no endpoint that satisfies /// the security requirement (SelectEndpoint returned null). /// private static ServiceResultException NoMatchingEndpoint(bool useSecurity) { return useSecurity ? new ServiceResultException( StatusCodes.BadSecurityPolicyRejected, "The server offers no secure endpoint supported by this client.") : new ServiceResultException( StatusCodes.BadNotFound, "The server offers no matching endpoint."); } private ValueTask CreateUserIdentityAsync( EndpointDescription endpointDescription, CancellationToken ct) { return IdentityProvider.AcquireIdentityAsync( endpointDescription, m_configuration.CreateMessageContext(), ct); } /// /// Disconnects the session. /// /// Leaves the channel open. 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); } } /// /// 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 (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); } } /// /// Called when the reconnect attempt was successful. /// 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(); } } } /// /// Per-error accept callback invoked by the new /// hook every time /// an untrusted certificate is received from the server. Returns /// to accept the error, /// to reject it. /// 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); } }