diff --git a/docs/DependencyInjection.md b/docs/DependencyInjection.md index eeca94d2b2..af81a4fb1d 100644 --- a/docs/DependencyInjection.md +++ b/docs/DependencyInjection.md @@ -403,6 +403,78 @@ an anonymous authenticator matching its default anonymous user-token policy. If a non-anonymous user-token policy is configured without a corresponding authenticator, startup logs a warning. +### Migrating with an existing configuration XML file + +Applications that already own a classic OPC UA application configuration +file (`*.Config.xml`, previously loaded through +`ApplicationInstance.LoadApplicationConfigurationAsync`) can pass that +file to `AddServer` directly and keep every setting in it — base +addresses, security policies, certificate stores, transport quotas, +operation limits, user-token policies — while adopting the hosted +dependency-injection surface: + +```csharp +builder.Services + .AddOpcUa() + .AddServer("MyServer.Config.xml") + .AddNodeManager(); +``` + +The same file can be referenced from configuration instead +(`OpcUa:Server:ConfigurationFile`), and a custom `StandardServer` +subclass works too: + +```json +{ "OpcUa": { "Server": { "ConfigurationFile": "MyServer.Config.xml" } } } +``` + +```csharp +builder.Services.AddOpcUa().AddServer("MyServer.Config.xml"); +``` + +On this path the file is authoritative: the `OpcUaServerOptions` knobs +that feed the configuration builder (`ApplicationName`, `EndpointUrls`, +`PkiRoot`, policy toggles, transport quotas, `ConfigureBuilder`, ...) +are not applied, and the file also takes precedence over a shared +application registered with `ConfigureApplication(...)`. Options that +act on the hosted server itself (`Identity`, `ConfigureRateLimits`) and +all fluent registrations (`AddNodeManager`, `ConfigureRoles`, +`AddDefaultIdentityAuthenticators`, ...) keep working. To override +individual file settings from code, pass the optional callback, which +runs after the file is loaded and validated but before certificates are +checked and the server starts: + +```csharp +builder.Services + .AddOpcUa() + .AddServer("MyServer.Config.xml", configuration => + { + configuration.ServerConfiguration.MaxSessionCount = 25; + }); +``` + +A configuration document that is not a file on disk — an embedded +resource, a document fetched from a store — can be supplied as a +`Stream` instead (`OpcUaServerOptions.ConfigurationStream`; mutually +exclusive with `ConfigurationFile`). The stream must remain open until +the host starts the server; it is read once and disposed by the hosted +service during startup, so do not wrap it in a `using` yourself: + +```csharp +Stream stream = typeof(Program).Assembly + .GetManifestResourceStream("MyApp.MyServer.Config.xml")!; + +builder.Services + .AddOpcUa() + .AddServer(stream) // read + disposed when the host starts the server + .AddNodeManager(); +``` + +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. + ### Server security and resource controls The hosted server is secure by default: sign-and-encrypt policies are included, @@ -504,6 +576,9 @@ properties (bindable from `IConfiguration` or set via the | `RegistrationEndpointUrl` | `SetRegistrationEndpoint(EndpointDescription)` | LDS/GDS endpoint URL the server registers itself with on startup. | | `ReverseConnect` | `SetReverseConnect(ReverseConnectServerConfiguration)` | Server-side reverse-connect clients (see below). | | `OperationLimits` | `SetOperationLimits(OperationLimits)` | Per-service node limits (max nodes per read/write/browse/...). | +| `ConfigurationFile` | `LoadApplicationConfigurationAsync(path)` | Load the configuration from an existing OPC UA XML configuration file instead of building it from the options (see [Migrating with an existing configuration XML file](#migrating-with-an-existing-configuration-xml-file)). | +| `ConfigurationStream` | `LoadApplicationConfigurationAsync(stream)` | Code-only: load the configuration from a stream (e.g. an embedded resource); read once and disposed at startup. Mutually exclusive with `ConfigurationFile`. | +| `ConfigureLoadedConfiguration` | Code-only callback | Override individual settings of the configuration loaded from `ConfigurationFile` / `ConfigurationStream`. | | `ConfigureBuilder` | Code-only callback | Pre-security server-policy and server-option escape hatch, including max failed authentication attempts, sessions, channels, auditing, and HTTPS mutual TLS. | | `ConfigureRateLimits` | Code-only callback | Tunes the default connection and session-establishment admission controls. | diff --git a/docs/migrate/2.0.x/configuration.md b/docs/migrate/2.0.x/configuration.md index 9cf1d549b7..c8ae5f4bbe 100644 --- a/docs/migrate/2.0.x/configuration.md +++ b/docs/migrate/2.0.x/configuration.md @@ -13,6 +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). - 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.Server/Hosting/OpcUaServerBuilderExtensions.cs b/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs index 5bab86f472..d2815d4e78 100644 --- a/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs @@ -32,6 +32,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -163,6 +164,189 @@ namespace Microsoft.Extensions.DependencyInjection return new OpcUaServerBuilder(builder.Services); } + /// + /// Registers an OPC UA server hosted as an + /// whose is loaded from an + /// existing OPC UA XML configuration file + /// (e.g. MyServer.Config.xml). + /// + /// + /// + /// This is the migration path for applications that already own a + /// configuration file: every setting in the file (base addresses, + /// security policies, certificate stores, transport quotas, operation + /// limits, ...) is applied as-is, exactly as when the file is loaded + /// through ApplicationInstance.LoadApplicationConfigurationAsync. + /// Node managers, identity authenticators, roles and the other + /// registrations compose with the + /// loaded file the same way they compose with option-built + /// configurations. + /// + /// + /// Equivalent to + /// + /// with and + /// set. + /// + /// + /// The OPC UA builder. + /// Path to the application + /// configuration XML file. A relative path is resolved against the + /// current working directory. + /// Optional callback invoked with the loaded + /// before certificates are + /// checked and the server starts; use it to override individual + /// settings from code. + /// An for chaining. + /// + /// or is null. + /// + /// is empty or white space. + /// An OPC UA server + /// is already registered. + public static IOpcUaServerBuilder AddServer( + this IOpcUaBuilder builder, + string configurationFile, + Action? configure = null) + { + ValidateConfigurationFilePath(configurationFile); + return builder.AddServer(options => + { + options.ConfigurationFile = configurationFile; + options.ConfigureLoadedConfiguration = configure; + }); + } + + /// + /// Registers an OPC UA server hosted as an + /// using a custom subclass whose + /// is loaded from an existing + /// OPC UA XML configuration file (e.g. MyServer.Config.xml). + /// + /// + /// See + /// for how the configuration file is applied. + /// + /// The server type created by the hosted service. + /// The OPC UA builder. + /// Path to the application + /// configuration XML file. A relative path is resolved against the + /// current working directory. + /// Optional callback invoked with the loaded + /// before certificates are + /// checked and the server starts. + /// An for chaining. + /// + /// or is null. + /// + /// is empty or white space. + /// An OPC UA server + /// is already registered. + public static IOpcUaServerBuilder AddServer< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TServer>( + this IOpcUaBuilder builder, + string configurationFile, + Action? configure = null) + where TServer : StandardServer + { + ValidateConfigurationFilePath(configurationFile); + return builder.AddServer(options => + { + options.ConfigurationFile = configurationFile; + options.ConfigureLoadedConfiguration = configure; + }); + } + + /// + /// Registers an OPC UA server hosted as an + /// 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 and + /// set. + /// + /// + /// The stream must remain open until the host starts the server; it + /// is read once and disposed by the hosted service during startup. + /// + /// + /// The OPC UA builder. + /// Stream containing the + /// application configuration XML document. + /// Optional callback invoked with the loaded + /// before certificates are + /// checked and the server starts; use it to override individual + /// settings from code. + /// An for chaining. + /// + /// or is null. + /// An OPC UA server + /// is already registered. + public static IOpcUaServerBuilder AddServer( + this IOpcUaBuilder builder, + Stream configurationStream, + Action? configure = null) + { + if (configurationStream is null) + { + throw new ArgumentNullException(nameof(configurationStream)); + } + + return builder.AddServer(options => + { + options.ConfigurationStream = configurationStream; + options.ConfigureLoadedConfiguration = configure; + }); + } + + /// + /// Registers an OPC UA server hosted as an + /// using a custom subclass whose + /// is loaded from a stream + /// containing an OPC UA XML configuration document, e.g. an embedded + /// resource. + /// + /// + /// See + /// for how the stream is applied and its lifetime. + /// + /// The server type created by the hosted service. + /// The OPC UA builder. + /// Stream containing the + /// application configuration XML document. + /// Optional callback invoked with the loaded + /// before certificates are + /// checked and the server starts. + /// An for chaining. + /// + /// or is null. + /// An OPC UA server + /// is already registered. + public static IOpcUaServerBuilder AddServer< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TServer>( + this IOpcUaBuilder builder, + Stream configurationStream, + Action? configure = null) + where TServer : StandardServer + { + if (configurationStream is null) + { + throw new ArgumentNullException(nameof(configurationStream)); + } + + return builder.AddServer(options => + { + options.ConfigurationStream = configurationStream; + options.ConfigureLoadedConfiguration = configure; + }); + } + /// /// Registers an OPC UA server hosted as an /// with options bound from the supplied @@ -1238,6 +1422,20 @@ namespace Microsoft.Extensions.DependencyInjection } } + private static void ValidateConfigurationFilePath(string configurationFile) + { + 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)); + } + } + private static void EnsureFirstRegistration(IServiceCollection services) { foreach (ServiceDescriptor d in services) @@ -1461,6 +1659,8 @@ namespace Microsoft.Extensions.DependencyInjection BindString(section, nameof(OpcUaServerOptions.ProductUri), value => options.ProductUri = value); BindString(section, nameof(OpcUaServerOptions.SubjectName), value => options.SubjectName = value); BindString(section, nameof(OpcUaServerOptions.PkiRoot), value => options.PkiRoot = value); + BindString(section, nameof(OpcUaServerOptions.ConfigurationFile), + value => options.ConfigurationFile = value); BindString(section, nameof(OpcUaServerOptions.RegistrationEndpointUrl), value => options.RegistrationEndpointUrl = value); BindBoolean(section, nameof(OpcUaServerOptions.AutoAcceptUntrustedCertificates), diff --git a/src/Opc.Ua.Server/Hosting/OpcUaServerHostedService.cs b/src/Opc.Ua.Server/Hosting/OpcUaServerHostedService.cs index d2f3de91d9..4164114a1e 100644 --- a/src/Opc.Ua.Server/Hosting/OpcUaServerHostedService.cs +++ b/src/Opc.Ua.Server/Hosting/OpcUaServerHostedService.cs @@ -124,11 +124,34 @@ namespace Opc.Ua.Server.Hosting protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - string[] urls = new string[m_options.EndpointUrls.Count]; - m_options.EndpointUrls.CopyTo(urls, 0); ICertificateManager? certificateManager = m_services.GetService(); - if (m_configurationProvider != null) + if (HasSuppliedConfiguration) + { + bool hasConfigurationFile = !string.IsNullOrEmpty(m_options.ConfigurationFile); + if (hasConfigurationFile && m_options.ConfigurationStream != null) + { + throw new InvalidOperationException( + "Set only one of OpcUaServerOptions.ConfigurationFile " + + "and OpcUaServerOptions.ConfigurationStream."); + } + if (hasConfigurationFile && + string.IsNullOrWhiteSpace(m_options.ConfigurationFile)) + { + throw new InvalidOperationException( + "OpcUaServerOptions.ConfigurationFile must not be a " + + "white-space path."); + } + + // An explicitly supplied configuration document is the most + // specific intent and therefore wins over a shared + // application registered via ConfigureApplication(...). + m_ownsApplication = true; + await LoadSuppliedApplicationConfigurationAsync( + certificateManager, + stoppingToken).ConfigureAwait(false); + } + else if (m_configurationProvider != null) { m_application = m_configurationProvider.Application; ApplyDependencyInjectedCertificateManager(certificateManager); @@ -254,7 +277,7 @@ namespace Opc.Ua.Server.Hosting } } - foreach (string url in urls) + foreach (string url in GetListenEndpointUrls(configuration)) { m_logger.OPCUAServerListeningAtEndpoint(url); } @@ -310,6 +333,86 @@ namespace Opc.Ua.Server.Hosting await securityOptions.CreateAsync(ct).ConfigureAwait(false); } + /// + /// Whether an existing configuration XML document was supplied via + /// or + /// . + /// + private bool HasSuppliedConfiguration => + !string.IsNullOrEmpty(m_options.ConfigurationFile) || + m_options.ConfigurationStream != null; + + /// + /// Loads the application configuration from the XML configuration + /// document supplied via + /// or so every + /// setting from the document is applied as-is, then applies the + /// optional + /// override callback. A supplied stream is read once and disposed. + /// + private async Task LoadSuppliedApplicationConfigurationAsync( + ICertificateManager? certificateManager, + CancellationToken ct) + { + IApplicationInstance application = m_applicationFactory.Create(m_telemetry); + m_application = application; + application.ApplicationType = ApplicationType.Server; + application.CertificatePasswordProvider = + m_services.GetService(); + + ApplicationConfiguration configuration; + if (m_options.ConfigurationStream is { } configurationStream) + { + m_logger.LoadingOPCUAServerConfigurationFromStream(); + using (configurationStream) + { + configuration = await application + .LoadApplicationConfigurationAsync(configurationStream, silent: false, ct) + .ConfigureAwait(false); + } + } + else + { + string configurationFile = m_options.ConfigurationFile!; + m_logger.LoadingOPCUAServerConfigurationFromFile(configurationFile); + configuration = await application + .LoadApplicationConfigurationAsync(configurationFile, silent: false, ct) + .ConfigureAwait(false); + } + + application.ApplicationName = configuration.ApplicationName; + m_options.ConfigureLoadedConfiguration?.Invoke(configuration); + ApplyDependencyInjectedCertificateManager(certificateManager); + } + + /// + /// The endpoint URLs to report as listening: the configured + /// when present, + /// otherwise the base addresses of the effective configuration + /// (e.g. when it was loaded from a configuration file). + /// + private string[] GetListenEndpointUrls(ApplicationConfiguration configuration) + { + if (m_options.EndpointUrls.Count > 0) + { + string[] urls = new string[m_options.EndpointUrls.Count]; + m_options.EndpointUrls.CopyTo(urls, 0); + return urls; + } + + if (configuration.ServerConfiguration is { } serverConfiguration) + { + string[] urls = new string[serverConfiguration.BaseAddresses.Count]; + for (int i = 0; i < urls.Length; i++) + { + urls[i] = serverConfiguration.BaseAddresses[i]; + } + return urls; + } + + return []; + } + private async Task BindKeyCredentialPushAsync(CancellationToken ct) { if (m_server == null) @@ -348,7 +451,9 @@ namespace Opc.Ua.Server.Hosting authenticators.Add(new AnonymousAuthenticator()); } - WarnForUnmatchedUserTokenPolicies(authenticators); + WarnForUnmatchedUserTokenPolicies( + authenticators, + m_application?.ApplicationConfiguration); foreach (IUserTokenAuthenticator authenticator in authenticators) { @@ -433,26 +538,58 @@ namespace Opc.Ua.Server.Hosting return certificates.Count > 0; } - private void WarnForUnmatchedUserTokenPolicies(IReadOnlyList authenticators) + private void WarnForUnmatchedUserTokenPolicies( + IReadOnlyList authenticators, + ApplicationConfiguration? configuration) { - IEnumerable policies = m_options.UserTokenPolicies.Count == 0 - ? [new OpcUaUserTokenPolicy { TokenType = UserTokenType.Anonymous }] - : m_options.UserTokenPolicies; - - foreach (OpcUaUserTokenPolicy policy in policies) + foreach (UserTokenType tokenType in GetAdvertisedUserTokenTypes(configuration)) { - if (policy.TokenType == UserTokenType.Anonymous) + if (tokenType == UserTokenType.Anonymous) { continue; } - if (!HasMatchingAuthenticator(policy.TokenType, authenticators)) + if (!HasMatchingAuthenticator(tokenType, authenticators)) { - m_logger.UserTokenPolicyTokenTypeIsConfiguredWithout(policy.TokenType); + m_logger.UserTokenPolicyTokenTypeIsConfiguredWithout(tokenType); } } } + /// + /// The user token types the server advertises: the option-configured + /// policies when present, the policies of the loaded configuration + /// document on the / + /// path, and the + /// anonymous fallback otherwise. + /// + private IEnumerable GetAdvertisedUserTokenTypes( + ApplicationConfiguration? configuration) + { + if (m_options.UserTokenPolicies.Count > 0) + { + foreach (OpcUaUserTokenPolicy policy in m_options.UserTokenPolicies) + { + yield return policy.TokenType; + } + yield break; + } + + if (HasSuppliedConfiguration && + configuration?.ServerConfiguration != null) + { + ArrayOf policies = + configuration.ServerConfiguration.UserTokenPolicies; + for (int i = 0; i < policies.Count; i++) + { + yield return policies[i].TokenType; + } + yield break; + } + + yield return UserTokenType.Anonymous; + } + private static bool HasMatchingAuthenticator( UserTokenType tokenType, IReadOnlyList authenticators) @@ -546,5 +683,15 @@ namespace Opc.Ua.Server.Hosting [LoggerMessage(EventId = ServerEventIds.OpcUaServerHostedService + 4, Level = LogLevel.Warning, Message = "Error while stopping OPC UA server.")] public static partial void ErrorWhileStoppingOPCUAServer(this ILogger logger, Exception ex); + + [LoggerMessage(EventId = ServerEventIds.OpcUaServerHostedService + 5, Level = LogLevel.Information, + Message = "Loading OPC UA server configuration from file {ConfigurationFile}.")] + public static partial void LoadingOPCUAServerConfigurationFromFile( + this ILogger logger, + string configurationFile); + + [LoggerMessage(EventId = ServerEventIds.OpcUaServerHostedService + 6, Level = LogLevel.Information, + Message = "Loading OPC UA server configuration from a stream.")] + public static partial void LoadingOPCUAServerConfigurationFromStream(this ILogger logger); } } diff --git a/src/Opc.Ua.Server/Hosting/OpcUaServerOptions.cs b/src/Opc.Ua.Server/Hosting/OpcUaServerOptions.cs index 2fb60b0551..59c316d04e 100644 --- a/src/Opc.Ua.Server/Hosting/OpcUaServerOptions.cs +++ b/src/Opc.Ua.Server/Hosting/OpcUaServerOptions.cs @@ -29,6 +29,7 @@ using System; using System.Collections.Generic; +using System.IO; using Opc.Ua.Configuration; namespace Opc.Ua.Server.Hosting @@ -43,7 +44,9 @@ namespace Opc.Ua.Server.Hosting /// server-policy and server-option builder stages. Use /// through /// ConfigureApplication(...) for post-security certificate and - /// validation settings. + /// validation settings. Set to load the + /// configuration from an existing OPC UA XML configuration file instead + /// of building it from these options. /// public sealed class OpcUaServerOptions { @@ -193,6 +196,64 @@ namespace Opc.Ua.Server.Hosting /// public OperationLimitsOptions? OperationLimits { get; set; } + /// + /// Optional path to a classic OPC UA application configuration XML + /// file (e.g. MyServer.Config.xml). When set, the hosted + /// service loads the from this + /// file instead of building one from the other options, so existing + /// applications can adopt dependency injection and keep every setting + /// from their configuration file (base addresses, security policies, + /// certificate stores, transport quotas, operation limits, ...). + /// + /// + /// + /// A relative path is resolved against the current working directory. + /// The file must validate as a + /// (or client-and-server) configuration. + /// + /// + /// Because the file is authoritative, the options that feed the + /// configuration builder (, + /// , , the policy + /// toggles, transport quotas, , ...) + /// are not applied on this path. Use + /// for programmatic + /// overrides of the loaded file. Options that act on the hosted + /// server rather than the configuration (, + /// ) keep working. 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 hosted service loads the + /// from this stream instead of building one from the other options, + /// with the same semantics as . + /// + /// + /// The stream must remain open until the host starts the server; it + /// is read once and disposed by the hosted service during startup. + /// 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 certificates are checked and the + /// server starts. Use it to override individual settings from code + /// without editing the document. Ignored when neither + /// nor + /// is set. + /// + public Action? ConfigureLoadedConfiguration { get; set; } + /// /// Optional escape hatch invoked after the standard transport-quota, /// server-policy, and server-option steps, but before the application @@ -200,7 +261,8 @@ namespace Opc.Ua.Server.Hosting /// policies or override server options. Use /// through /// ConfigureApplication(...) for post-security certificate and - /// validation settings. + /// validation settings. Not applied when + /// or is set. /// public Action? ConfigureBuilder { get; set; } diff --git a/src/Opc.Ua.Server/NugetREADME.md b/src/Opc.Ua.Server/NugetREADME.md index 717ce75674..8adab0c883 100644 --- a/src/Opc.Ua.Server/NugetREADME.md +++ b/src/Opc.Ua.Server/NugetREADME.md @@ -34,7 +34,11 @@ public sealed class MyServer : StandardServer Then start the server via `ApplicationInstance.StartAsync` (in the `OPCFoundation.NetStandard.Opc.Ua.Configuration` package), or host it -through the fluent `services.AddOpcUa().AddServer(...)` DI surface. The +through the fluent `services.AddOpcUa().AddServer(...)` DI surface. +Applications that already own a classic configuration XML file can pass +it directly — `AddServer("MyServer.Config.xml")`, or as a `Stream` for +embedded resources — and keep every setting from the file while +adopting dependency injection. The raw-socket `opc.tcp://` binding is built in; reference the `OPCFoundation.NetStandard.Opc.Ua.Bindings.Https` package to additionally expose an `https://` endpoint. diff --git a/tests/Opc.Ua.Server.Tests/Hosting/ConfigurationFileHostingTests.cs b/tests/Opc.Ua.Server.Tests/Hosting/ConfigurationFileHostingTests.cs new file mode 100644 index 0000000000..0004529af7 --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/Hosting/ConfigurationFileHostingTests.cs @@ -0,0 +1,849 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Opc.Ua.Server.Hosting; + +namespace Opc.Ua.Server.Tests.Hosting +{ + /// + /// Exercises the AddServer(configurationFile) and + /// AddServer(configurationStream) migration paths: a hosted OPC UA + /// server whose is loaded from an + /// existing OPC UA XML configuration document instead of being built from + /// . Covers the new IOpcUaBuilder + /// overloads, the / + /// options and the + /// configuration-section binding, the + /// + /// override callback, precedence over ConfigureApplication(...), + /// mutual exclusion of file and stream, startup failure for missing + /// files, and the user-token-policy warning derived from the loaded + /// document. + /// + [TestFixture] + [Category("Server")] + [Category("Hosting")] + [NonParallelizable] + public sealed class ConfigurationFileHostingTests + { + [Test] + public async Task AddServerAppliesAllSettingsFromConfigurationFileAsync() + { + ConfigurationCaptureServer.Reset(); + const string applicationName = "CfgXmlApplyServer"; + string testRoot = CreateTestRoot(); + int port = GetAvailablePort(); + string configurationFile = WriteConfigurationFile(testRoot, applicationName, port); + var loggerProvider = new CapturingLoggerProvider(); + + await using HostedServerFixture fixture = await HostedServerFixture.StartAsync( + services => + { + services.AddLogging(builder => builder.AddProvider(loggerProvider)); + services.AddOpcUa().AddServer(configurationFile); + }).ConfigureAwait(false); + + Assert.That( + await WaitForAsync( + () => ConfigurationCaptureServer.Started, + TimeSpan.FromSeconds(60)).ConfigureAwait(false), + Is.True, + "The hosted server did not start from the configuration file."); + + ApplicationConfiguration configuration = + ConfigurationCaptureServer.StartedConfiguration ?? + throw new InvalidOperationException("No configuration was captured."); + + // Every setting captured at server start must come from the file. + 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.ProductUri, + Is.EqualTo("uri:opcfoundation.org:test:" + applicationName)); + Assert.That(configuration.TransportQuotas, Is.Not.Null); + Assert.That(configuration.TransportQuotas!.MaxStringLength, Is.EqualTo(654321)); + Assert.That(configuration.TransportQuotas.OperationTimeout, Is.EqualTo(90000)); + Assert.That(configuration.ServerConfiguration, Is.Not.Null); + Assert.That(configuration.ServerConfiguration!.MaxSessionCount, Is.EqualTo(77)); + Assert.That(configuration.ServerConfiguration.BaseAddresses.Count, Is.EqualTo(1)); + string expectedBaseAddress = Utils.ReplaceLocalhost(FormatBaseAddress(applicationName, port))!; + Assert.That( + configuration.ServerConfiguration.BaseAddresses[0], + Is.EqualTo(expectedBaseAddress)); + Assert.That( + configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates, + Is.True); + Assert.That( + configuration.SecurityConfiguration.ApplicationCertificates[0].StorePath, + Is.EqualTo(GetPkiRoot(testRoot) + "/own")); + + // The hosted service reports the listen endpoints from the loaded + // file because OpcUaServerOptions.EndpointUrls is empty on this path. + Assert.That( + await WaitForAsync( + () => loggerProvider.Messages.Any(message => + message.Contains("listening", StringComparison.OrdinalIgnoreCase) && + message.Contains(expectedBaseAddress, StringComparison.Ordinal)), + TimeSpan.FromSeconds(30)).ConfigureAwait(false), + Is.True, + "The endpoint from the configuration file was not reported as listening."); + } + + [Test] + public async Task AddServerStartsDefaultServerFromConfigurationFileAsync() + { + string testRoot = CreateTestRoot(); + string configurationFile = WriteConfigurationFile( + testRoot, + "CfgXmlDefaultServer", + GetAvailablePort()); + var startupTask = new RecordingStartupTask(); + + await using HostedServerFixture fixture = await HostedServerFixture.StartAsync( + services => + { + services.AddLogging(); + services.AddSingleton(startupTask); + services.AddOpcUa().AddServer(configurationFile); + }).ConfigureAwait(false); + + Assert.That( + await WaitForAsync( + () => startupTask.InvocationCount > 0, + TimeSpan.FromSeconds(60)).ConfigureAwait(false), + Is.True, + "The default hosted server did not start from the configuration file."); + + OpcUaServerOptions options = fixture.Services + .GetRequiredService>().Value; + Assert.That(options.ConfigurationFile, Is.EqualTo(configurationFile)); + } + + [Test] + public async Task ConfigureLoadedConfigurationOverridesIndividualSettingsAsync() + { + ConfigurationCaptureServer.Reset(); + string testRoot = CreateTestRoot(); + string configurationFile = WriteConfigurationFile( + testRoot, + "CfgXmlOverrideServer", + GetAvailablePort()); + + await using HostedServerFixture fixture = await HostedServerFixture.StartAsync( + services => + { + services.AddLogging(); + services.AddOpcUa().AddServer( + configurationFile, + configuration => + configuration.ServerConfiguration!.MaxSessionCount = 4242); + }).ConfigureAwait(false); + + Assert.That( + await WaitForAsync( + () => ConfigurationCaptureServer.Started, + TimeSpan.FromSeconds(60)).ConfigureAwait(false), + Is.True); + + ApplicationConfiguration configuration = + ConfigurationCaptureServer.StartedConfiguration ?? + throw new InvalidOperationException("No configuration was captured."); + + // The callback override wins over the file value while every + // untouched setting keeps its file value. + Assert.That(configuration.ServerConfiguration!.MaxSessionCount, Is.EqualTo(4242)); + Assert.That(configuration.TransportQuotas!.MaxStringLength, Is.EqualTo(654321)); + Assert.That(configuration.SourceFilePath, Is.EqualTo(configurationFile)); + } + + [Test] + public async Task ConfigurationFileTakesPrecedenceOverConfigureApplicationAsync() + { + ConfigurationCaptureServer.Reset(); + const string applicationName = "CfgXmlPrecedenceServer"; + string testRoot = CreateTestRoot(); + string configurationFile = WriteConfigurationFile( + testRoot, + applicationName, + GetAvailablePort()); + + await using HostedServerFixture fixture = await HostedServerFixture.StartAsync( + services => + { + services.AddLogging(); + services.AddOpcUa() + .ConfigureApplication(options => + { + options.ApplicationName = "SharedHostedApplication"; + options.ApplicationUri = "urn:localhost:SharedHostedApplication"; + options.PkiRoot = Path.Combine(testRoot, "sharedpki"); + options.AutoAcceptUntrustedCertificates = true; + }) + .AddServer(configurationFile); + }).ConfigureAwait(false); + + Assert.That( + await WaitForAsync( + () => ConfigurationCaptureServer.Started, + TimeSpan.FromSeconds(60)).ConfigureAwait(false), + Is.True); + + ApplicationConfiguration configuration = + ConfigurationCaptureServer.StartedConfiguration ?? + throw new InvalidOperationException("No configuration was captured."); + + Assert.That(configuration.SourceFilePath, Is.EqualTo(configurationFile)); + Assert.That(configuration.ApplicationName, Is.EqualTo(applicationName)); + } + + [Test] + public async Task UnmatchedUserNamePolicyFromConfigurationFileLogsWarningAsync() + { + string testRoot = CreateTestRoot(); + string configurationFile = WriteConfigurationFile( + testRoot, + "CfgXmlWarnServer", + GetAvailablePort(), + AnonymousTokenPolicyXml + UserNameTokenPolicyXml); + var loggerProvider = new CapturingLoggerProvider(); + + await using HostedServerFixture fixture = await HostedServerFixture.StartAsync( + services => + { + services.AddLogging(builder => builder.AddProvider(loggerProvider)); + // No user database / user management is registered, so the + // UserName policy advertised by the file has no matching + // authenticator and the hosted service must warn. + services.AddOpcUa().AddServer(configurationFile); + }).ConfigureAwait(false); + + Assert.That( + await WaitForAsync( + () => loggerProvider.Messages.Any(message => + message.Contains("UserName", StringComparison.Ordinal) && + message.Contains( + "without a matching identity authenticator", + StringComparison.Ordinal)), + TimeSpan.FromSeconds(60)).ConfigureAwait(false), + Is.True, + "Expected a warning for the unmatched UserName token policy from the file."); + } + + [Test] + public async Task MissingConfigurationFileFailsStartupAsync() + { + string missingFile = Path.Combine(CreateTestRoot(), "DoesNotExist.Config.xml"); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOpcUa().AddServer(missingFile); + + Exception? failure = await StartAndCaptureStartupFailureAsync(services) + .ConfigureAwait(false); + + Assert.That(failure, Is.InstanceOf()); + } + + [Test] + public async Task AddServerAppliesSettingsFromConfigurationStreamAsync() + { + ConfigurationCaptureServer.Reset(); + const string applicationName = "CfgXmlStreamServer"; + string testRoot = CreateTestRoot(); + var stream = new MemoryStream(Encoding.UTF8.GetBytes(BuildConfigurationXml( + applicationName, + GetAvailablePort(), + GetPkiRoot(testRoot)))); + + await using HostedServerFixture fixture = await HostedServerFixture.StartAsync( + services => + { + services.AddLogging(); + services.AddOpcUa().AddServer( + stream, + configuration => + configuration.ServerConfiguration!.MaxSessionCount = 555); + }).ConfigureAwait(false); + + Assert.That( + await WaitForAsync( + () => ConfigurationCaptureServer.Started, + TimeSpan.FromSeconds(60)).ConfigureAwait(false), + Is.True, + "The hosted server did not start from the configuration stream."); + + ApplicationConfiguration configuration = + ConfigurationCaptureServer.StartedConfiguration ?? + throw new InvalidOperationException("No configuration was captured."); + + // Settings come from the stream document; a stream has no source + // file path; the override callback wins over the document value. + Assert.That(configuration.SourceFilePath, Is.Null); + Assert.That(configuration.ApplicationName, Is.EqualTo(applicationName)); + Assert.That(configuration.TransportQuotas!.MaxStringLength, Is.EqualTo(654321)); + Assert.That(configuration.ServerConfiguration!.MaxSessionCount, Is.EqualTo(555)); + Assert.That(configuration.ServerConfiguration.BaseAddresses.Count, Is.EqualTo(1)); + + // The hosted service disposes the stream after reading it. + Assert.That(stream.CanRead, Is.False); + } + + [Test] + public async Task AddServerStartsDefaultServerFromConfigurationStreamAsync() + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(BuildConfigurationXml( + "CfgXmlStreamDefault", + GetAvailablePort(), + GetPkiRoot(CreateTestRoot())))); + var startupTask = new RecordingStartupTask(); + + await using HostedServerFixture fixture = await HostedServerFixture.StartAsync( + services => + { + services.AddLogging(); + services.AddSingleton(startupTask); + services.AddOpcUa().AddServer(stream); + }).ConfigureAwait(false); + + Assert.That( + await WaitForAsync( + () => startupTask.InvocationCount > 0, + TimeSpan.FromSeconds(60)).ConfigureAwait(false), + Is.True, + "The default hosted server did not start from the configuration stream."); + + OpcUaServerOptions options = fixture.Services + .GetRequiredService>().Value; + Assert.That(options.ConfigurationStream, Is.SameAs(stream)); + } + + [Test] + public async Task WhiteSpaceConfigurationFileFailsStartupWithClearErrorAsync() + { + // A white-space path can reach the options through configuration + // binding or the options callback, bypassing the AddServer + // argument validation. Startup must fail with a clear error + // instead of a confusing file-load failure. + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOpcUa().AddServer(options => options.ConfigurationFile = " "); + + Exception? failure = await StartAndCaptureStartupFailureAsync(services) + .ConfigureAwait(false); + + Assert.That(failure, Is.InstanceOf()); + Assert.That(failure!.Message, Does.Contain("white-space")); + } + + [Test] + public async Task ConfigurationFileAndStreamTogetherFailStartupAsync() + { + using var stream = new MemoryStream([1, 2, 3]); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOpcUa().AddServer(options => + { + options.ConfigurationFile = "Some.Config.xml"; + options.ConfigurationStream = stream; + }); + + Exception? failure = await StartAndCaptureStartupFailureAsync(services) + .ConfigureAwait(false); + + Assert.That(failure, Is.InstanceOf()); + Assert.That(failure!.Message, Does.Contain("only one")); + } + + [Test] + public void AddServerConfigurationFileValidatesArguments() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + Assert.That( + () => default(IOpcUaBuilder)!.AddServer("Server.Config.xml"), + Throws.ArgumentNullException.With + .Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder")); + Assert.That( + () => builder.AddServer((string)null!), + Throws.ArgumentNullException.With + .Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationFile")); + Assert.That( + () => builder.AddServer(" "), + Throws.TypeOf().With + .Property(nameof(ArgumentException.ParamName)).EqualTo("configurationFile")); + + Assert.That( + () => default(IOpcUaBuilder)!.AddServer( + "Server.Config.xml"), + Throws.ArgumentNullException.With + .Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder")); + Assert.That( + () => builder.AddServer((string)null!), + Throws.ArgumentNullException.With + .Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationFile")); + Assert.That( + () => builder.AddServer(" "), + Throws.TypeOf().With + .Property(nameof(ArgumentException.ParamName)).EqualTo("configurationFile")); + + using var stream = new MemoryStream(); + Assert.That( + () => default(IOpcUaBuilder)!.AddServer(stream), + Throws.ArgumentNullException.With + .Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder")); + Assert.That( + () => builder.AddServer((Stream)null!), + Throws.ArgumentNullException.With + .Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationStream")); + Assert.That( + () => default(IOpcUaBuilder)!.AddServer(stream), + Throws.ArgumentNullException.With + .Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder")); + Assert.That( + () => builder.AddServer((Stream)null!), + Throws.ArgumentNullException.With + .Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationStream")); + } + + /// + /// Builds the provider, starts the single hosted service, and returns + /// the exception the startup failed with (from a synchronous + /// StartAsync fault or the faulted execute task), or + /// null when startup did not fail promptly. Disposes the + /// provider before returning. + /// + private static async Task StartAndCaptureStartupFailureAsync( + ServiceCollection services) + { + ServiceProvider provider = services.BuildServiceProvider(); + try + { + IHostedService hostedService = provider.GetServices().Single(); + try + { + await hostedService.StartAsync(CancellationToken.None).ConfigureAwait(false); + Task? executeTask = (hostedService as BackgroundService)?.ExecuteTask; + if (executeTask == null) + { + return null; + } + + Task completed = await Task.WhenAny( + executeTask, + Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false); + if (!ReferenceEquals(completed, executeTask)) + { + return null; + } + return executeTask.Exception?.GetBaseException(); + } + catch (Exception ex) + { + return ex; + } + } + finally + { + await provider.DisposeAsync().ConfigureAwait(false); + } + } + + [Test] + public void AddServerConfigurationFileThrowsWhenServerAlreadyRegistered() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddServer("First.Config.xml"); + + Assert.That( + () => builder.AddServer("Second.Config.xml"), + Throws.InvalidOperationException); + Assert.That( + () => builder.AddServer("Third.Config.xml"), + Throws.InvalidOperationException); + } + + [Test] + public void AddServerBindsConfigurationFileFromConfigurationSection() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OpcUa:Server:ApplicationName"] = "BoundConfigFileServer", + ["OpcUa:Server:ConfigurationFile"] = "Legacy/Server.Config.xml" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + using ServiceProvider provider = services.AddOpcUa() + .AddServer(configuration) + .Services.BuildServiceProvider(); + + OpcUaServerOptions options = provider + .GetRequiredService>().Value; + + Assert.That(options.ConfigurationFile, Is.EqualTo("Legacy/Server.Config.xml")); + Assert.That(options.ApplicationName, Is.EqualTo("BoundConfigFileServer")); + } + + private const string AnonymousTokenPolicyXml = """ + + Anonymous_0 + + """; + + private const string UserNameTokenPolicyXml = """ + + UserName_1 + + """; + + 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, + "cfgxml", + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(testRoot); + return testRoot; + } + + private static string GetPkiRoot(string testRoot) + { + return Path.Combine(testRoot, "pki").Replace('\\', '/'); + } + + private static string FormatBaseAddress(string applicationName, int port) + { + return "opc.tcp://localhost:" + + port.ToString(CultureInfo.InvariantCulture) + + "/" + + applicationName; + } + + /// + /// Writes a classic OPC UA server configuration XML file of the shape + /// existing applications carry (security configuration, transport + /// quotas, server configuration) with distinctive values the tests + /// can assert on. + /// + private static string WriteConfigurationFile( + string testRoot, + string applicationName, + int port, + string? userTokenPoliciesXml = null) + { + string configurationFile = Path.Combine(testRoot, applicationName + ".Config.xml"); + File.WriteAllText( + configurationFile, + BuildConfigurationXml(applicationName, port, GetPkiRoot(testRoot), userTokenPoliciesXml)); + return configurationFile; + } + + /// + /// Builds the classic OPC UA server configuration XML document used + /// by both the file-based and the stream-based tests. + /// + private static string BuildConfigurationXml( + string applicationName, + int port, + string pkiRoot, + string? userTokenPoliciesXml = null) + { + return $""" + + + {applicationName} + urn:opcfoundation:test:{applicationName} + uri:opcfoundation.org:test:{applicationName} + Server_0 + + + + 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 + + + + {FormatBaseAddress(applicationName, port)} + + + + None_1 + http://opcfoundation.org/UA/SecurityPolicy#None + + + + {userTokenPoliciesXml ?? AnonymousTokenPolicyXml} + + false + 77 + 10000 + 3600000 + + + """; + } + + private static int GetAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static async Task WaitForAsync(Func condition, TimeSpan timeout) + { + DateTime deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return true; + } + await Task.Delay(100).ConfigureAwait(false); + } + return condition(); + } + + /// + /// A that captures the effective + /// the hosted service starts + /// it with, so tests can assert that the settings of the loaded + /// configuration file were applied. + /// + public sealed class ConfigurationCaptureServer : StandardServer + { + public ConfigurationCaptureServer(ITelemetryContext telemetry, TimeProvider timeProvider) + : base(telemetry, timeProvider) + { + } + + public static ApplicationConfiguration? StartedConfiguration { get; private set; } + + public static bool Started { get; private set; } + + public static void Reset() + { + StartedConfiguration = null; + Started = false; + } + + protected override void OnServerStarting(ApplicationConfiguration configuration) + { + StartedConfiguration = configuration; + base.OnServerStarting(configuration); + } + + protected override void OnServerStarted(IServerInternal server) + { + Started = true; + base.OnServerStarted(server); + } + } + + private sealed class RecordingStartupTask : IServerStartupTask + { + public int InvocationCount => Volatile.Read(ref m_invocationCount); + + public ValueTask OnServerStartedAsync( + IServerContext server, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref m_invocationCount); + return default; + } + + private int m_invocationCount; + } + + private sealed class HostedServerFixture : IAsyncDisposable + { + private HostedServerFixture(ServiceProvider provider, IHostedService hostedService) + { + m_provider = provider; + m_hostedService = hostedService; + } + + public static async ValueTask StartAsync( + Action configureServices) + { + var services = new ServiceCollection(); + configureServices(services); + ServiceProvider provider = services.BuildServiceProvider(); + IHostedService hostedService = provider.GetServices().Single(); + try + { + await hostedService.StartAsync(CancellationToken.None).ConfigureAwait(false); + return new HostedServerFixture(provider, hostedService); + } + catch + { + await provider.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + public IServiceProvider Services => m_provider; + + public async ValueTask DisposeAsync() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + try + { + await m_hostedService.StopAsync(cts.Token).ConfigureAwait(false); + } + finally + { + await m_provider.DisposeAsync().ConfigureAwait(false); + } + } + + private readonly ServiceProvider m_provider; + private readonly IHostedService m_hostedService; + } + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentBag Messages { get; } = []; + + public ILogger CreateLogger(string categoryName) + { + return new CapturingLogger(Messages); + } + + public void Dispose() + { + } + } + + private sealed class CapturingLogger : ILogger + { + public CapturingLogger(ConcurrentBag messages) + { + m_messages = messages; + } + + public IDisposable BeginScope(TState state) + where TState : notnull + { + return NoopDisposable.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + m_messages.Add(formatter(state, exception)); + } + + private readonly ConcurrentBag m_messages; + } + + private sealed class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new(); + + public void Dispose() + { + } + } + } +}