Files
UA-.NETStandard/Applications/ConsoleReferenceClient/Program.cs
T
Marc Schier 2966f693b2 2.0 Migration Analyzer and codefixer with Agent update (#3820)
Introduces `OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer` — a
single NuGet package that ships **three Roslyn components + a runtime
shim** to make the 1.5.378 → 2.0 upgrade as close to "install package,
run `dotnet format`, remove package" as possible:

| DLL | Where | Purpose |
|-----|-------|---------|
| `Opc.Ua.MigrationAnalyzer.dll` | `analyzers/dotnet/cs/` | 19 Roslyn
analyzers (`UA0001`–`UA0022`) covering every breaking-change pattern in
[`Docs/MigrationGuide.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/Docs/MigrationGuide.md).
Built against the stable Roslyn 4.14 analyzer API — loads cleanly in
csc.exe (no `Microsoft.CodeAnalysis.Workspaces` reference). |
| `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` | `analyzers/dotnet/cs/` | 14
companion `CodeFixProvider`s; loaded only by Workspaces-aware hosts
(Visual Studio, `dotnet format analyzers`). |
| `Opc.Ua.MigrationAnalyzer.Generator.dll` | `analyzers/dotnet/cs/` |
Roslyn `IIncrementalGenerator` that emits per-consumer `internal sealed
[Obsolete] class <Name>Collection : List<TElement>` shims for every
`<Type>Collection` wrapper the consumer references — including
**arbitrary model-compiled `<UserType>Collection` patterns**, not just
the 30 well-known built-in ones. |
| `Opc.Ua.MigrationAnalyzer.Core.dll` | `lib/<tfm>/` for `net472, net48,
netstandard2.1, net8.0, net9.0, net10.0` | 1.5.378 → 2.0 runtime
compatibility shim. Re-exposes the obsolete extension surface so
1.5.378-style call sites keep compiling against 2.0 with warnings
instead of errors. |

### Migration workflow the package enables

1. **Bump every `OPCFoundation.NetStandard.Opc.Ua.*` PackageReference to
`2.0.*-*`.**
2. **Add the migration package** as a build-only dependency:
   ```xml
<PackageReference
Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer"
                     Version="2.0.*-*"
                     PrivateAssets="all" />
   ```
3. **Run `dotnet build`.** The Core shim DLL re-supplies obsolete
extension members; the source generator covers `<Type>Collection`
references; what remains are `[Obsolete]` warnings + `UA00xx` analyzer
diagnostics + (occasionally) `MIG01` for unresolvable model-compiled
element types.
4. **Apply auto-fixes** in the IDE or from the command line:
   ```bash
   dotnet format analyzers <solution>.sln \
       --diagnostics UA0002 UA0003 UA0004 UA0005 UA0006 UA0007 UA0008 \
                     UA0009 UA0010 UA0012 UA0014 UA0019 UA0020 UA0022 \
       --severity warn
   ```
5. **Walk the residual** `UA0001` / `UA0011` / `UA0015` / `UA0018` /
`UA0021` diagnostics manually — they cover patterns that require human
judgement.
6. **Remove the migration package.** You are now on clean 2.0 with no
shim dependency.

### Source generator (`Opc.Ua.MigrationAnalyzer.Generator.dll`)

Per-consumer Roslyn `IIncrementalGenerator` that emits `.g.cs` files for
every `<Name>Collection` reference in the consumer's compilation that
fails to bind:

- **Catalog override (30 entries)** — pins element types that renamed
across the 1.5.378 → 2.0 boundary: `DateTime`→`DateTimeUtc`,
`Guid`→`Uuid`, `byte[]`→`ByteString`, `XmlElement`→`Opc.Ua.XmlElement`,
plus the primitives and built-in opc types whose element name didn't
rename.
- **Semantic lookup** — for model-compiled `<UserType>Collection`
patterns the model compiler historically emitted for any user-defined
complex type. Strips the `Collection` suffix, runs
`Compilation.GetSymbolsWithName` against the unique element short name,
emits the shim with the fully-qualified type reference.
- **`MIG01` diagnostic** — fires when the element type can't be uniquely
resolved (zero or multiple candidates). User adds the missing `using` or
migrates the site manually.

Each generated file emits a class shaped exactly like the 1.5.378
wrapper:

```csharp
[global::System.Obsolete("'Int32Collection' was removed in 2.0. Use 'List<int>' or 'ArrayOf<int>' instead. (UA0002)")]
internal sealed class Int32Collection : global::System.Collections.Generic.List<int>
{
    public Int32Collection() { }
    public Int32Collection(int capacity) : base(capacity) { }
    public Int32Collection(global::System.Collections.Generic.IEnumerable<int> collection) : base(collection) { }
    public static implicit operator global::Opc.Ua.ArrayOf<int>(Int32Collection? value)
        => value is null ? default : value.ToArrayOf();
}
```

**Design choice — `internal` (not `public`):** the generated shim never
leaks through the consumer's public API surface. Consumers with *public*
methods or properties returning a `<Type>Collection` will hit `CS0050:
Inconsistent accessibility` — the intended signal that the public
surface must migrate to `List<T>` / `ArrayOf<T>` first; internal call
sites keep compiling under the shim so iteration is incremental.

### 19-rule analyzer reference

| Rule | Replaces | Default | Auto-fix |
|------|----------|---------|----------|
| `UA0001` | `Utils.Trace` / `Utils.LogX` | Info | No (telemetry
plumbing requires manual review) |
| `UA0002` | Removed `<Type>Collection` wrappers | Warning | Yes (→
`List<T>` / `ArrayOf<T>`); the source generator keeps the call site
compiling in the meantime |
| `UA0003` | `x == null` on now-struct built-ins | Warning | Yes (→
`x.IsNull`) |
| `UA0004` | `?.` on now-struct built-ins | Warning | Yes (drop the `?`)
|
| `UA0005` | `byte[]` where `ByteString` is expected | Warning | Yes
(append `.ToByteString()`) |
| `UA0006` | `new Variant(object\|DateTime\|Guid\|byte[])` | Warning |
Yes (→ `Variant.From(...)`) |
| `UA0007` | `new NodeId(string)` / `new ExpandedNodeId(string)` |
Warning | Yes (→ `Parse`) |
| `UA0008` | `Session.Call(..., params object[])` | Warning | Yes (wrap
with `Variant.From`) |
| `UA0009` | `[DataContract]` / `[DataMember]` on config extensions |
Warning | Yes (→ `[DataType]` / `[DataTypeField]`) |
| `UA0010` | `using` / `Dispose` on `CertificateIdentifier` /
`UserIdentity` / `IUserIdentityTokenHandler` | Warning | Yes (drop the
disposable) |
| `UA0011` | Sync
`IUserIdentityTokenHandler.{Encrypt,Decrypt,Sign,Verify}` | Info | No
(async promotion required) |
| `UA0012` | `CertificateFactory.*` static helpers | Warning | Yes (→
instance methods) |
| `UA0014` | `DataValue.IsGood(dv)` static helper | Warning | Yes (→
`dv.IsGood`) |
| `UA0015` | Sync / APM members on GDS / LDS clients | Info | No (async
promotion required) |
| `UA0018` | `CertificateIdentifier.Certificate` getter | Info | No (→
`LoadCertificate2Async`) |
| `UA0019` | `new DataValue(StatusCode[, ts])` | Warning | Yes (→
object-initializer form) |
| `UA0020` | `EncodeableFactory.GlobalFactory` / `Create()` | Warning |
Yes (→ `ServiceMessageContext.Factory` / `Fork()`) |
| `UA0021` | `CertificateValidator` / `CertificateValidationEventArgs`
(structural rename) | Info | No (see migration guide §Certificate
validation) |
| `UA0022` | `ApplicationConfiguration.CertificateValidator` /
`ServerBase.CertificateValidator` property rename | Warning | Yes (→
`.CertificateManager`) |

Plus one generator-only diagnostic:

| Rule | Source | Default | Triggers |
|------|--------|---------|----------|
| `MIG01` | Generator | Warning | Generator can't uniquely resolve the
element type for a `<Foo>Collection` reference (zero or multiple
candidates). The user adds a `using` for the namespace defining `Foo`,
or migrates the call site manually. |

### Runtime shim coverage (`Opc.Ua.MigrationAnalyzer.Core.dll`)

C# 14 `extension` members so 1.5.378 call sites continue to compile:

- **Moved obsolete extensions** the 2.0 libraries no longer carry
inline: `NodeId` / `Variant` / `DataValue` null-check helpers, `Session`
sync helpers, `Subscription` sync helpers, `ApplicationInstance`
helpers, `ServerBase.Start` / `Stop`, `TransportChannel` APM,
`ChannelBase` static factory methods, etc.
- **New shims for genuinely-removed members**:
`EncodeableFactory.GlobalFactory`, `CertificateIdentifier.Certificate`
(throws), sync wrappers for
`IUserIdentityTokenHandler.{Encrypt,Decrypt,Sign,Verify}`, sync + APM
wrappers for the GDS / LDS client APIs, `GlobalDiscoverySampleServer`
1.5.378-shape ctor (in-tree `Libraries/Opc.Ua.Gds.Server.Common/`).

> ⚠ The sync shims wrap their `*Async` counterparts via
`Task.Run(…).GetAwaiter().GetResult()`. Intended as a **migration aid
only** — port to `await` before shipping.

### Repo layout

```
Tools/Opc.Ua.MigrationAnalyzer/                  # analyzer DLL
  Analyzers/UA00xxAnalyzer.cs                    # 19 DiagnosticAnalyzer impls
  Diagnostics/{DiagnosticIds,Descriptors,WellKnownProperties}.cs
  Helpers/{SymbolExtensions,UaSymbols}.cs
  Opc.Ua.MigrationAnalyzer.nuspec                # ships all 3 analyzer DLLs + 6-TFM Core
  OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props

Tools/Opc.Ua.MigrationAnalyzer.CodeFixer/        # code-fix DLL
  CodeFixes/UA00xxCodeFix.cs                     # 14 CodeFixProvider impls

Tools/Opc.Ua.MigrationAnalyzer.Generator/        # source generator DLL  (NEW)
  MigrationGenerator.cs                          # IIncrementalGenerator
  CollectionShimCatalog.cs                       # 30-entry element-type overrides
  Diagnostics/GeneratorDiagnostics.cs            # MIG01

Tools/Opc.Ua.MigrationAnalyzer.Core/             # runtime shim (multi-TFM)
  Client/, Configuration/, Core/, Gds.Client.Common/, Types/, Marker/

Tools/Roslyn.slnx                                # source-gen + migration projects

Tests/Opc.Ua.MigrationAnalyzer.Tests/            # 142 tests
  Analyzers/UA00xxTests.cs                       # 107 analyzer + code-fix tests
  Generators/MigrationGeneratorTests.cs          # 35 generator tests (NEW)
Tests/Opc.Ua.MigrationAnalyzer.Core.Tests/       # 13 runtime-shim tests
```

### Documentation

-
[`Docs/MigrationGuide.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/Docs/MigrationGuide.md)
— §3 Collection Type Migration gains a tip pointing at the generator so
CS0246 is replaced with `[Obsolete]` warnings + UA0002.
-
[`.github/agents/opcua-v20-migration.agent.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/.github/agents/opcua-v20-migration.agent.md)
— Strategy section mentions the three Roslyn components + runtime shim
explicitly, plus the MIG01 diagnostic and the "internal-by-design"
CS0050 hint.

### Central package pinning

`Directory.Packages.props` pins `Microsoft.CodeAnalysis.*` (Common,
CSharp, CSharp.Workspaces, Workspaces.Common) to **4.14.0** — the stable
analyzer API surface. The earlier 5.3 pin was the *csc-internal*
version, which is not safe for analyzer hosts.

### Dogfood story

The full 42-sample dogfood against
`OPCFoundation/UA-.NETStandard-Samples` surfaced a **critical
analyzer-loader bug** in an earlier iteration: the analyzer DLL
initially co-shipped its code-fix providers in one assembly, which
transitively referenced `Microsoft.CodeAnalysis.Workspaces.dll`.
csc.exe's analyzer host ships only `Microsoft.CodeAnalysis.dll` +
`CSharp.dll` in its bincore and **silently swallows** load failures —
net result: zero diagnostics across all samples. Fix landed in commit
`861fa6ee1`:

1. **Split** the assembly: analyzer in `Opc.Ua.MigrationAnalyzer.dll`
(Workspaces-free, csc-safe), code-fixes in
`Opc.Ua.MigrationAnalyzer.CodeFixer.dll` (Workspaces-aware, IDE-only).
2. **Downgrade** to Roslyn 4.14 — the stable analyzer API.
3. `/p:ReportAnalyzer=true` confirms all 19 analyzers initialise and
execute on real consumer code.

This PR's new generator was smoke-tested against a minimal consumer that
exercises all 30 catalog entries + a model-compiled element type + the
unresolvable case:
- 30 catalog patterns + model-compiled `MyThingCollection` → all emit
shims, fire CS0618 with `(UA0002)` marker
- Element-type renames verified: `DateTimeCollection` → `DateTimeUtc`,
`ByteStringCollection` → `ByteString`, `GuidCollection` → `Uuid`, etc.
- `NeverHeardOfItCollection` → `MIG01` fires with help link to the
migration guide

Known compatibility gaps (documented in the migration agent):
- Legacy WinForms `.Net4` sample projects use pre-SDK MSBuild XML and
**ignore** `Directory.Build.targets` `<PackageReference>` injection —
consumers in this format must add the migration package to their csproj
inline.

### Packaging design notes

- Hand-authored nuspec ships 3 DLLs in `analyzers/dotnet/cs/` + 6 TFMs
of Core (shim) DLLs under `lib/`. All 6 OPC UA runtime package
dependencies are declared per-TFM-group.
- `build/OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props` is
auto-imported by NuGet (file name must match package id exactly) and
injects all three analyzer DLLs as `<Analyzer>` items.
- `DiagnosticIds.cs` / `DiagnosticDescriptors.cs` /
`WellKnownProperties.cs` / `Helpers/*.cs` are **shared via `<Compile
Link=…>`** between the analyzer and code-fixer projects to break what
would otherwise be a NuGet restore cycle. The generator has no compile
dependency on the analyzer; the 30-entry override table is duplicated in
`CollectionShimCatalog.cs` (the analyzer table also drives diagnostic
messages, so the duplication is small and intentional).
- `Opc.Ua.MigrationAnalyzer.Core` is multi-TFM (`net472, net48,
netstandard2.1, net8.0, net9.0, net10.0`) and **non-packable on its
own**; the parent migration package's nuspec consumes each TFM's DLL by
path.
2026-06-05 12:01:53 +02:00

867 lines
42 KiB
C#

/* ========================================================================
* 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;
// TODO: remove this in 2.0 migration; use telemetry.ConfigureLogging below.
#pragma warning disable CS0618 // Type or member is obsolete
config.TraceConfiguration.ApplySettings();
#pragma warning restore CS0618 // Type or member is obsolete
}
// 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));
reverseConnectManager.StartService(config);
}
// 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 = new ComplexTypeSystem(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
{
reverseConnectManager?.Dispose();
}
});
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);
}
}
}
}