Allow passing an existing config XML to the hosted server DI AddServer (#4325)

# Description

Extends the hosted-server dependency-injection surface so applications
that already own a classic `ApplicationConfiguration` XML file
(`*.Config.xml`) have a direct migration path to DI with **all** of
their existing settings applied.

New API surface:

- `AddServer(string configurationFile, Action<ApplicationConfiguration>?
configure = null)` and the `AddServer<TServer>` variant load the
configuration from the file instead of building it from
`OpcUaServerOptions`.
- `AddServer(Stream configurationStream, ...)` (plus generic variant)
for documents that are not files on disk, e.g. embedded resources. The
stream is read once and disposed by the hosted service during startup.
- `OpcUaServerOptions.ConfigurationFile` / `ConfigurationStream` back
the overloads, so the options-callback route works too and the file path
can be bound from configuration (`OpcUa:Server:ConfigurationFile`).
- `OpcUaServerOptions.ConfigureLoadedConfiguration` runs after the
document is loaded and validated but before certificates are checked, so
individual settings can be overridden from code without editing the
file.

Behavior:

- The document is loaded through
`ApplicationInstance.LoadApplicationConfigurationAsync`, so every
setting (base addresses, security policies, certificate stores,
transport quotas, operation limits, user token policies) applies exactly
as on the classic path, including localhost fix-up.
- The supplied document is authoritative: the builder-feeding options
(`ApplicationName`, `EndpointUrls`, `PkiRoot`, policy toggles,
`ConfigureBuilder`, ...) are not applied on this path, and it takes
precedence over a shared application registered with
`ConfigureApplication(...)`. Options acting on the hosted server itself
(`Identity`, `ConfigureRateLimits`) and all fluent registrations
(`AddNodeManager`, `ConfigureRoles`, identity authenticators, ...)
compose unchanged.
- DI-registered `ICertificateManager` / `ICertificatePasswordProvider`
are honored, the "listening at" log reports the loaded base addresses,
and the unmatched user-token-policy startup warning is derived from the
policies the document advertises.
- Setting both `ConfigurationFile` and `ConfigurationStream` fails
startup with a clear `InvalidOperationException`; a missing file fails
startup with the original `ServiceResultException`.

Coverage: new `ConfigurationFileHostingTests` fixture (12 tests) with
end-to-end hosted starts from file and stream asserting the loaded
settings, custom `TServer` types, the override callback, precedence over
`ConfigureApplication`, warning derivation from the document,
missing-file and file+stream startup failures, argument validation, the
duplicate-registration guard, and configuration-section binding.

Docs: new "Migrating with an existing configuration XML file" section in
`docs/DependencyInjection.md` (plus first-class options table rows), a
pointer in the 2.0 migration guide, and a NuGet README mention.

## Related Issues

None — additive convenience overloads on the existing hosted-server
dependency-injection surface; no behavior change for existing
registrations.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
romanett
2026-08-29 10:48:24 +02:00
committed by GitHub
parent 41db5aca60
commit b9657e5f0d
7 changed files with 1355 additions and 17 deletions
+75
View File
@@ -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<MyNodeManagerFactory>();
```
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>("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<MyNodeManagerFactory>();
```
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. |
+1
View File
@@ -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`.
@@ -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);
}
/// <summary>
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
/// whose <see cref="ApplicationConfiguration"/> is loaded from an
/// existing OPC UA XML configuration file
/// (e.g. <c>MyServer.Config.xml</c>).
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>ApplicationInstance.LoadApplicationConfigurationAsync</c>.
/// Node managers, identity authenticators, roles and the other
/// <see cref="IOpcUaServerBuilder"/> registrations compose with the
/// loaded file the same way they compose with option-built
/// configurations.
/// </para>
/// <para>
/// Equivalent to
/// <see cref="AddServer(IOpcUaBuilder, Action{OpcUaServerOptions})"/>
/// with <see cref="OpcUaServerOptions.ConfigurationFile"/> and
/// <see cref="OpcUaServerOptions.ConfigureLoadedConfiguration"/> set.
/// </para>
/// </remarks>
/// <param name="builder">The OPC UA builder.</param>
/// <param name="configurationFile">Path to the application
/// configuration XML file. A relative path is resolved against the
/// current working directory.</param>
/// <param name="configure">Optional callback invoked with the loaded
/// <see cref="ApplicationConfiguration"/> before certificates are
/// checked and the server starts; use it to override individual
/// settings from code.</param>
/// <returns>An <see cref="IOpcUaServerBuilder"/> for chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
/// or <paramref name="configurationFile"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException"><paramref name="configurationFile"/>
/// is empty or white space.</exception>
/// <exception cref="InvalidOperationException">An OPC UA server
/// is already registered.</exception>
public static IOpcUaServerBuilder AddServer(
this IOpcUaBuilder builder,
string configurationFile,
Action<ApplicationConfiguration>? configure = null)
{
ValidateConfigurationFilePath(configurationFile);
return builder.AddServer(options =>
{
options.ConfigurationFile = configurationFile;
options.ConfigureLoadedConfiguration = configure;
});
}
/// <summary>
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
/// using a custom <see cref="StandardServer"/> subclass whose
/// <see cref="ApplicationConfiguration"/> is loaded from an existing
/// OPC UA XML configuration file (e.g. <c>MyServer.Config.xml</c>).
/// </summary>
/// <remarks>
/// See <see cref="AddServer(IOpcUaBuilder, string, Action{ApplicationConfiguration})"/>
/// for how the configuration file is applied.
/// </remarks>
/// <typeparam name="TServer">The server type created by the hosted service.</typeparam>
/// <param name="builder">The OPC UA builder.</param>
/// <param name="configurationFile">Path to the application
/// configuration XML file. A relative path is resolved against the
/// current working directory.</param>
/// <param name="configure">Optional callback invoked with the loaded
/// <see cref="ApplicationConfiguration"/> before certificates are
/// checked and the server starts.</param>
/// <returns>An <see cref="IOpcUaServerBuilder"/> for chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
/// or <paramref name="configurationFile"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException"><paramref name="configurationFile"/>
/// is empty or white space.</exception>
/// <exception cref="InvalidOperationException">An OPC UA server
/// is already registered.</exception>
public static IOpcUaServerBuilder AddServer<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TServer>(
this IOpcUaBuilder builder,
string configurationFile,
Action<ApplicationConfiguration>? configure = null)
where TServer : StandardServer
{
ValidateConfigurationFilePath(configurationFile);
return builder.AddServer<TServer>(options =>
{
options.ConfigurationFile = configurationFile;
options.ConfigureLoadedConfiguration = configure;
});
}
/// <summary>
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
/// whose <see cref="ApplicationConfiguration"/> is loaded from a
/// stream containing an OPC UA XML configuration document, e.g. an
/// embedded resource.
/// </summary>
/// <remarks>
/// <para>
/// See <see cref="AddServer(IOpcUaBuilder, string, Action{ApplicationConfiguration})"/>
/// for how the loaded configuration is applied. Equivalent to
/// <see cref="AddServer(IOpcUaBuilder, Action{OpcUaServerOptions})"/>
/// with <see cref="OpcUaServerOptions.ConfigurationStream"/> and
/// <see cref="OpcUaServerOptions.ConfigureLoadedConfiguration"/> set.
/// </para>
/// <para>
/// The stream must remain open until the host starts the server; it
/// is read once and disposed by the hosted service during startup.
/// </para>
/// </remarks>
/// <param name="builder">The OPC UA builder.</param>
/// <param name="configurationStream">Stream containing the
/// application configuration XML document.</param>
/// <param name="configure">Optional callback invoked with the loaded
/// <see cref="ApplicationConfiguration"/> before certificates are
/// checked and the server starts; use it to override individual
/// settings from code.</param>
/// <returns>An <see cref="IOpcUaServerBuilder"/> for chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
/// or <paramref name="configurationStream"/> is <c>null</c>.</exception>
/// <exception cref="InvalidOperationException">An OPC UA server
/// is already registered.</exception>
public static IOpcUaServerBuilder AddServer(
this IOpcUaBuilder builder,
Stream configurationStream,
Action<ApplicationConfiguration>? configure = null)
{
if (configurationStream is null)
{
throw new ArgumentNullException(nameof(configurationStream));
}
return builder.AddServer(options =>
{
options.ConfigurationStream = configurationStream;
options.ConfigureLoadedConfiguration = configure;
});
}
/// <summary>
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
/// using a custom <see cref="StandardServer"/> subclass whose
/// <see cref="ApplicationConfiguration"/> is loaded from a stream
/// containing an OPC UA XML configuration document, e.g. an embedded
/// resource.
/// </summary>
/// <remarks>
/// See <see cref="AddServer(IOpcUaBuilder, Stream, Action{ApplicationConfiguration})"/>
/// for how the stream is applied and its lifetime.
/// </remarks>
/// <typeparam name="TServer">The server type created by the hosted service.</typeparam>
/// <param name="builder">The OPC UA builder.</param>
/// <param name="configurationStream">Stream containing the
/// application configuration XML document.</param>
/// <param name="configure">Optional callback invoked with the loaded
/// <see cref="ApplicationConfiguration"/> before certificates are
/// checked and the server starts.</param>
/// <returns>An <see cref="IOpcUaServerBuilder"/> for chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
/// or <paramref name="configurationStream"/> is <c>null</c>.</exception>
/// <exception cref="InvalidOperationException">An OPC UA server
/// is already registered.</exception>
public static IOpcUaServerBuilder AddServer<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TServer>(
this IOpcUaBuilder builder,
Stream configurationStream,
Action<ApplicationConfiguration>? configure = null)
where TServer : StandardServer
{
if (configurationStream is null)
{
throw new ArgumentNullException(nameof(configurationStream));
}
return builder.AddServer<TServer>(options =>
{
options.ConfigurationStream = configurationStream;
options.ConfigureLoadedConfiguration = configure;
});
}
/// <summary>
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
/// with options bound from the supplied <paramref name="configuration"/>
@@ -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),
@@ -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<ICertificateManager>();
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);
}
/// <summary>
/// Whether an existing configuration XML document was supplied via
/// <see cref="OpcUaServerOptions.ConfigurationFile"/> or
/// <see cref="OpcUaServerOptions.ConfigurationStream"/>.
/// </summary>
private bool HasSuppliedConfiguration =>
!string.IsNullOrEmpty(m_options.ConfigurationFile) ||
m_options.ConfigurationStream != null;
/// <summary>
/// Loads the application configuration from the XML configuration
/// document supplied via <see cref="OpcUaServerOptions.ConfigurationFile"/>
/// or <see cref="OpcUaServerOptions.ConfigurationStream"/> so every
/// setting from the document is applied as-is, then applies the
/// optional <see cref="OpcUaServerOptions.ConfigureLoadedConfiguration"/>
/// override callback. A supplied stream is read once and disposed.
/// </summary>
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<ICertificatePasswordProvider>();
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);
}
/// <summary>
/// The endpoint URLs to report as listening: the configured
/// <see cref="OpcUaServerOptions.EndpointUrls"/> when present,
/// otherwise the base addresses of the effective configuration
/// (e.g. when it was loaded from a configuration file).
/// </summary>
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<IUserTokenAuthenticator> authenticators)
private void WarnForUnmatchedUserTokenPolicies(
IReadOnlyList<IUserTokenAuthenticator> authenticators,
ApplicationConfiguration? configuration)
{
IEnumerable<OpcUaUserTokenPolicy> 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);
}
}
}
/// <summary>
/// The user token types the server advertises: the option-configured
/// policies when present, the policies of the loaded configuration
/// document on the <see cref="OpcUaServerOptions.ConfigurationFile"/> /
/// <see cref="OpcUaServerOptions.ConfigurationStream"/> path, and the
/// anonymous fallback otherwise.
/// </summary>
private IEnumerable<UserTokenType> 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<UserTokenPolicy> 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<IUserTokenAuthenticator> 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);
}
}
@@ -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
/// <see cref="OpcUaApplicationOptions.ConfigureSecurity"/> through
/// <c>ConfigureApplication(...)</c> for post-security certificate and
/// validation settings.
/// validation settings. Set <see cref="ConfigurationFile"/> to load the
/// configuration from an existing OPC UA XML configuration file instead
/// of building it from these options.
/// </remarks>
public sealed class OpcUaServerOptions
{
@@ -193,6 +196,64 @@ namespace Opc.Ua.Server.Hosting
/// </summary>
public OperationLimitsOptions? OperationLimits { get; set; }
/// <summary>
/// Optional path to a classic OPC UA application configuration XML
/// file (e.g. <c>MyServer.Config.xml</c>). When set, the hosted
/// service loads the <see cref="ApplicationConfiguration"/> 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, ...).
/// </summary>
/// <remarks>
/// <para>
/// A relative path is resolved against the current working directory.
/// The file must validate as a <see cref="ApplicationType.Server"/>
/// (or client-and-server) configuration.
/// </para>
/// <para>
/// Because the file is authoritative, the options that feed the
/// configuration builder (<see cref="ApplicationName"/>,
/// <see cref="EndpointUrls"/>, <see cref="PkiRoot"/>, the policy
/// toggles, transport quotas, <see cref="ConfigureBuilder"/>, ...)
/// are not applied on this path. Use
/// <see cref="ConfigureLoadedConfiguration"/> for programmatic
/// overrides of the loaded file. Options that act on the hosted
/// server rather than the configuration (<see cref="Identity"/>,
/// <see cref="ConfigureRateLimits"/>) keep working. This path also
/// takes precedence over a shared application registered with
/// <c>ConfigureApplication(...)</c>. Mutually exclusive with
/// <see cref="ConfigurationStream"/>.
/// </para>
/// </remarks>
public string? ConfigurationFile { get; set; }
/// <summary>
/// Optional stream containing a classic OPC UA application
/// configuration XML document, e.g. an embedded resource. When set,
/// the hosted service loads the <see cref="ApplicationConfiguration"/>
/// from this stream instead of building one from the other options,
/// with the same semantics as <see cref="ConfigurationFile"/>.
/// </summary>
/// <remarks>
/// 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 <see cref="ConfigurationFile"/>.
/// </remarks>
public Stream? ConfigurationStream { get; set; }
/// <summary>
/// Optional callback invoked with the <see cref="ApplicationConfiguration"/>
/// loaded from <see cref="ConfigurationFile"/> or
/// <see cref="ConfigurationStream"/>, after the document has been
/// read and validated but before certificates are checked and the
/// server starts. Use it to override individual settings from code
/// without editing the document. Ignored when neither
/// <see cref="ConfigurationFile"/> nor
/// <see cref="ConfigurationStream"/> is set.
/// </summary>
public Action<ApplicationConfiguration>? ConfigureLoadedConfiguration { get; set; }
/// <summary>
/// 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
/// <see cref="OpcUaApplicationOptions.ConfigureSecurity"/> through
/// <c>ConfigureApplication(...)</c> for post-security certificate and
/// validation settings.
/// validation settings. Not applied when <see cref="ConfigurationFile"/>
/// or <see cref="ConfigurationStream"/> is set.
/// </summary>
public Action<IApplicationConfigurationBuilderServerSelected>? ConfigureBuilder { get; set; }
+5 -1
View File
@@ -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.
@@ -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
{
/// <summary>
/// Exercises the <c>AddServer(configurationFile)</c> and
/// <c>AddServer(configurationStream)</c> migration paths: a hosted OPC UA
/// server whose <see cref="ApplicationConfiguration"/> is loaded from an
/// existing OPC UA XML configuration document instead of being built from
/// <see cref="OpcUaServerOptions"/>. Covers the new <c>IOpcUaBuilder</c>
/// overloads, the <see cref="OpcUaServerOptions.ConfigurationFile"/> /
/// <see cref="OpcUaServerOptions.ConfigurationStream"/> options and the
/// configuration-section binding, the
/// <see cref="OpcUaServerOptions.ConfigureLoadedConfiguration"/>
/// override callback, precedence over <c>ConfigureApplication(...)</c>,
/// mutual exclusion of file and stream, startup failure for missing
/// files, and the user-token-policy warning derived from the loaded
/// document.
/// </summary>
[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<ConfigurationCaptureServer>(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<IServerStartupTask>(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<IOptions<OpcUaServerOptions>>().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<ConfigurationCaptureServer>(
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<ConfigurationCaptureServer>(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<ServiceResultException>());
}
[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<ConfigurationCaptureServer>(
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<IServerStartupTask>(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<IOptions<OpcUaServerOptions>>().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<InvalidOperationException>());
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<InvalidOperationException>());
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<ArgumentException>().With
.Property(nameof(ArgumentException.ParamName)).EqualTo("configurationFile"));
Assert.That(
() => default(IOpcUaBuilder)!.AddServer<ConfigurationCaptureServer>(
"Server.Config.xml"),
Throws.ArgumentNullException.With
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder"));
Assert.That(
() => builder.AddServer<ConfigurationCaptureServer>((string)null!),
Throws.ArgumentNullException.With
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationFile"));
Assert.That(
() => builder.AddServer<ConfigurationCaptureServer>(" "),
Throws.TypeOf<ArgumentException>().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<ConfigurationCaptureServer>(stream),
Throws.ArgumentNullException.With
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder"));
Assert.That(
() => builder.AddServer<ConfigurationCaptureServer>((Stream)null!),
Throws.ArgumentNullException.With
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationStream"));
}
/// <summary>
/// Builds the provider, starts the single hosted service, and returns
/// the exception the startup failed with (from a synchronous
/// <c>StartAsync</c> fault or the faulted execute task), or
/// <c>null</c> when startup did not fail promptly. Disposes the
/// provider before returning.
/// </summary>
private static async Task<Exception?> StartAndCaptureStartupFailureAsync(
ServiceCollection services)
{
ServiceProvider provider = services.BuildServiceProvider();
try
{
IHostedService hostedService = provider.GetServices<IHostedService>().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<ConfigurationCaptureServer>("Third.Config.xml"),
Throws.InvalidOperationException);
}
[Test]
public void AddServerBindsConfigurationFileFromConfigurationSection()
{
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<IOptions<OpcUaServerOptions>>().Value;
Assert.That(options.ConfigurationFile, Is.EqualTo("Legacy/Server.Config.xml"));
Assert.That(options.ApplicationName, Is.EqualTo("BoundConfigFileServer"));
}
private const string AnonymousTokenPolicyXml = """
<ua:UserTokenPolicy>
<ua:TokenType>Anonymous_0</ua:TokenType>
</ua:UserTokenPolicy>
""";
private const string UserNameTokenPolicyXml = """
<ua:UserTokenPolicy>
<ua:TokenType>UserName_1</ua:TokenType>
</ua:UserTokenPolicy>
""";
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Builds the classic OPC UA server configuration XML document used
/// by both the file-based and the stream-based tests.
/// </summary>
private static string BuildConfigurationXml(
string applicationName,
int port,
string pkiRoot,
string? userTokenPoliciesXml = null)
{
return $"""
<?xml version="1.0" encoding="utf-8"?>
<ApplicationConfiguration
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:ua="http://opcfoundation.org/UA/2008/02/Types.xsd"
xmlns="http://opcfoundation.org/UA/SDK/Configuration.xsd">
<ApplicationName>{applicationName}</ApplicationName>
<ApplicationUri>urn:opcfoundation:test:{applicationName}</ApplicationUri>
<ProductUri>uri:opcfoundation.org:test:{applicationName}</ProductUri>
<ApplicationType>Server_0</ApplicationType>
<SecurityConfiguration>
<ApplicationCertificates>
<CertificateIdentifier>
<StoreType>Directory</StoreType>
<StorePath>{pkiRoot}/own</StorePath>
<SubjectName>CN={applicationName}, O=OPC Foundation, DC=localhost</SubjectName>
<CertificateTypeString>RsaSha256</CertificateTypeString>
</CertificateIdentifier>
</ApplicationCertificates>
<TrustedIssuerCertificates>
<StoreType>Directory</StoreType>
<StorePath>{pkiRoot}/issuer</StorePath>
</TrustedIssuerCertificates>
<TrustedPeerCertificates>
<StoreType>Directory</StoreType>
<StorePath>{pkiRoot}/trusted</StorePath>
</TrustedPeerCertificates>
<RejectedCertificateStore>
<StoreType>Directory</StoreType>
<StorePath>{pkiRoot}/rejected</StorePath>
</RejectedCertificateStore>
<AutoAcceptUntrustedCertificates>true</AutoAcceptUntrustedCertificates>
</SecurityConfiguration>
<TransportConfigurations></TransportConfigurations>
<TransportQuotas>
<OperationTimeout>90000</OperationTimeout>
<MaxStringLength>654321</MaxStringLength>
<MaxByteStringLength>1048576</MaxByteStringLength>
<MaxArrayLength>65535</MaxArrayLength>
<MaxMessageSize>4194304</MaxMessageSize>
<MaxBufferSize>65535</MaxBufferSize>
<ChannelLifetime>300000</ChannelLifetime>
<SecurityTokenLifetime>3600000</SecurityTokenLifetime>
</TransportQuotas>
<ServerConfiguration>
<BaseAddresses>
<ua:String>{FormatBaseAddress(applicationName, port)}</ua:String>
</BaseAddresses>
<SecurityPolicies>
<ServerSecurityPolicy>
<SecurityMode>None_1</SecurityMode>
<SecurityPolicyUri>http://opcfoundation.org/UA/SecurityPolicy#None</SecurityPolicyUri>
</ServerSecurityPolicy>
</SecurityPolicies>
<UserTokenPolicies>
{userTokenPoliciesXml ?? AnonymousTokenPolicyXml}
</UserTokenPolicies>
<DiagnosticsEnabled>false</DiagnosticsEnabled>
<MaxSessionCount>77</MaxSessionCount>
<MinSessionTimeout>10000</MinSessionTimeout>
<MaxSessionTimeout>3600000</MaxSessionTimeout>
</ServerConfiguration>
</ApplicationConfiguration>
""";
}
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<bool> WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
DateTime deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition())
{
return true;
}
await Task.Delay(100).ConfigureAwait(false);
}
return condition();
}
/// <summary>
/// A <see cref="StandardServer"/> that captures the effective
/// <see cref="ApplicationConfiguration"/> the hosted service starts
/// it with, so tests can assert that the settings of the loaded
/// configuration file were applied.
/// </summary>
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<HostedServerFixture> StartAsync(
Action<IServiceCollection> configureServices)
{
var services = new ServiceCollection();
configureServices(services);
ServiceProvider provider = services.BuildServiceProvider();
IHostedService hostedService = provider.GetServices<IHostedService>().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<string> Messages { get; } = [];
public ILogger CreateLogger(string categoryName)
{
return new CapturingLogger(Messages);
}
public void Dispose()
{
}
}
private sealed class CapturingLogger : ILogger
{
public CapturingLogger(ConcurrentBag<string> messages)
{
m_messages = messages;
}
public IDisposable BeginScope<TState>(TState state)
where TState : notnull
{
return NoopDisposable.Instance;
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
m_messages.Add(formatter(state, exception));
}
private readonly ConcurrentBag<string> m_messages;
}
private sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new();
public void Dispose()
{
}
}
}
}