Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaApplicationHostOptionsValidator.cs
T
Joseph Doherty a83bf1fa27
v2-ci / unit-tests (push) Has been cancelled
fix(opcua): a configured OpcUa:EnabledSecurityProfiles list replaces the default, so None can be dropped (#879)
ConfigurationBinder appends configured collection items to an existing list, so
the pre-filled [None, Sign, SignAndEncrypt] default made "drop None" a no-op:
every deployment that listed only the secure profiles still advertised an
unsigned/unencrypted None endpoint.

EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles()
applies the unchanged three-profile default only when nothing is configured.
A set-but-empty list fails startup validation and BuildSecurityPolicies no
longer falls back to a None endpoint on empty input (it throws). Sweep of all
options classes found no other non-empty pre-filled collection default.

Tests: options binding (indexed, JSON, env forms, default, empty, bad name), a
booted-server wire test (no None endpoint, None connection refused with
BadSecurityPolicyRejected, secure connection works; default case is the control).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 11:38:42 -05:00

65 lines
4.3 KiB
C#

using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Fail-fast startup validator for <see cref="OpcUaApplicationHostOptions"/>, built on the
/// shared <c>ZB.MOM.WW.Configuration</c> <see cref="OptionsValidatorBase{TOptions}"/>. The C#
/// defaults are all valid, so a host with no explicit <c>"OpcUa"</c> section passes untouched;
/// the validator exists to reject explicit prod/env overrides before the OPC UA SDK boots.
/// Identity/transport essentials (<c>ApplicationName</c>, <c>ApplicationUri</c>,
/// <c>PublicHostname</c>, <c>PkiStoreRoot</c>, <c>OpcUaPort</c>) must be present/valid and at
/// least one security profile must be enabled. Optional fields — <c>ApplicationConfigPath</c>,
/// <c>PeerApplicationUris</c>, <c>AutoAcceptUntrustedClientCertificates</c>, and
/// <c>ProductUri</c> — are intentionally not validated. Failure messages carry the real
/// <c>"OpcUa:"</c> section prefix matching the bound configuration section.
/// </summary>
public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<OpcUaApplicationHostOptions>
{
/// <inheritdoc />
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");
// lmxopcua#879: null (not configured) means "use the default three profiles" and is valid; a list that is
// present but empty is an error — it must never silently mean "None only" or "everything".
// EnabledSecurityProfiles is declared as IList<T> — that interface does not derive from
// IReadOnlyCollection<T>, so ToList() bridges to MinCount's parameter (count + message preserved).
if (o.EnabledSecurityProfiles is not null)
builder.MinCount(o.EnabledSecurityProfiles.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
// #876: discovery registration is opt-in; when configured, the URL must be an absolute opc.tcp URI and the
// interval positive (0 would silently disable the registration that was asked for).
if (!string.IsNullOrWhiteSpace(o.DiscoveryServerUrl))
{
builder.RequireThat(
Uri.TryCreate(o.DiscoveryServerUrl.Trim(), UriKind.Absolute, out var discoveryUri)
&& discoveryUri.Scheme == "opc.tcp",
"OpcUa:DiscoveryServerUrl must be an absolute opc.tcp:// URL (or empty to disable discovery registration)");
builder.RequireThat(o.DiscoveryRegistrationIntervalMs > 0,
"OpcUa:DiscoveryRegistrationIntervalMs must be greater than 0 while OpcUa:DiscoveryServerUrl is set");
}
// #833 review: an enabled credential lockout needs positive durations — a zero LockoutSeconds would lock
// nothing, a zero FailureWindowSeconds would never accumulate. MaxFailedAttempts <= 0 disables it.
var lockout = o.CredentialLockout;
builder.RequireThat(lockout is null || lockout.MaxFailedAttempts <= 0
|| (lockout.LockoutSeconds > 0 && lockout.FailureWindowSeconds > 0 && lockout.SlotWaitSeconds > 0),
"OpcUa:CredentialLockout:LockoutSeconds, OpcUa:CredentialLockout:FailureWindowSeconds and "
+ "OpcUa:CredentialLockout:SlotWaitSeconds must all be "
+ "greater than 0 while OpcUa:CredentialLockout:MaxFailedAttempts is greater than 0 (set "
+ "MaxFailedAttempts to 0 to disable the lockout)");
// #844: the per-client cap (which a success never resets) below the per-credential threshold would make every
// per-credential lockout a client-wide one; 0 means 2 x MaxFailedAttempts.
builder.RequireThat(lockout is null || lockout.MaxFailedAttempts <= 0 || lockout.MaxFailedAttemptsPerClient <= 0
|| lockout.MaxFailedAttemptsPerClient >= lockout.MaxFailedAttempts,
"OpcUa:CredentialLockout:MaxFailedAttemptsPerClient must be 0 (= 2 x MaxFailedAttempts) or at least "
+ "OpcUa:CredentialLockout:MaxFailedAttempts");
}
}