7829b937d1
OpcUa:Redundancy:Mode (Unset default = today's server exactly; None; Warm; Hot and unknown values fail start). - T1 options + validator + effective-URI check + boot banner; Warm needs the driver role. - T2 metadata in OnNodeManagerStartedAsync: Warm -> NonTransparentRedundancyType, read-only ServerUriArray [self, peers], RedundancySupport=Warm, boot ServiceLevel 1; None -> RedundancySupport=None and the SDK's orphan i=11314 deleted; Unset untouched. - T3 RedundancyStateChanged.Revision (up-number, incarnation tick, sequence); subscribers drop older revisions under Warm (OpcUaPublishActor, DriverHostActor); unrevisioned never makes a node Active. - T4 WarmRoleAuthority + ComputeWarm bands (Standby 90/50, Active 250/200/100), PromoteWhilePrimaryUnreachable. - T5 IStandbyProjection + DeferredStandbyProjection; OpcUaPublishActor runs promotion/demotion in the spec 4.7 order (Active level last and retried; Standby level first, data degraded regardless). - T6 EquipmentVariableState read projection (BadOutOfService/null); Warm Standby refuses writes BadInvalidState. - T7 D4 mechanism adopted after T0: ordinary-tag marker on MonitoredItemNotification.Message validated by (ClientHandle, DataValue); first-delivery decision keyed by (SubscriptionId, SequenceNumber); the SDK's cached copy is never mutated. - T8 queue current/BadOutOfService to live ordinary-tag items ignoring filters. - T9 alarm stamp/deliver split (suppressed on Standby), Part 9 methods refused on Standby, promotion refresh via ISubscription.ConditionRefresh2Async on every live event item. - T10 deploy-time RedundancyMode cross-check + six instruments. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
70 lines
4.7 KiB
C#
70 lines
4.7 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");
|
|
|
|
// Gitea #717 spec §3: Hot/unknown modes fail start; Warm needs distinct peers that do not include this server's
|
|
// own ApplicationUri; None needs no peers. The effective URI is re-checked after an ApplicationConfigPath load.
|
|
foreach (var failure in (o.Redundancy ?? new OpcUaRedundancyOptions()).Check(o.ApplicationUri, o.PeerApplicationUris))
|
|
builder.RequireThat(false, failure);
|
|
}
|
|
}
|