/* ======================================================================== * 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 { /// /// The program. /// public static class Program { /// /// Main entry point. /// /// public static Task 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("--autoaccept", "-a") { Description = "auto accept certificates (for testing only)" }; var noSecurityOption = new Option("--nosecurity", "--nsec") { Description = "select endpoint with security NONE, least secure if unavailable" }; var usernameOption = new Option("--username", "--un") { Description = "the name of the user identity for the connection" }; var userPasswordOption = new Option("--userpassword", "--up") { Description = "the password of the user identity for the connection" }; var userCertificateOption = new Option("--usercertificate", "--uc") { Description = "the thumbprint of the user certificate for the user identity" }; var userCertificatePasswordOption = new Option("--usercertificatepassword", "--ucp") { Description = "the password of the user certificate for the user identity" }; var consoleOption = new Option("--console", "-c") { Description = "log to console" }; var logOption = new Option("--log", "-l") { Description = "log app output" }; var fileOption = new Option("--file", "-f") { Description = "log to file" }; var passwordOption = new Option("--password", "-p") { Description = "optional password for private key" }; var renewOption = new Option("--renew", "-r") { Description = "renew application certificate" }; var timeoutOption = new Option("--timeout", "-t") { Description = "timeout in seconds to exit application", DefaultValueFactory = _ => Timeout.Infinite }; var logFileOption = new Option("--logfile") { Description = "custom file name for log output" }; var loadTypesOption = new Option("--loadtypes", "--lt") { Description = "Load custom types" }; var managedBrowseAllOption = new Option("--managedbrowseall", "-m") { Description = "Browse all references using the MangedBrowseAsync method" }; var browseAllOption = new Option("--browseall", "-b") { Description = "Browse all references" }; var exportOption = new Option("--export", "-e") { Description = "Export all fetched nodes into Nodeset2 xml per default" }; var exportNamespaceOption = new Option("--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("--fetchall", "--fa") { Description = "Fetch all nodes" }; var jsonOption = new Option("--json", "-j") { Description = "Output all Values as JSON" }; var verboseOption = new Option("--verbose", "-v") { Description = "Verbose output" }; var subscribeOption = new Option("--subscribe", "-s") { Description = "Subscribe" }; var testallEndpointsOption = new Option("--testall", "--ea") { Description = "Test All Endpoints" }; var reverseConnectOption = new Option("--reverseconnect", "--rc") { Description = "Connect using the reverse connect endpoint. (e.g. --rc opc.tcp://localhost:65300)" }; var foreverOption = new Option("--forever") { Description = "run inner connect/disconnect loop forever" }; var leakChannelsOption = new Option("--leakchannels") { Description = "Leave a channel leak open when disconnecting a session." }; var durableSubscriptionOption = new Option("--durablesubscription", "--ds") { Description = "SetDurableSubscription example" }; var historianOption = new Option("--historian") { Description = "Run the complete OPC UA Historical Access client workflow" }; var serverUrlArgument = new Argument("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, historianOption, 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? exportNamespaces = exportNamespacesRaw is { Length: > 0 } ? new HashSet(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); bool runHistorian = parseResult.GetValue(historianOption); // 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(); // 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) { using var complexTypeSystem = ComplexTypeSystem.Create(uaClient.Session, telemetry); await samples.LoadTypeSystemAsync(complexTypeSystem, ct).ConfigureAwait(false); } if (runHistorian) { await HistorianClientSample.RunAsync( uaClient.Session, ct).ConfigureAwait(false); quit = true; } else if (browseall || fetchall || jsonvalues || managedbrowseall) { List? variableIds = null; List? variableIdsManagedBrowse = null; ArrayOf referenceDescriptions = default; ArrayOf 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? 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 allValues, ArrayOf 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(); if (fetchall) { variables.AddRange( allNodes! .Where(r => r.NodeClass == NodeClass.Variable && r.NodeId.NamespaceIndex > 1 ) .Cast() .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() ); } 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.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.Error.WriteLine(ex.Message); throw; } 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); } /// /// returns a CertificateIdentifier of the Certificate with the specified thumbprint /// if it is found in the trustedUserCertificates TrustList /// /// the thumbprint of the certificate to select /// the trustlist of the user certificates /// the cancellation token /// Certificate Identifier private static async Task 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; } /// /// A dialog which asks for user input. /// public class ApplicationMessageDlg : IApplicationMessageDlg { private readonly TextWriter m_output; private string m_message = string.Empty; private bool m_ask; /// /// Initializes a message dialog that writes to the supplied writer or standard output. /// public ApplicationMessageDlg(TextWriter? output = null) { m_output = output ?? Console.Out; } /// /// Sets the message and whether displaying it should request confirmation. /// public override void Message(string text, bool ask) { m_message = text; m_ask = ask; } /// /// Displays the message and returns the confirmation response, defaulting to acceptance. /// public override async Task 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); } } } }