mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 22:55:49 +08:00
Allow passing an existing config XML to the client DI AddClient (#4328)
# Description > **Stacked PR**: based on `romanett/server-di-config-xml-e30c4f` (#4325) so the diff shows only the client changes. Once #4325 merges this will be rebased onto `master`. Gives the **client** DI surface the same migration path for existing configuration XML files that #4325 gave the server: applications that already own a classic `MyClient.Config.xml` can pass it to `AddClient` and keep **all** of their settings. New API surface: - `AddClient(string configurationFile, Action<OpcUaClientOptions>? configure = null)` loads the `ApplicationConfiguration` from the file; the optional callback configures the remaining client options (session endpoint, identity, reverse connect, ...). - `AddClient(Stream configurationStream, ...)` for documents that are not files on disk, e.g. embedded resources. The stream is read once and disposed after loading. - `OpcUaClientOptions.ConfigurationFile` / `ConfigurationStream` back the overloads, so the options-callback route works too and the file path can be bound from configuration (`OpcUa:Client:ConfigurationFile`). - `OpcUaClientOptions.ConfigureLoadedConfiguration` runs after the document is loaded and validated but before the application-instance certificate check, for code-side overrides of individual file settings. Mechanism and behavior: - A new internal `ClientSuppliedConfigurationProvider` implements `IOpcUaApplicationConfigurationProvider` and plugs into the client's existing configuration-provider seam. The document loads **lazily on first use** — the first session connect, reverse-connect startup, or an explicit `GetAsync` — through `ApplicationInstance.LoadApplicationConfigurationAsync`, so every setting (security configuration, certificate stores, transport quotas, client configuration) applies exactly as on the classic path, including localhost fix-up. - The load **ensures the application-instance certificate** (mirroring the shared `ConfigureApplication(...)` provider), which the pre-existing manual workaround — pre-loading and assigning `OpcUaClientOptions.Configuration` — never received. DI-registered `ICertificateManager` / `ICertificatePasswordProvider` are honored. Single-flight loading; `IAsyncDisposable` like the shared provider. - The supplied document is authoritative and takes precedence over a shared application registered with `ConfigureApplication(...)`. Ambiguous combinations are rejected eagerly at `AddClient` with clear `InvalidOperationException`s: file + stream, document + explicit `Configuration`, document + application identity properties, and a white-space path arriving through configuration binding. The start-time `IValidateOptions` validator accepts a supplied document as a configuration source. - `ConnectManagedSessionAsync` publishes the lazily loaded configuration onto the resolved options after `GetAsync`, so the channel manager and session builder see it exactly as on the other configuration paths; reverse connect consumes the provider through its existing async-startup path unchanged. Coverage: new `ClientConfigurationFileTests` fixture (8 tests) — loading from file and stream with assertions on the document settings, the override callback, the certificate bootstrap, provider caching and lazy semantics, precedence over `ConfigureApplication`, load failure for a missing file, argument validation, ambiguous-combination rejection, configuration-section binding, and validator acceptance. All 323 existing ClientBuilder tests and all 180 server hosting tests (including the combined client+server fixtures) pass; `Opc.Ua.Client` builds warning-free on net472, net8.0, and net10.0 (AOT analyzers). Docs: new "Client: using an existing configuration XML file" section in `docs/DependencyInjection.md` with cross-links from the server section, an updated pointer in the 2.0 migration guide, and a NuGet README mention. ## Related Issues - Fixes #4327 - Stacked on #4325 (server-side counterpart) ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Fable 5
copilot-swe-agent[bot]
marcschier
parent
e61ad4b306
commit
70c8ce4649
@@ -473,7 +473,9 @@ builder.Services
|
||||
The certificate manager and certificate password provider registered in
|
||||
dependency injection are honored the same way as on the options path,
|
||||
and the unmatched user-token-policy startup warning is derived from the
|
||||
policies advertised by the file.
|
||||
policies advertised by the file. The client offers the same migration
|
||||
path — see
|
||||
[Client: using an existing configuration XML file](#client-using-an-existing-configuration-xml-file).
|
||||
|
||||
### Server security and resource controls
|
||||
|
||||
@@ -894,7 +896,51 @@ var managedSessions = sp.GetRequiredService<IManagedSessionFactory>();
|
||||
ManagedSession dynamicSession = await managedSessions.ConnectAsync(endpoint, ct);
|
||||
```
|
||||
|
||||
Misconfiguration is validated through `IValidateOptions<OpcUaClientOptions>` when the host starts and again before a DI-created session connects. Supply either an explicit `Configuration`, the application identity fields directly on `OpcUaClientOptions` (as shown above), or the shared `ConfigureApplication(...)` options for a combined client/server host (see [Shared application configuration](#shared-application-configuration)). `Session.Endpoint` is required by the cached fixed-endpoint delegate; `IManagedSessionFactory.ConnectAsync(endpoint, ...)` supplies it at runtime.
|
||||
Misconfiguration is validated through `IValidateOptions<OpcUaClientOptions>` when the host starts and again before a DI-created session connects. Supply either an explicit `Configuration`, the application identity fields directly on `OpcUaClientOptions` (as shown above), an existing configuration XML document (`ConfigurationFile` / `ConfigurationStream`, see below), or the shared `ConfigureApplication(...)` options for a combined client/server host (see [Shared application configuration](#shared-application-configuration)). `Session.Endpoint` is required by the cached fixed-endpoint delegate; `IManagedSessionFactory.ConnectAsync(endpoint, ...)` supplies it at runtime.
|
||||
|
||||
### Client: using an existing configuration XML file
|
||||
|
||||
The client offers the same migration path as
|
||||
[the server](#migrating-with-an-existing-configuration-xml-file):
|
||||
applications that already own a classic client configuration file
|
||||
(`*.Config.xml`) can pass it to `AddClient` and keep every setting in it
|
||||
— security configuration, certificate stores, transport quotas, client
|
||||
configuration:
|
||||
|
||||
```csharp
|
||||
services
|
||||
.AddOpcUa()
|
||||
.AddClient("MyClient.Config.xml", opt =>
|
||||
{
|
||||
opt.Session = new ManagedSessionOptions { Endpoint = endpoint };
|
||||
});
|
||||
```
|
||||
|
||||
The same file can be referenced from configuration
|
||||
(`OpcUa:Client:ConfigurationFile`), and a stream works for documents
|
||||
that are not files on disk, e.g. embedded resources
|
||||
(`OpcUaClientOptions.ConfigurationStream`; read once and disposed after
|
||||
loading):
|
||||
|
||||
```csharp
|
||||
Stream stream = typeof(Program).Assembly
|
||||
.GetManifestResourceStream("MyApp.MyClient.Config.xml")!;
|
||||
services.AddOpcUa().AddClient(stream);
|
||||
```
|
||||
|
||||
The document loads lazily through
|
||||
`ApplicationInstance.LoadApplicationConfigurationAsync` on first use —
|
||||
the first session connect, reverse-connect startup, or an explicit
|
||||
`GetAsync` on `OpcUaClientOptions.ConfigurationProvider` — which also
|
||||
runs the optional `ConfigureLoadedConfiguration` override callback and
|
||||
ensures the application-instance certificate, exactly like the shared
|
||||
`ConfigureApplication(...)` path. The supplied document is
|
||||
authoritative: it takes precedence over a shared application registered
|
||||
with `ConfigureApplication(...)`, and combining it with an explicit
|
||||
`Configuration`, with the application identity properties, or setting
|
||||
both file and stream is rejected with a clear
|
||||
`InvalidOperationException` at registration. DI-registered
|
||||
`ICertificateManager` / `ICertificatePasswordProvider` are honored.
|
||||
|
||||
### Fluent shortcuts
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Because **Data Contract serialization** is not AOT compliant and does not suppor
|
||||
All configuration DTO classes (`ApplicationConfiguration`, `ServerConfiguration`, `TraceConfiguration`, `TransportConfiguration`, `ServerSecurityPolicy`, `OAuth2ServerSettings`, `OAuth2Credential`, `GlobalDiscoveryServerConfiguration`, `CertificateGroupConfiguration`, `BrowserOptions`, etc.) migrated from `[DataContract]`/`[DataMember]` to source-generated `[DataType]`/`[DataTypeField]` attributes and are now `partial` classes.
|
||||
|
||||
- `ApplicationConfiguration.LoadWithNoValidation` uses `XmlParser`/`IEncodeable.Decode()`. Existing XML config files should remain loadable.
|
||||
Server applications moving to the hosted dependency-injection surface can pass such a file directly to `services.AddOpcUa().AddServer("MyServer.Config.xml")` and keep every setting from it — see [Migrating with an existing configuration XML file](../../DependencyInjection.md#migrating-with-an-existing-configuration-xml-file).
|
||||
Applications moving to the dependency-injection surface can pass such a file directly to `services.AddOpcUa().AddServer("MyServer.Config.xml")` or `.AddClient("MyClient.Config.xml")` and keep every setting from it — see [Migrating with an existing configuration XML file](../../DependencyInjection.md#migrating-with-an-existing-configuration-xml-file) and [Client: using an existing configuration XML file](../../DependencyInjection.md#client-using-an-existing-configuration-xml-file).
|
||||
- Browser and session state persistence switched from XML to OPC UA Binary encoding. **Old persisted files cannot be loaded** — delete and re-save.
|
||||
- `SecuredApplication` uses `SecuredApplicationEncoding` helpers instead of `DataContractSerializer`.
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
|
||||
namespace Opc.Ua.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="IOpcUaApplicationConfigurationProvider"/> backed by an
|
||||
/// existing OPC UA application configuration XML document supplied via
|
||||
/// <see cref="OpcUaClientOptions.ConfigurationFile"/> or
|
||||
/// <see cref="OpcUaClientOptions.ConfigurationStream"/>. The document
|
||||
/// is loaded lazily on the first <see cref="GetAsync"/>: it is read
|
||||
/// through <c>ApplicationInstance.LoadApplicationConfigurationAsync</c>
|
||||
/// so every setting applies exactly as on the classic path, the
|
||||
/// optional <see cref="OpcUaClientOptions.ConfigureLoadedConfiguration"/>
|
||||
/// override callback runs, and the application-instance certificate is
|
||||
/// ensured — mirroring how the shared
|
||||
/// <c>OpcUaApplicationConfigurationProvider</c> completes client
|
||||
/// configurations. A supplied stream is read once and disposed.
|
||||
/// </summary>
|
||||
internal sealed class ClientSuppliedConfigurationProvider :
|
||||
IOpcUaApplicationConfigurationProvider
|
||||
{
|
||||
public ClientSuppliedConfigurationProvider(
|
||||
string? configurationFile,
|
||||
Stream? configurationStream,
|
||||
Action<ApplicationConfiguration>? configureLoadedConfiguration,
|
||||
IApplicationInstanceFactory applicationFactory,
|
||||
ITelemetryContext telemetry,
|
||||
ICertificateManager? certificateManager = null,
|
||||
ICertificatePasswordProvider? certificatePasswordProvider = null)
|
||||
{
|
||||
if (applicationFactory is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(applicationFactory));
|
||||
}
|
||||
if (telemetry is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(telemetry));
|
||||
}
|
||||
if (string.IsNullOrEmpty(configurationFile) && configurationStream == null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Either a configuration file path or a configuration stream is required.",
|
||||
nameof(configurationFile));
|
||||
}
|
||||
|
||||
m_configurationFile = configurationFile;
|
||||
m_configurationStream = configurationStream;
|
||||
m_configureLoadedConfiguration = configureLoadedConfiguration;
|
||||
m_certificateManager = certificateManager;
|
||||
|
||||
Application = applicationFactory.Create(telemetry);
|
||||
Application.ApplicationType = ApplicationType.Client;
|
||||
Application.CertificatePasswordProvider = certificatePasswordProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IApplicationInstance Application { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// The supplied document loads lazily, so the configuration is only
|
||||
/// available after the first <see cref="GetAsync"/> has completed.
|
||||
/// </remarks>
|
||||
public ApplicationConfiguration Configuration =>
|
||||
Volatile.Read(ref m_configuration) ??
|
||||
throw new InvalidOperationException(
|
||||
"The supplied configuration document has not been loaded yet. " +
|
||||
"Await GetAsync (e.g. by connecting a session) first.");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ApplicationConfiguration> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (Volatile.Read(ref m_disposed) != 0)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(ClientSuppliedConfigurationProvider));
|
||||
}
|
||||
|
||||
Task<ApplicationConfiguration> loadTask;
|
||||
lock (m_lock)
|
||||
{
|
||||
if (m_disposed != 0)
|
||||
{
|
||||
throw new ObjectDisposedException(
|
||||
nameof(ClientSuppliedConfigurationProvider));
|
||||
}
|
||||
m_loadTask ??= LoadAsync();
|
||||
loadTask = m_loadTask;
|
||||
}
|
||||
|
||||
await ((Task)loadTask).WaitAsync(ct).ConfigureAwait(false);
|
||||
return await loadTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref m_disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Task<ApplicationConfiguration>? loadTask;
|
||||
lock (m_lock)
|
||||
{
|
||||
loadTask = m_loadTask;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (loadTask != null)
|
||||
{
|
||||
// Wait for a load in flight so the application is not
|
||||
// disposed under it; a failed load has already surfaced
|
||||
// to the GetAsync caller and must not fail disposal too.
|
||||
await loadTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (loadTask == null)
|
||||
{
|
||||
// Never consumed: the supplied stream is owned by this
|
||||
// provider and would otherwise leak.
|
||||
m_configurationStream?.Dispose();
|
||||
}
|
||||
await Application.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApplicationConfiguration> LoadAsync()
|
||||
{
|
||||
ApplicationConfiguration configuration;
|
||||
if (m_configurationStream is { } configurationStream)
|
||||
{
|
||||
using (configurationStream)
|
||||
{
|
||||
configuration = await Application
|
||||
.LoadApplicationConfigurationAsync(
|
||||
configurationStream,
|
||||
silent: false,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
configuration = await Application
|
||||
.LoadApplicationConfigurationAsync(
|
||||
m_configurationFile!,
|
||||
silent: false,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Application.ApplicationName = configuration.ApplicationName;
|
||||
if (m_certificateManager != null)
|
||||
{
|
||||
configuration.CertificateManager = m_certificateManager;
|
||||
}
|
||||
m_configureLoadedConfiguration?.Invoke(configuration);
|
||||
|
||||
bool haveCertificate = m_certificateManager is null or CertificateManager
|
||||
? await Application
|
||||
.CheckApplicationInstanceCertificatesAsync(
|
||||
silent: true,
|
||||
CertificateFactory.DefaultLifeTime,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false)
|
||||
: await HasApplicationCertificateAsync(
|
||||
m_certificateManager,
|
||||
configuration,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
if (!haveCertificate)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Application instance certificate invalid.");
|
||||
}
|
||||
|
||||
Volatile.Write(ref m_configuration, configuration);
|
||||
return configuration;
|
||||
}
|
||||
|
||||
private static async Task<bool> HasApplicationCertificateAsync(
|
||||
ICertificateManager certificateManager,
|
||||
ApplicationConfiguration configuration,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await certificateManager.UpdateAsync(
|
||||
configuration.SecurityConfiguration,
|
||||
configuration.ApplicationUri,
|
||||
ct).ConfigureAwait(false);
|
||||
using CertificateEntryCollection certificates =
|
||||
certificateManager.SnapshotApplicationCertificates();
|
||||
return certificates.Count > 0;
|
||||
}
|
||||
|
||||
private readonly string? m_configurationFile;
|
||||
private readonly Stream? m_configurationStream;
|
||||
private readonly Action<ApplicationConfiguration>? m_configureLoadedConfiguration;
|
||||
private readonly ICertificateManager? m_certificateManager;
|
||||
private readonly Lock m_lock = new();
|
||||
private Task<ApplicationConfiguration>? m_loadTask;
|
||||
private ApplicationConfiguration? m_configuration;
|
||||
private int m_disposed;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -117,6 +118,126 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
return new OpcUaClientBuilder(builder.Services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers OPC UA client services whose
|
||||
/// <see cref="ApplicationConfiguration"/> is loaded from an existing
|
||||
/// OPC UA XML configuration file (e.g. <c>MyClient.Config.xml</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the migration path for applications that already own a
|
||||
/// configuration file: every setting in the file (security
|
||||
/// configuration, certificate stores, transport quotas, client
|
||||
/// configuration, ...) is applied as-is, exactly as when the file is
|
||||
/// loaded through
|
||||
/// <c>ApplicationInstance.LoadApplicationConfigurationAsync</c>. The
|
||||
/// document is loaded and the application-instance certificate is
|
||||
/// ensured on first use (first session connect or reverse-connect
|
||||
/// startup). Identity providers, reverse connect, and the other
|
||||
/// <see cref="IOpcUaClientBuilder"/> registrations compose with the
|
||||
/// loaded file the same way they compose with built configurations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Equivalent to
|
||||
/// <see cref="AddClient(IOpcUaBuilder, Action{OpcUaClientOptions})"/>
|
||||
/// with <see cref="OpcUaClientOptions.ConfigurationFile"/> set. Use
|
||||
/// <paramref name="configure"/> to set the session endpoint and the
|
||||
/// other client options, and
|
||||
/// <see cref="OpcUaClientOptions.ConfigureLoadedConfiguration"/>
|
||||
/// within it to override individual settings of the loaded file from
|
||||
/// code.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The OPC UA builder.</param>
|
||||
/// <param name="configurationFile">Path to the application
|
||||
/// configuration XML file. A relative path is resolved against the
|
||||
/// current working directory.</param>
|
||||
/// <param name="configure">Optional configuration delegate for the
|
||||
/// remaining <see cref="OpcUaClientOptions"/> (session, identity,
|
||||
/// reverse connect, ...). The application identity properties and an
|
||||
/// explicit <see cref="OpcUaClientOptions.Configuration"/> must not
|
||||
/// be combined with the configuration file.</param>
|
||||
/// <returns>An <see cref="IOpcUaClientBuilder"/> for chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
|
||||
/// or <paramref name="configurationFile"/> is <c>null</c>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="configurationFile"/>
|
||||
/// is empty or white space.</exception>
|
||||
/// <exception cref="InvalidOperationException">The configuration file
|
||||
/// is combined with an explicit
|
||||
/// <see cref="OpcUaClientOptions.Configuration"/>, a
|
||||
/// <see cref="OpcUaClientOptions.ConfigurationStream"/>, or client
|
||||
/// application identity options.</exception>
|
||||
public static IOpcUaClientBuilder AddClient(
|
||||
this IOpcUaBuilder builder,
|
||||
string configurationFile,
|
||||
Action<OpcUaClientOptions>? configure = null)
|
||||
{
|
||||
if (configurationFile is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configurationFile));
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(configurationFile))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The configuration file path must not be empty.",
|
||||
nameof(configurationFile));
|
||||
}
|
||||
|
||||
return builder.AddClient(options =>
|
||||
{
|
||||
options.ConfigurationFile = configurationFile;
|
||||
configure?.Invoke(options);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers OPC UA client services whose
|
||||
/// <see cref="ApplicationConfiguration"/> is loaded from a stream
|
||||
/// containing an OPC UA XML configuration document, e.g. an embedded
|
||||
/// resource.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// See <see cref="AddClient(IOpcUaBuilder, string, Action{OpcUaClientOptions})"/>
|
||||
/// for how the loaded configuration is applied. Equivalent to
|
||||
/// <see cref="AddClient(IOpcUaBuilder, Action{OpcUaClientOptions})"/>
|
||||
/// with <see cref="OpcUaClientOptions.ConfigurationStream"/> set.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The stream must remain open until the configuration is first
|
||||
/// used; it is read once and disposed after loading.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The OPC UA builder.</param>
|
||||
/// <param name="configurationStream">Stream containing the
|
||||
/// application configuration XML document.</param>
|
||||
/// <param name="configure">Optional configuration delegate for the
|
||||
/// remaining <see cref="OpcUaClientOptions"/>.</param>
|
||||
/// <returns>An <see cref="IOpcUaClientBuilder"/> for chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
|
||||
/// or <paramref name="configurationStream"/> is <c>null</c>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The configuration
|
||||
/// stream is combined with an explicit
|
||||
/// <see cref="OpcUaClientOptions.Configuration"/>, a
|
||||
/// <see cref="OpcUaClientOptions.ConfigurationFile"/>, or client
|
||||
/// application identity options.</exception>
|
||||
public static IOpcUaClientBuilder AddClient(
|
||||
this IOpcUaBuilder builder,
|
||||
Stream configurationStream,
|
||||
Action<OpcUaClientOptions>? configure = null)
|
||||
{
|
||||
if (configurationStream is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configurationStream));
|
||||
}
|
||||
|
||||
return builder.AddClient(options =>
|
||||
{
|
||||
options.ConfigurationStream = configurationStream;
|
||||
configure?.Invoke(options);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers OPC UA client services with options bound from the
|
||||
/// supplied <paramref name="configuration"/> section
|
||||
@@ -874,7 +995,13 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
OpcUaClientOptions options = sp.GetRequiredService<OpcUaClientOptions>();
|
||||
if (options.ConfigurationProvider != null)
|
||||
{
|
||||
await options.ConfigurationProvider.GetAsync(ct).ConfigureAwait(false);
|
||||
// Completes validation and application-instance certificate
|
||||
// setup; a lazily loaded supplied configuration document
|
||||
// (ConfigurationFile / ConfigurationStream) also becomes
|
||||
// available here, so publish it on the resolved options.
|
||||
ApplicationConfiguration providedConfiguration = await options
|
||||
.ConfigurationProvider.GetAsync(ct).ConfigureAwait(false);
|
||||
options.Configuration ??= providedConfiguration;
|
||||
}
|
||||
ValidateClientOptions(options, sessionOptions);
|
||||
ITelemetryContext telemetry = sp.GetRequiredService<ITelemetryContext>();
|
||||
@@ -899,7 +1026,7 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
IServiceCollection services,
|
||||
OpcUaClientOptions options)
|
||||
{
|
||||
if (options.Configuration == null)
|
||||
if (options.Configuration == null && !options.HasSuppliedConfigurationDocument)
|
||||
{
|
||||
services.TryAddEnumerable(
|
||||
ServiceDescriptor.Singleton<
|
||||
@@ -907,16 +1034,48 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
OpcUaClientApplicationConfigurationFeature>());
|
||||
}
|
||||
|
||||
if (options.HasSuppliedConfigurationDocument)
|
||||
{
|
||||
// Capture the document source eagerly: the provider factory
|
||||
// must not resolve OpcUaClientOptions, whose own factory
|
||||
// resolves this provider.
|
||||
string? configurationFile = options.ConfigurationFile;
|
||||
Stream? configurationStream = options.ConfigurationStream;
|
||||
Action<ApplicationConfiguration>? configureLoadedConfiguration =
|
||||
options.ConfigureLoadedConfiguration;
|
||||
services.TryAddSingleton(sp => new ClientSuppliedConfigurationProvider(
|
||||
configurationFile,
|
||||
configurationStream,
|
||||
configureLoadedConfiguration,
|
||||
sp.GetRequiredService<IApplicationInstanceFactory>(),
|
||||
sp.GetRequiredService<ITelemetryContext>(),
|
||||
sp.GetService<ICertificateManager>(),
|
||||
sp.GetService<ICertificatePasswordProvider>()));
|
||||
}
|
||||
|
||||
services.TryAddSingleton<OpcUaClientOptions>(sp =>
|
||||
{
|
||||
var resolvedOptions = new OpcUaClientOptions();
|
||||
CopyClientOptions(options, resolvedOptions);
|
||||
if (resolvedOptions.Configuration == null)
|
||||
{
|
||||
resolvedOptions.ConfigurationProvider =
|
||||
sp.GetService<IOpcUaApplicationConfigurationProvider>();
|
||||
resolvedOptions.Configuration =
|
||||
resolvedOptions.ConfigurationProvider?.Configuration;
|
||||
if (sp.GetService<ClientSuppliedConfigurationProvider>() is
|
||||
ClientSuppliedConfigurationProvider suppliedProvider)
|
||||
{
|
||||
// An explicitly supplied configuration document is the
|
||||
// most specific intent and therefore wins over a shared
|
||||
// application registered via ConfigureApplication(...).
|
||||
// It loads lazily: Configuration is filled in after the
|
||||
// first GetAsync completes.
|
||||
resolvedOptions.ConfigurationProvider = suppliedProvider;
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedOptions.ConfigurationProvider =
|
||||
sp.GetService<IOpcUaApplicationConfigurationProvider>();
|
||||
resolvedOptions.Configuration =
|
||||
resolvedOptions.ConfigurationProvider?.Configuration;
|
||||
}
|
||||
}
|
||||
return resolvedOptions;
|
||||
});
|
||||
@@ -937,6 +1096,10 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
/// <summary>
|
||||
/// Copies every publicly settable field of <paramref name="source"/>
|
||||
/// (application identity/security options, <see cref="OpcUaClientOptions.Configuration"/>,
|
||||
/// the supplied configuration document
|
||||
/// (<see cref="OpcUaClientOptions.ConfigurationFile"/> /
|
||||
/// <see cref="OpcUaClientOptions.ConfigurationStream"/> /
|
||||
/// <see cref="OpcUaClientOptions.ConfigureLoadedConfiguration"/>),
|
||||
/// <see cref="OpcUaClientOptions.Session"/>, <see cref="OpcUaClientOptions.Identity"/>
|
||||
/// and <see cref="OpcUaClientOptions.ReverseConnect"/>) into
|
||||
/// <paramref name="target"/>.
|
||||
@@ -944,6 +1107,9 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
private static void CopyClientOptions(OpcUaClientOptions source, OpcUaClientOptions target)
|
||||
{
|
||||
target.Configuration = source.Configuration;
|
||||
target.ConfigurationFile = source.ConfigurationFile;
|
||||
target.ConfigurationStream = source.ConfigurationStream;
|
||||
target.ConfigureLoadedConfiguration = source.ConfigureLoadedConfiguration;
|
||||
target.ApplicationName = source.ApplicationName;
|
||||
target.ApplicationUri = source.ApplicationUri;
|
||||
target.ProductUri = source.ProductUri;
|
||||
@@ -978,6 +1144,17 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
IOpcUaBuilder builder,
|
||||
OpcUaClientOptions options)
|
||||
{
|
||||
ValidateSuppliedConfigurationDocument(options);
|
||||
if (options.HasSuppliedConfigurationDocument)
|
||||
{
|
||||
// The supplied document is loaded through an
|
||||
// IApplicationInstance created by the shared factory; ensure
|
||||
// the factory is registered even when no ConfigureApplication
|
||||
// call is made.
|
||||
builder.AddApplicationInstance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.HasApplicationOptions)
|
||||
{
|
||||
return;
|
||||
@@ -999,6 +1176,54 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
builder.ConfigureApplication(_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects ambiguous combinations of an existing configuration XML
|
||||
/// document (<see cref="OpcUaClientOptions.ConfigurationFile"/> /
|
||||
/// <see cref="OpcUaClientOptions.ConfigurationStream"/>) with other
|
||||
/// configuration sources. Unlike the argument validation on the
|
||||
/// <c>AddClient(string, ...)</c> overload, this also covers values
|
||||
/// that arrive through configuration binding or the options
|
||||
/// callback.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">An invalid
|
||||
/// combination was configured.</exception>
|
||||
private static void ValidateSuppliedConfigurationDocument(OpcUaClientOptions options)
|
||||
{
|
||||
bool hasConfigurationFile = !string.IsNullOrEmpty(options.ConfigurationFile);
|
||||
if (hasConfigurationFile && string.IsNullOrWhiteSpace(options.ConfigurationFile))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaClientOptions.ConfigurationFile must not be a white-space path.");
|
||||
}
|
||||
if (hasConfigurationFile && options.ConfigurationStream != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Set only one of OpcUaClientOptions.ConfigurationFile and " +
|
||||
"OpcUaClientOptions.ConfigurationStream.");
|
||||
}
|
||||
if (!options.HasSuppliedConfigurationDocument)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (options.Configuration != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaClientOptions.ConfigurationFile / ConfigurationStream cannot " +
|
||||
"be combined with an explicit OpcUaClientOptions.Configuration.");
|
||||
}
|
||||
if (options.HasApplicationOptions)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaClientOptions.ConfigurationFile / ConfigurationStream cannot " +
|
||||
"be combined with client application identity options " +
|
||||
"(ApplicationName, ApplicationUri, ProductUri, SubjectName, " +
|
||||
"PkiRoot, AutoAcceptUntrustedCertificates, " +
|
||||
"RejectSHA1SignedCertificates, MinimumCertificateKeySize). The " +
|
||||
"supplied document is authoritative; use " +
|
||||
"ConfigureLoadedConfiguration for programmatic overrides.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateClientOptions(
|
||||
OpcUaClientOptions options,
|
||||
ManagedSessionOptions sessionOptions)
|
||||
@@ -1369,7 +1594,9 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
bool hasConfigurationProvider = false)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
if (options.Configuration == null && !hasConfigurationProvider)
|
||||
if (options.Configuration == null &&
|
||||
!hasConfigurationProvider &&
|
||||
!options.HasSuppliedConfigurationDocument)
|
||||
{
|
||||
failures.Add("OpcUaClientOptions.Configuration is required.");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using Opc.Ua.Configuration;
|
||||
|
||||
namespace Opc.Ua.Client
|
||||
@@ -41,10 +43,71 @@ namespace Opc.Ua.Client
|
||||
/// The application configuration. When omitted,
|
||||
/// <c>ConfigureApplication(...)</c> must be registered on the root
|
||||
/// OPC UA builder, or the application identity properties below
|
||||
/// must be set instead.
|
||||
/// must be set instead, or the configuration must be supplied as an
|
||||
/// existing XML document via <see cref="ConfigurationFile"/> /
|
||||
/// <see cref="ConfigurationStream"/>.
|
||||
/// </summary>
|
||||
public ApplicationConfiguration? Configuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to a classic OPC UA application configuration XML
|
||||
/// file (e.g. <c>MyClient.Config.xml</c>). When set, the client
|
||||
/// loads the <see cref="ApplicationConfiguration"/> from this file
|
||||
/// instead of building one, so existing applications can adopt
|
||||
/// dependency injection and keep every setting from their
|
||||
/// configuration file (security configuration, certificate stores,
|
||||
/// transport quotas, client configuration, ...).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A relative path is resolved against the current working
|
||||
/// directory. The file must validate as a client (or
|
||||
/// client-and-server) configuration. The document is loaded and the
|
||||
/// application-instance certificate is ensured on first use (first
|
||||
/// session connect, reverse-connect startup, or an explicit
|
||||
/// <c>GetAsync</c> on the resolved configuration provider),
|
||||
/// mirroring how a shared <c>ConfigureApplication(...)</c>
|
||||
/// application completes asynchronously.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Because the file is authoritative, it must not be combined with
|
||||
/// an explicit <see cref="Configuration"/> or with the application
|
||||
/// identity properties (<see cref="ApplicationName"/>, ...); use
|
||||
/// <see cref="ConfigureLoadedConfiguration"/> for programmatic
|
||||
/// overrides of the loaded file. This path also takes precedence
|
||||
/// over a shared application registered with
|
||||
/// <c>ConfigureApplication(...)</c>. Mutually exclusive with
|
||||
/// <see cref="ConfigurationStream"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? ConfigurationFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional stream containing a classic OPC UA application
|
||||
/// configuration XML document, e.g. an embedded resource. When set,
|
||||
/// the client loads the <see cref="ApplicationConfiguration"/> from
|
||||
/// this stream with the same semantics as
|
||||
/// <see cref="ConfigurationFile"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The stream must remain open until the configuration is first
|
||||
/// used; it is read once and disposed after loading. Mutually
|
||||
/// exclusive with <see cref="ConfigurationFile"/>.
|
||||
/// </remarks>
|
||||
public Stream? ConfigurationStream { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked with the <see cref="ApplicationConfiguration"/>
|
||||
/// loaded from <see cref="ConfigurationFile"/> or
|
||||
/// <see cref="ConfigurationStream"/>, after the document has been
|
||||
/// read and validated but before the application-instance
|
||||
/// certificate is checked. Use it to override individual settings
|
||||
/// from code without editing the document. Ignored when neither
|
||||
/// <see cref="ConfigurationFile"/> nor
|
||||
/// <see cref="ConfigurationStream"/> is set.
|
||||
/// </summary>
|
||||
public Action<ApplicationConfiguration>? ConfigureLoadedConfiguration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The application name. When set (and <see cref="Configuration"/>
|
||||
/// is omitted), the root <c>ConfigureApplication(...)</c>
|
||||
@@ -153,5 +216,13 @@ namespace Opc.Ua.Client
|
||||
|| AutoAcceptUntrustedCertificates != null
|
||||
|| RejectSHA1SignedCertificates != null
|
||||
|| MinimumCertificateKeySize != null;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when an existing configuration XML document was
|
||||
/// supplied via <see cref="ConfigurationFile"/> or
|
||||
/// <see cref="ConfigurationStream"/>.
|
||||
/// </summary>
|
||||
internal bool HasSuppliedConfigurationDocument =>
|
||||
!string.IsNullOrEmpty(ConfigurationFile) || ConfigurationStream != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ var sessionFactory =
|
||||
ManagedSession session = await sessionFactory(ct);
|
||||
```
|
||||
|
||||
Applications that already own a classic configuration XML file can pass
|
||||
it directly — `AddClient("MyClient.Config.xml", ...)`, or as a `Stream`
|
||||
for embedded resources — and keep every setting from the file while
|
||||
adopting dependency injection.
|
||||
|
||||
## Reverse connect lifecycle
|
||||
|
||||
`ReverseConnectManager` uses an asynchronous, cancellation-aware lifecycle:
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Configuration.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NUnit.Framework;
|
||||
using Opc.Ua.Configuration;
|
||||
|
||||
namespace Opc.Ua.Client.Tests.ClientBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises the <c>AddClient(configurationFile)</c> and
|
||||
/// <c>AddClient(configurationStream)</c> migration paths: OPC UA client
|
||||
/// services whose <see cref="ApplicationConfiguration"/> is loaded from
|
||||
/// an existing OPC UA XML configuration document instead of being built
|
||||
/// from options or the shared application. Covers the new
|
||||
/// <c>IOpcUaBuilder</c> overloads, the
|
||||
/// <see cref="OpcUaClientOptions.ConfigurationFile"/> /
|
||||
/// <see cref="OpcUaClientOptions.ConfigurationStream"/> options and the
|
||||
/// configuration-section binding, the lazily loading configuration
|
||||
/// provider (including the application-instance certificate bootstrap
|
||||
/// and the <see cref="OpcUaClientOptions.ConfigureLoadedConfiguration"/>
|
||||
/// override callback), precedence over <c>ConfigureApplication(...)</c>,
|
||||
/// ambiguous-combination validation, and load failures.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("Client")]
|
||||
[Category("ClientBuilder")]
|
||||
[SetCulture("en-us")]
|
||||
[SetUICulture("en-us")]
|
||||
[Parallelizable]
|
||||
public sealed class ClientConfigurationFileTests
|
||||
{
|
||||
[Test]
|
||||
public async Task AddClientLoadsConfigurationFromXmlFileAsync()
|
||||
{
|
||||
const string applicationName = "CfgXmlClient";
|
||||
string testRoot = CreateTestRoot();
|
||||
string configurationFile = WriteConfigurationFile(testRoot, applicationName);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddClient(
|
||||
configurationFile,
|
||||
options => options.ConfigureLoadedConfiguration = configuration =>
|
||||
configuration.ClientConfiguration!.MinSubscriptionLifetime = 4242);
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
OpcUaClientOptions resolvedOptions =
|
||||
provider.GetRequiredService<OpcUaClientOptions>();
|
||||
|
||||
// The supplied document loads lazily: the provider is wired but
|
||||
// the configuration is not materialized until first use.
|
||||
Assert.That(resolvedOptions.Configuration, Is.Null);
|
||||
Assert.That(resolvedOptions.ConfigurationProvider, Is.Not.Null);
|
||||
|
||||
ApplicationConfiguration configuration = await resolvedOptions
|
||||
.ConfigurationProvider!.GetAsync().ConfigureAwait(false);
|
||||
|
||||
// Every setting comes from the file; the override callback wins
|
||||
// over the file value for the setting it touches.
|
||||
Assert.That(configuration.SourceFilePath, Is.EqualTo(configurationFile));
|
||||
Assert.That(configuration.ApplicationName, Is.EqualTo(applicationName));
|
||||
Assert.That(
|
||||
configuration.ApplicationUri,
|
||||
Is.EqualTo("urn:opcfoundation:test:" + applicationName));
|
||||
Assert.That(configuration.TransportQuotas!.MaxStringLength, Is.EqualTo(654321));
|
||||
Assert.That(configuration.ClientConfiguration, Is.Not.Null);
|
||||
Assert.That(
|
||||
configuration.ClientConfiguration!.DefaultSessionTimeout,
|
||||
Is.EqualTo(123456));
|
||||
Assert.That(
|
||||
configuration.ClientConfiguration.MinSubscriptionLifetime,
|
||||
Is.EqualTo(4242));
|
||||
|
||||
// The load ran the client application-instance certificate
|
||||
// bootstrap: the own store now holds the application certificate.
|
||||
Assert.That(
|
||||
Directory.Exists(Path.Combine(testRoot, "pki", "own")) &&
|
||||
Directory.GetFiles(
|
||||
Path.Combine(testRoot, "pki", "own"),
|
||||
"*",
|
||||
SearchOption.AllDirectories).Length > 0,
|
||||
Is.True,
|
||||
"The application instance certificate was not created.");
|
||||
|
||||
// The provider caches the loaded document.
|
||||
ApplicationConfiguration second = await resolvedOptions
|
||||
.ConfigurationProvider.GetAsync().ConfigureAwait(false);
|
||||
Assert.That(second, Is.SameAs(configuration));
|
||||
Assert.That(
|
||||
resolvedOptions.ConfigurationProvider.Configuration,
|
||||
Is.SameAs(configuration));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddClientLoadsConfigurationFromStreamAsync()
|
||||
{
|
||||
const string applicationName = "CfgXmlStreamClient";
|
||||
string testRoot = CreateTestRoot();
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(
|
||||
BuildConfigurationXml(applicationName, GetPkiRoot(testRoot))));
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddClient(stream);
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
OpcUaClientOptions resolvedOptions =
|
||||
provider.GetRequiredService<OpcUaClientOptions>();
|
||||
|
||||
ApplicationConfiguration configuration = await resolvedOptions
|
||||
.ConfigurationProvider!.GetAsync().ConfigureAwait(false);
|
||||
|
||||
// A stream has no source file path; the settings come from the
|
||||
// document and the stream is disposed after the single read.
|
||||
Assert.That(configuration.SourceFilePath, Is.Null);
|
||||
Assert.That(configuration.ApplicationName, Is.EqualTo(applicationName));
|
||||
Assert.That(configuration.TransportQuotas!.MaxStringLength, Is.EqualTo(654321));
|
||||
Assert.That(stream.CanRead, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SuppliedDocumentTakesPrecedenceOverConfigureApplicationAsync()
|
||||
{
|
||||
const string applicationName = "CfgXmlPrecedenceClient";
|
||||
string testRoot = CreateTestRoot();
|
||||
string configurationFile = WriteConfigurationFile(testRoot, applicationName);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
IOpcUaBuilder builder = services.AddOpcUa()
|
||||
.ConfigureApplication(options =>
|
||||
{
|
||||
options.ApplicationName = "SharedClientApplication";
|
||||
options.ApplicationUri = "urn:localhost:SharedClientApplication";
|
||||
options.PkiRoot = Path.Combine(testRoot, "sharedpki");
|
||||
options.AutoAcceptUntrustedCertificates = true;
|
||||
});
|
||||
builder.AddClient(configurationFile);
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
OpcUaClientOptions resolvedOptions =
|
||||
provider.GetRequiredService<OpcUaClientOptions>();
|
||||
|
||||
ApplicationConfiguration configuration = await resolvedOptions
|
||||
.ConfigurationProvider!.GetAsync().ConfigureAwait(false);
|
||||
|
||||
Assert.That(configuration.SourceFilePath, Is.EqualTo(configurationFile));
|
||||
Assert.That(configuration.ApplicationName, Is.EqualTo(applicationName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MissingConfigurationFileFailsGetAsyncAsync()
|
||||
{
|
||||
string missingFile = Path.Combine(CreateTestRoot(), "DoesNotExist.Config.xml");
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddClient(missingFile);
|
||||
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
OpcUaClientOptions resolvedOptions =
|
||||
provider.GetRequiredService<OpcUaClientOptions>();
|
||||
|
||||
Assert.That(
|
||||
() => resolvedOptions.ConfigurationProvider!.GetAsync(),
|
||||
Throws.InstanceOf<ServiceResultException>());
|
||||
Assert.That(
|
||||
() => resolvedOptions.ConfigurationProvider!.Configuration,
|
||||
Throws.InvalidOperationException);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddClientSuppliedDocumentValidatesArguments()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
IOpcUaBuilder builder = services.AddOpcUa();
|
||||
|
||||
Assert.That(
|
||||
() => default(IOpcUaBuilder)!.AddClient("Client.Config.xml"),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder"));
|
||||
Assert.That(
|
||||
() => builder.AddClient((string)null!),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationFile"));
|
||||
Assert.That(
|
||||
() => builder.AddClient(" "),
|
||||
Throws.TypeOf<ArgumentException>().With
|
||||
.Property(nameof(ArgumentException.ParamName)).EqualTo("configurationFile"));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
Assert.That(
|
||||
() => default(IOpcUaBuilder)!.AddClient(stream),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder"));
|
||||
Assert.That(
|
||||
() => builder.AddClient((Stream)null!),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationStream"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddClientSuppliedDocumentRejectsAmbiguousCombinations()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// File and stream together.
|
||||
Assert.That(
|
||||
() => new ServiceCollection().AddOpcUa().AddClient(options =>
|
||||
{
|
||||
options.ConfigurationFile = "Client.Config.xml";
|
||||
options.ConfigurationStream = stream;
|
||||
}),
|
||||
Throws.InvalidOperationException.With.Message.Contains("only one"));
|
||||
|
||||
// Supplied document combined with an explicit Configuration.
|
||||
Assert.That(
|
||||
() => new ServiceCollection().AddOpcUa().AddClient(options =>
|
||||
{
|
||||
options.ConfigurationFile = "Client.Config.xml";
|
||||
options.Configuration = CreateConfiguration();
|
||||
}),
|
||||
Throws.InvalidOperationException.With.Message.Contains("Configuration"));
|
||||
|
||||
// Supplied document combined with application identity options.
|
||||
Assert.That(
|
||||
() => new ServiceCollection().AddOpcUa().AddClient(options =>
|
||||
{
|
||||
options.ConfigurationFile = "Client.Config.xml";
|
||||
options.ApplicationName = "SomeClient";
|
||||
}),
|
||||
Throws.InvalidOperationException.With.Message.Contains("identity options"));
|
||||
|
||||
// A white-space path that bypasses the AddClient(string) argument
|
||||
// validation (e.g. from configuration binding).
|
||||
Assert.That(
|
||||
() => new ServiceCollection().AddOpcUa().AddClient(options =>
|
||||
options.ConfigurationFile = " "),
|
||||
Throws.InvalidOperationException.With.Message.Contains("white-space"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddClientBindsConfigurationFileFromConfigurationSectionAsync()
|
||||
{
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.Add(new MemoryConfigurationSource
|
||||
{
|
||||
InitialData = new Dictionary<string, string?>
|
||||
{
|
||||
["OpcUa:Client:ConfigurationFile"] = "Legacy/Client.Config.xml"
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddClient(configuration);
|
||||
|
||||
// The supplied-document provider is IAsyncDisposable-only (like
|
||||
// the shared application provider), so the container must be
|
||||
// disposed asynchronously once the provider was instantiated.
|
||||
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||
OpcUaClientOptions options = provider.GetRequiredService<OpcUaClientOptions>();
|
||||
|
||||
Assert.That(options.ConfigurationFile, Is.EqualTo("Legacy/Client.Config.xml"));
|
||||
Assert.That(options.ConfigurationProvider, Is.Not.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OptionsValidationAcceptsSuppliedDocument()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddClient("Client.Config.xml");
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
OpcUaClientOptions boundOptions =
|
||||
provider.GetRequiredService<IOptions<OpcUaClientOptions>>().Value;
|
||||
|
||||
// The start-time validator must accept a supplied document as a
|
||||
// configuration source even though no shared application
|
||||
// provider is registered and Configuration is still null.
|
||||
foreach (IValidateOptions<OpcUaClientOptions> validator in
|
||||
provider.GetServices<IValidateOptions<OpcUaClientOptions>>())
|
||||
{
|
||||
ValidateOptionsResult result =
|
||||
validator.Validate(Options.DefaultName, boundOptions);
|
||||
Assert.That(result.Failed, Is.False, result.FailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTestRoot()
|
||||
{
|
||||
// Keep the root short: certificate file names would otherwise
|
||||
// push the PFX path past MAX_PATH on .NET Framework.
|
||||
string testRoot = Path.Combine(
|
||||
TestContext.CurrentContext.WorkDirectory,
|
||||
"ccfg",
|
||||
Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(testRoot);
|
||||
return testRoot;
|
||||
}
|
||||
|
||||
private static string GetPkiRoot(string testRoot)
|
||||
{
|
||||
return Path.Combine(testRoot, "pki").Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static ApplicationConfiguration CreateConfiguration()
|
||||
{
|
||||
return new ApplicationConfiguration(
|
||||
Opc.Ua.Tests.NUnitTelemetryContext.Create());
|
||||
}
|
||||
|
||||
private static string WriteConfigurationFile(string testRoot, string applicationName)
|
||||
{
|
||||
string configurationFile = Path.Combine(testRoot, applicationName + ".Config.xml");
|
||||
File.WriteAllText(
|
||||
configurationFile,
|
||||
BuildConfigurationXml(applicationName, GetPkiRoot(testRoot)));
|
||||
return configurationFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a classic OPC UA client configuration XML document of the
|
||||
/// shape existing applications carry (security configuration,
|
||||
/// transport quotas, client configuration) with distinctive values
|
||||
/// the tests can assert on.
|
||||
/// </summary>
|
||||
private static string BuildConfigurationXml(string applicationName, string pkiRoot)
|
||||
{
|
||||
return $"""
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ApplicationConfiguration
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:ua="http://opcfoundation.org/UA/2008/02/Types.xsd"
|
||||
xmlns="http://opcfoundation.org/UA/SDK/Configuration.xsd">
|
||||
<ApplicationName>{applicationName}</ApplicationName>
|
||||
<ApplicationUri>urn:opcfoundation:test:{applicationName}</ApplicationUri>
|
||||
<ProductUri>uri:opcfoundation.org:test:{applicationName}</ProductUri>
|
||||
<ApplicationType>Client_1</ApplicationType>
|
||||
<SecurityConfiguration>
|
||||
<ApplicationCertificates>
|
||||
<CertificateIdentifier>
|
||||
<StoreType>Directory</StoreType>
|
||||
<StorePath>{pkiRoot}/own</StorePath>
|
||||
<SubjectName>CN={applicationName}, O=OPC Foundation, DC=localhost</SubjectName>
|
||||
<CertificateTypeString>RsaSha256</CertificateTypeString>
|
||||
</CertificateIdentifier>
|
||||
</ApplicationCertificates>
|
||||
<TrustedIssuerCertificates>
|
||||
<StoreType>Directory</StoreType>
|
||||
<StorePath>{pkiRoot}/issuer</StorePath>
|
||||
</TrustedIssuerCertificates>
|
||||
<TrustedPeerCertificates>
|
||||
<StoreType>Directory</StoreType>
|
||||
<StorePath>{pkiRoot}/trusted</StorePath>
|
||||
</TrustedPeerCertificates>
|
||||
<RejectedCertificateStore>
|
||||
<StoreType>Directory</StoreType>
|
||||
<StorePath>{pkiRoot}/rejected</StorePath>
|
||||
</RejectedCertificateStore>
|
||||
<AutoAcceptUntrustedCertificates>true</AutoAcceptUntrustedCertificates>
|
||||
</SecurityConfiguration>
|
||||
<TransportConfigurations></TransportConfigurations>
|
||||
<TransportQuotas>
|
||||
<OperationTimeout>90000</OperationTimeout>
|
||||
<MaxStringLength>654321</MaxStringLength>
|
||||
<MaxByteStringLength>1048576</MaxByteStringLength>
|
||||
<MaxArrayLength>65535</MaxArrayLength>
|
||||
<MaxMessageSize>4194304</MaxMessageSize>
|
||||
<MaxBufferSize>65535</MaxBufferSize>
|
||||
<ChannelLifetime>300000</ChannelLifetime>
|
||||
<SecurityTokenLifetime>3600000</SecurityTokenLifetime>
|
||||
</TransportQuotas>
|
||||
<ClientConfiguration>
|
||||
<DefaultSessionTimeout>123456</DefaultSessionTimeout>
|
||||
<MinSubscriptionLifetime>10000</MinSubscriptionLifetime>
|
||||
</ClientConfiguration>
|
||||
</ApplicationConfiguration>
|
||||
""";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user