diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs
new file mode 100644
index 00000000..5fa08291
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs
@@ -0,0 +1,32 @@
+using ZB.MOM.WW.Configuration;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Fail-fast startup validator for , built on the
+/// shared ZB.MOM.WW.Configuration . The C#
+/// defaults are all valid, so a host with no explicit "OpcUa" section passes untouched;
+/// the validator exists to reject explicit prod/env overrides before the OPC UA SDK boots.
+/// Identity/transport essentials (ApplicationName, ApplicationUri,
+/// PublicHostname, PkiStoreRoot, OpcUaPort) must be present/valid and at
+/// least one security profile must be enabled. Optional fields — ApplicationConfigPath,
+/// PeerApplicationUris, AutoAcceptUntrustedClientCertificates, and
+/// ProductUri — are intentionally not validated. Failure messages carry the real
+/// "OpcUa:" section prefix matching the bound configuration section.
+///
+public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase
+{
+ ///
+ protected override void Validate(ValidationBuilder builder, OpcUaApplicationHostOptions o)
+ {
+ builder.Required(o.ApplicationName, "OpcUa:ApplicationName");
+ builder.Required(o.ApplicationUri, "OpcUa:ApplicationUri");
+ builder.Required(o.PublicHostname, "OpcUa:PublicHostname");
+ builder.Required(o.PkiStoreRoot, "OpcUa:PkiStoreRoot");
+ builder.Port(o.OpcUaPort, "OpcUa:OpcUaPort");
+ // EnabledSecurityProfiles is typed IList, which does not implement IReadOnlyCollection;
+ // ToList() bridges to the shared MinCount primitive while preserving the count (and message).
+ builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
index 86d8774c..6087a6c6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
@@ -1,6 +1,6 @@
-using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
///
public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposable
{
- private readonly IConfiguration _configuration;
+ private readonly OpcUaApplicationHostOptions _options;
private readonly DeferredAddressSpaceSink _deferredSink;
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
@@ -33,19 +33,19 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
///
/// Initializes a new instance of the OtOpcUaServerHostedService class.
///
- /// The application configuration.
+ /// The validated OPC UA host options (bound from the OpcUa section and validated at startup via ValidateOnStart).
/// The deferred address space sink that receives the real sink once the server is ready.
/// The deferred service level publisher that receives the real publisher once the server is ready.
/// The OPC UA user authenticator.
/// The logger factory for creating loggers.
public OtOpcUaServerHostedService(
- IConfiguration configuration,
+ IOptions options,
DeferredAddressSpaceSink deferredSink,
DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
ILoggerFactory loggerFactory)
{
- _configuration = configuration;
+ _options = options.Value;
_deferredSink = deferredSink;
_deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
@@ -59,8 +59,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// Cancellation token.
public async Task StartAsync(CancellationToken cancellationToken)
{
- var options = new OpcUaApplicationHostOptions();
- _configuration.GetSection("OpcUa").Bind(options);
+ var options = _options;
_server = new OtOpcUaSdkServer();
_appHost = new OpcUaApplicationHost(
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 7bdfe959..c43997d8 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -16,6 +16,7 @@ using ZB.MOM.WW.OtOpcUa.Host.Engines;
using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Host.Observability;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Security;
@@ -102,6 +103,12 @@ if (hasDriver)
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ // Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
+ // and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
+ // re-binding the section imperatively. Defaults pass; this guards explicit prod/env overrides.
+ builder.Services.AddValidatedOptions(
+ builder.Configuration, "OpcUa");
+
builder.Services.AddHostedService();
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs
new file mode 100644
index 00000000..15c62020
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs
@@ -0,0 +1,59 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// Task 4 — verifies the net-new (built on the
+/// shared ZB.MOM.WW.Configuration OptionsValidatorBase/ValidationBuilder) that
+/// gates the OPC UA host options at startup. The C# defaults are all valid so a host with no
+/// explicit "OpcUa" section still passes; the validator exists to reject explicit
+/// prod/env overrides. Failure messages carry the real "OpcUa:" section prefix and the
+/// exact shared-primitive wording so they read correctly when surfaced via ValidateOnStart.
+///
+public sealed class OpcUaApplicationHostOptionsValidatorTests
+{
+ private static readonly OpcUaApplicationHostOptionsValidator Sut = new();
+
+ /// The C# defaults (the as-bound shape when the section is absent) pass validation.
+ [Fact]
+ public void Default_options_succeed()
+ {
+ Sut.Validate(null, new OpcUaApplicationHostOptions()).Succeeded.ShouldBeTrue();
+ }
+
+ /// A port of 0 reports the shared port-range failure with the OpcUa prefix.
+ [Fact]
+ public void Zero_port_fails()
+ {
+ var result = Sut.Validate(null, new OpcUaApplicationHostOptions { OpcUaPort = 0 });
+
+ result.Failed.ShouldBeTrue();
+ result.Failures.ShouldContain("OpcUa:OpcUaPort must be between 1 and 65535 (was 0)");
+ }
+
+ /// A blank public hostname reports the shared required failure with the OpcUa prefix.
+ [Fact]
+ public void Blank_public_hostname_fails()
+ {
+ var result = Sut.Validate(null, new OpcUaApplicationHostOptions { PublicHostname = "" });
+
+ result.Failed.ShouldBeTrue();
+ result.Failures.ShouldContain("OpcUa:PublicHostname is required");
+ }
+
+ /// An empty security-profile list reports the shared min-count failure with the OpcUa prefix.
+ [Fact]
+ public void Empty_security_profiles_fails()
+ {
+ var result = Sut.Validate(null, new OpcUaApplicationHostOptions
+ {
+ EnabledSecurityProfiles = new List(),
+ });
+
+ result.Failed.ShouldBeTrue();
+ result.Failures.ShouldContain("OpcUa:EnabledSecurityProfiles must contain at least 1 item(s) (had 0)");
+ }
+}