diff --git a/docs/DependencyInjection.md b/docs/DependencyInjection.md index af81a4fb1d..e9a76a981f 100644 --- a/docs/DependencyInjection.md +++ b/docs/DependencyInjection.md @@ -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(); ManagedSession dynamicSession = await managedSessions.ConnectAsync(endpoint, ct); ``` -Misconfiguration is validated through `IValidateOptions` 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` 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 diff --git a/docs/migrate/2.0.x/configuration.md b/docs/migrate/2.0.x/configuration.md index c8ae5f4bbe..8fe12d61bb 100644 --- a/docs/migrate/2.0.x/configuration.md +++ b/docs/migrate/2.0.x/configuration.md @@ -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`. diff --git a/src/Opc.Ua.Client/Fluent/ClientSuppliedConfigurationProvider.cs b/src/Opc.Ua.Client/Fluent/ClientSuppliedConfigurationProvider.cs new file mode 100644 index 0000000000..90e479a2e2 --- /dev/null +++ b/src/Opc.Ua.Client/Fluent/ClientSuppliedConfigurationProvider.cs @@ -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 +{ + /// + /// backed by an + /// existing OPC UA application configuration XML document supplied via + /// or + /// . The document + /// is loaded lazily on the first : it is read + /// through ApplicationInstance.LoadApplicationConfigurationAsync + /// so every setting applies exactly as on the classic path, the + /// optional + /// override callback runs, and the application-instance certificate is + /// ensured — mirroring how the shared + /// OpcUaApplicationConfigurationProvider completes client + /// configurations. A supplied stream is read once and disposed. + /// + internal sealed class ClientSuppliedConfigurationProvider : + IOpcUaApplicationConfigurationProvider + { + public ClientSuppliedConfigurationProvider( + string? configurationFile, + Stream? configurationStream, + Action? 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; + } + + /// + public IApplicationInstance Application { get; } + + /// + /// + /// The supplied document loads lazily, so the configuration is only + /// available after the first has completed. + /// + 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."); + + /// + public async Task GetAsync(CancellationToken ct = default) + { + if (Volatile.Read(ref m_disposed) != 0) + { + throw new ObjectDisposedException(nameof(ClientSuppliedConfigurationProvider)); + } + + Task 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); + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref m_disposed, 1) != 0) + { + return; + } + + Task? 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 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 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? m_configureLoadedConfiguration; + private readonly ICertificateManager? m_certificateManager; + private readonly Lock m_lock = new(); + private Task? m_loadTask; + private ApplicationConfiguration? m_configuration; + private int m_disposed; + } +} diff --git a/src/Opc.Ua.Client/Fluent/OpcUaClientBuilderExtensions.cs b/src/Opc.Ua.Client/Fluent/OpcUaClientBuilderExtensions.cs index ac95ec8be7..cba8c3f0d7 100644 --- a/src/Opc.Ua.Client/Fluent/OpcUaClientBuilderExtensions.cs +++ b/src/Opc.Ua.Client/Fluent/OpcUaClientBuilderExtensions.cs @@ -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); } + /// + /// Registers OPC UA client services whose + /// is loaded from an existing + /// OPC UA XML configuration file (e.g. MyClient.Config.xml). + /// + /// + /// + /// 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 + /// ApplicationInstance.LoadApplicationConfigurationAsync. 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 + /// registrations compose with the + /// loaded file the same way they compose with built configurations. + /// + /// + /// Equivalent to + /// + /// with set. Use + /// to set the session endpoint and the + /// other client options, and + /// + /// within it to override individual settings of the loaded file from + /// code. + /// + /// + /// The OPC UA builder. + /// Path to the application + /// configuration XML file. A relative path is resolved against the + /// current working directory. + /// Optional configuration delegate for the + /// remaining (session, identity, + /// reverse connect, ...). The application identity properties and an + /// explicit must not + /// be combined with the configuration file. + /// An for chaining. + /// + /// or is null. + /// + /// is empty or white space. + /// The configuration file + /// is combined with an explicit + /// , a + /// , or client + /// application identity options. + public static IOpcUaClientBuilder AddClient( + this IOpcUaBuilder builder, + string configurationFile, + Action? 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); + }); + } + + /// + /// Registers OPC UA client services whose + /// is loaded from a stream + /// containing an OPC UA XML configuration document, e.g. an embedded + /// resource. + /// + /// + /// + /// See + /// for how the loaded configuration is applied. Equivalent to + /// + /// with set. + /// + /// + /// The stream must remain open until the configuration is first + /// used; it is read once and disposed after loading. + /// + /// + /// The OPC UA builder. + /// Stream containing the + /// application configuration XML document. + /// Optional configuration delegate for the + /// remaining . + /// An for chaining. + /// + /// or is null. + /// The configuration + /// stream is combined with an explicit + /// , a + /// , or client + /// application identity options. + public static IOpcUaClientBuilder AddClient( + this IOpcUaBuilder builder, + Stream configurationStream, + Action? configure = null) + { + if (configurationStream is null) + { + throw new ArgumentNullException(nameof(configurationStream)); + } + + return builder.AddClient(options => + { + options.ConfigurationStream = configurationStream; + configure?.Invoke(options); + }); + } + /// /// Registers OPC UA client services with options bound from the /// supplied section @@ -874,7 +995,13 @@ namespace Microsoft.Extensions.DependencyInjection OpcUaClientOptions options = sp.GetRequiredService(); 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(); @@ -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? configureLoadedConfiguration = + options.ConfigureLoadedConfiguration; + services.TryAddSingleton(sp => new ClientSuppliedConfigurationProvider( + configurationFile, + configurationStream, + configureLoadedConfiguration, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetService(), + sp.GetService())); + } + services.TryAddSingleton(sp => { var resolvedOptions = new OpcUaClientOptions(); CopyClientOptions(options, resolvedOptions); if (resolvedOptions.Configuration == null) { - resolvedOptions.ConfigurationProvider = - sp.GetService(); - resolvedOptions.Configuration = - resolvedOptions.ConfigurationProvider?.Configuration; + if (sp.GetService() 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(); + resolvedOptions.Configuration = + resolvedOptions.ConfigurationProvider?.Configuration; + } } return resolvedOptions; }); @@ -937,6 +1096,10 @@ namespace Microsoft.Extensions.DependencyInjection /// /// Copies every publicly settable field of /// (application identity/security options, , + /// the supplied configuration document + /// ( / + /// / + /// ), /// , /// and ) into /// . @@ -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(_ => { }); } + /// + /// Rejects ambiguous combinations of an existing configuration XML + /// document ( / + /// ) with other + /// configuration sources. Unlike the argument validation on the + /// AddClient(string, ...) overload, this also covers values + /// that arrive through configuration binding or the options + /// callback. + /// + /// An invalid + /// combination was configured. + 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(); - if (options.Configuration == null && !hasConfigurationProvider) + if (options.Configuration == null && + !hasConfigurationProvider && + !options.HasSuppliedConfigurationDocument) { failures.Add("OpcUaClientOptions.Configuration is required."); } diff --git a/src/Opc.Ua.Client/Fluent/OpcUaClientOptions.cs b/src/Opc.Ua.Client/Fluent/OpcUaClientOptions.cs index ad51408203..4652373259 100644 --- a/src/Opc.Ua.Client/Fluent/OpcUaClientOptions.cs +++ b/src/Opc.Ua.Client/Fluent/OpcUaClientOptions.cs @@ -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, /// ConfigureApplication(...) 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 / + /// . /// public ApplicationConfiguration? Configuration { get; set; } + /// + /// Optional path to a classic OPC UA application configuration XML + /// file (e.g. MyClient.Config.xml). When set, the client + /// loads the 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, ...). + /// + /// + /// + /// 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 + /// GetAsync on the resolved configuration provider), + /// mirroring how a shared ConfigureApplication(...) + /// application completes asynchronously. + /// + /// + /// Because the file is authoritative, it must not be combined with + /// an explicit or with the application + /// identity properties (, ...); use + /// for programmatic + /// overrides of the loaded file. This path also takes precedence + /// over a shared application registered with + /// ConfigureApplication(...). Mutually exclusive with + /// . + /// + /// + public string? ConfigurationFile { get; set; } + + /// + /// Optional stream containing a classic OPC UA application + /// configuration XML document, e.g. an embedded resource. When set, + /// the client loads the from + /// this stream with the same semantics as + /// . + /// + /// + /// The stream must remain open until the configuration is first + /// used; it is read once and disposed after loading. Mutually + /// exclusive with . + /// + public Stream? ConfigurationStream { get; set; } + + /// + /// Optional callback invoked with the + /// loaded from or + /// , 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 + /// nor + /// is set. + /// + public Action? ConfigureLoadedConfiguration { get; set; } + /// /// The application name. When set (and /// is omitted), the root ConfigureApplication(...) @@ -153,5 +216,13 @@ namespace Opc.Ua.Client || AutoAcceptUntrustedCertificates != null || RejectSHA1SignedCertificates != null || MinimumCertificateKeySize != null; + + /// + /// true when an existing configuration XML document was + /// supplied via or + /// . + /// + internal bool HasSuppliedConfigurationDocument => + !string.IsNullOrEmpty(ConfigurationFile) || ConfigurationStream != null; } } diff --git a/src/Opc.Ua.Client/NugetREADME.md b/src/Opc.Ua.Client/NugetREADME.md index e841305685..dd343d4173 100644 --- a/src/Opc.Ua.Client/NugetREADME.md +++ b/src/Opc.Ua.Client/NugetREADME.md @@ -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: diff --git a/tests/Opc.Ua.Client.Tests/ClientBuilder/ClientConfigurationFileTests.cs b/tests/Opc.Ua.Client.Tests/ClientBuilder/ClientConfigurationFileTests.cs new file mode 100644 index 0000000000..c4917b9910 --- /dev/null +++ b/tests/Opc.Ua.Client.Tests/ClientBuilder/ClientConfigurationFileTests.cs @@ -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 +{ + /// + /// Exercises the AddClient(configurationFile) and + /// AddClient(configurationStream) migration paths: OPC UA client + /// services whose is loaded from + /// an existing OPC UA XML configuration document instead of being built + /// from options or the shared application. Covers the new + /// IOpcUaBuilder overloads, the + /// / + /// options and the + /// configuration-section binding, the lazily loading configuration + /// provider (including the application-instance certificate bootstrap + /// and the + /// override callback), precedence over ConfigureApplication(...), + /// ambiguous-combination validation, and load failures. + /// + [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(); + + // 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(); + + 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(); + + 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(); + + Assert.That( + () => resolvedOptions.ConfigurationProvider!.GetAsync(), + Throws.InstanceOf()); + 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().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 + { + ["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(); + + 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>().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 validator in + provider.GetServices>()) + { + 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; + } + + /// + /// 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. + /// + private static string BuildConfigurationXml(string applicationName, string pkiRoot) + { + return $""" + + + {applicationName} + urn:opcfoundation:test:{applicationName} + uri:opcfoundation.org:test:{applicationName} + Client_1 + + + + Directory + {pkiRoot}/own + CN={applicationName}, O=OPC Foundation, DC=localhost + RsaSha256 + + + + Directory + {pkiRoot}/issuer + + + Directory + {pkiRoot}/trusted + + + Directory + {pkiRoot}/rejected + + true + + + + 90000 + 654321 + 1048576 + 65535 + 4194304 + 65535 + 300000 + 3600000 + + + 123456 + 10000 + + + """; + } + } +}