5 Commits

14 changed files with 204 additions and 246 deletions
+1
View File
@@ -79,6 +79,7 @@
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" /> <PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" /> <PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" /> <PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1
View File
@@ -20,6 +20,7 @@
<package pattern="ZB.MOM.WW.Health.*" /> <package pattern="ZB.MOM.WW.Health.*" />
<package pattern="ZB.MOM.WW.Telemetry" /> <package pattern="ZB.MOM.WW.Telemetry" />
<package pattern="ZB.MOM.WW.Telemetry.*" /> <package pattern="ZB.MOM.WW.Telemetry.*" />
<package pattern="ZB.MOM.WW.Configuration" />
</packageSource> </packageSource>
</packageSourceMapping> </packageSourceMapping>
<!-- <!--
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
/// drop in-flight investigations, too long would defeat the partition-switch /// drop in-flight investigations, too long would defeat the partition-switch
/// purge's purpose. /// purge's purpose.
/// </summary> /// </summary>
public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions> public sealed class AuditLogOptionsValidator : OptionsValidatorBase<AuditLogOptions>
{ {
/// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary> /// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
public const int MinRetentionDays = 30; public const int MinRetentionDays = 30;
@@ -28,43 +28,29 @@ public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
public const int MaxInboundMaxBytes = 16_777_216; public const int MaxInboundMaxBytes = 16_777_216;
/// <inheritdoc /> /// <inheritdoc />
public ValidateOptionsResult Validate(string? name, AuditLogOptions options) protected override void Validate(ValidationBuilder builder, AuditLogOptions options)
{ {
ArgumentNullException.ThrowIfNull(options); builder.RequireThat(options.DefaultCapBytes > 0,
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
"must be > 0; it drives payload-summary truncation in audit writers.");
var failures = new List<string>(); builder.RequireThat(options.ErrorCapBytes >= options.DefaultCapBytes,
$"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " +
$"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " +
"the error-row cap is intended to capture more detail than the happy-path summary.");
if (options.DefaultCapBytes <= 0) // Valid when RetentionDays is within [Min, Max] inclusive. The De Morgan'd
{ // guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
failures.Add( builder.RequireThat(
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " + !(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays),
"must be > 0; it drives payload-summary truncation in audit writers."); $"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
} $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
if (options.ErrorCapBytes < options.DefaultCapBytes) // Valid when InboundMaxBytes is within [Min, Max] inclusive. The De Morgan'd
{ // guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
failures.Add( builder.RequireThat(
$"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " + !(options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes),
$"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " + $"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
"the error-row cap is intended to capture more detail than the happy-path summary."); $"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
}
if (options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays)
{
failures.Add(
$"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
}
if (options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes)
{
failures.Add(
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
}
return failures.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(failures);
} }
} }
@@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload; using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
@@ -62,10 +62,12 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(config); ArgumentNullException.ThrowIfNull(config);
// M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.). // M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
services.AddOptions<AuditLogOptions>() // Collapsed onto the shared ZB.MOM.WW.Configuration helper: it binds the
.Bind(config.GetSection(ConfigSectionName)) // "AuditLog" section, registers the validator, and enables ValidateOnStart in
.ValidateOnStart(); // one call. Same section path as before; AddAuditLog is call-once per
services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>(); // collection, and the helper's TryAddEnumerable is idempotent for the
// validator (a strict improvement over the previous AddSingleton).
services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);
// M5 Bundle A: payload filter — truncates oversized RequestSummary / // M5 Bundle A: payload filter — truncates oversized RequestSummary /
// ResponseSummary / ErrorDetail / Extra fields between event // ResponseSummary / ErrorDetail / Extra fields between event
@@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
/// Registered with <c>ValidateOnStart()</c> so a bad <c>appsettings.json</c> /// Registered with <c>ValidateOnStart()</c> so a bad <c>appsettings.json</c>
/// fails fast at boot rather than failing far from the cause. /// fails fast at boot rather than failing far from the cause.
/// </summary> /// </summary>
public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions> public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
{ {
/// <summary>Split-brain resolver strategies safe for ScadaBridge's two-node clusters.</summary> /// <summary>Split-brain resolver strategies safe for ScadaBridge's two-node clusters.</summary>
private static readonly HashSet<string> AllowedStrategies = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> AllowedStrategies = new(StringComparer.OrdinalIgnoreCase)
@@ -19,77 +19,51 @@ public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions>
}; };
/// <summary> /// <summary>
/// Validates the cluster options, returning a failure result if any critical settings are misconfigured. /// Validates the cluster options, recording a failure if any critical settings are misconfigured.
/// </summary> /// </summary>
/// <param name="name">Named options instance name (unused; all instances are validated identically).</param> /// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">The cluster options to validate.</param> /// <param name="options">The cluster options to validate.</param>
public ValidateOptionsResult Validate(string? name, ClusterOptions options) protected override void Validate(ValidationBuilder builder, ClusterOptions options)
{ {
var failures = new List<string>(); // CI-012: design doc states "both nodes are seed nodes — each node lists
// both itself and its partner" so a properly-configured deployment lists
// two. Accepting a single-seed configuration silently defeats the
// "no startup ordering dependency" guarantee called out by
// Component-ClusterInfrastructure.md (Node Configuration).
builder.RequireThat(options.SeedNodes is not null && options.SeedNodes.Count >= 2,
"ClusterOptions.SeedNodes must contain at least 2 seed nodes "
+ "(Component-ClusterInfrastructure.md → Node Configuration: "
+ "both nodes are seed nodes); a single-seed configuration defeats "
+ "the no-startup-ordering-dependency guarantee.");
if (options.SeedNodes is null || options.SeedNodes.Count < 2) builder.RequireThat(
{ !string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy)
// CI-012: design doc states "both nodes are seed nodes — each node lists && AllowedStrategies.Contains(options.SplitBrainResolverStrategy),
// both itself and its partner" so a properly-configured deployment lists $"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " +
// two. Accepting a single-seed configuration silently defeats the $"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition.");
// "no startup ordering dependency" guarantee called out by
// Component-ClusterInfrastructure.md (Node Configuration).
failures.Add(
"ClusterOptions.SeedNodes must contain at least 2 seed nodes "
+ "(Component-ClusterInfrastructure.md → Node Configuration: "
+ "both nodes are seed nodes); a single-seed configuration defeats "
+ "the no-startup-ordering-dependency guarantee.");
}
if (string.IsNullOrWhiteSpace(options.SplitBrainResolverStrategy) builder.RequireThat(options.MinNrOfMembers == 1,
|| !AllowedStrategies.Contains(options.SplitBrainResolverStrategy)) $"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
{ "any other value blocks the cluster singleton after failover and halts all data collection.");
failures.Add(
$"ClusterOptions.SplitBrainResolverStrategy must be 'keep-oldest' for a two-node cluster; " +
$"'{options.SplitBrainResolverStrategy}' would risk a total cluster shutdown on a partition.");
}
if (options.MinNrOfMembers != 1) builder.RequireThat(options.StableAfter > TimeSpan.Zero,
{ "ClusterOptions.StableAfter must be a positive duration.");
failures.Add(
$"ClusterOptions.MinNrOfMembers must be 1 (was {options.MinNrOfMembers}); " +
"any other value blocks the cluster singleton after failover and halts all data collection.");
}
if (options.StableAfter <= TimeSpan.Zero) builder.RequireThat(options.HeartbeatInterval > TimeSpan.Zero,
{ "ClusterOptions.HeartbeatInterval must be a positive duration.");
failures.Add("ClusterOptions.StableAfter must be a positive duration.");
}
if (options.HeartbeatInterval <= TimeSpan.Zero) builder.RequireThat(options.FailureDetectionThreshold > TimeSpan.Zero,
{ "ClusterOptions.FailureDetectionThreshold must be a positive duration.");
failures.Add("ClusterOptions.HeartbeatInterval must be a positive duration.");
}
if (options.FailureDetectionThreshold <= TimeSpan.Zero) builder.RequireThat(options.HeartbeatInterval < options.FailureDetectionThreshold,
{ $"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " +
failures.Add("ClusterOptions.FailureDetectionThreshold must be a positive duration."); $"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " +
} "declared unreachable before a heartbeat can arrive.");
if (options.HeartbeatInterval >= options.FailureDetectionThreshold) builder.RequireThat(options.DownIfAlone,
{ "ClusterOptions.DownIfAlone must be true for the keep-oldest resolver "
failures.Add( + "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the "
$"ClusterOptions.HeartbeatInterval ({options.HeartbeatInterval}) must be well below " + + "oldest node can run as an isolated single-node cluster during a partition while the "
$"FailureDetectionThreshold ({options.FailureDetectionThreshold}); otherwise nodes are " + + "younger node forms its own, producing two live clusters.");
"declared unreachable before a heartbeat can arrive.");
}
if (!options.DownIfAlone)
{
failures.Add(
"ClusterOptions.DownIfAlone must be true for the keep-oldest resolver "
+ "(Component-ClusterInfrastructure.md → Split-Brain Resolution); with it false the "
+ "oldest node can run as an isolated single-node cluster during a partition while the "
+ "younger node forms its own, producing two live clusters.");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
} }
} }
@@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
@@ -14,51 +14,40 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
/// <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:HealthMonitoring</c> section /// <c>ValidateOnStart()</c> so a bad <c>ScadaBridge:HealthMonitoring</c> section
/// fails fast at boot with a clear, key-naming message. /// fails fast at boot with a clear, key-naming message.
/// </summary> /// </summary>
public sealed class HealthMonitoringOptionsValidator : IValidateOptions<HealthMonitoringOptions> public sealed class HealthMonitoringOptionsValidator : OptionsValidatorBase<HealthMonitoringOptions>
{ {
/// <summary> /// <summary>
/// Validates the health monitoring options, returning a failure result if any interval values are non-positive. /// Validates the health monitoring options, recording a failure if any interval values are non-positive.
/// </summary> /// </summary>
/// <param name="name">Named options instance name (unused).</param> /// <param name="builder">The accumulator to record failures on.</param>
/// <param name="options">The health monitoring options to validate.</param> /// <param name="options">The health monitoring options to validate.</param>
public ValidateOptionsResult Validate(string? name, HealthMonitoringOptions options) protected override void Validate(ValidationBuilder builder, HealthMonitoringOptions options)
{ {
var failures = new List<string>(); builder.RequireThat(options.ReportInterval > TimeSpan.Zero,
$"ScadaBridge:HealthMonitoring:ReportInterval must be a positive duration " +
$"(was {options.ReportInterval}); it is used directly as a PeriodicTimer period.");
if (options.ReportInterval <= TimeSpan.Zero) builder.RequireThat(options.OfflineTimeout > TimeSpan.Zero,
{ $"ScadaBridge:HealthMonitoring:OfflineTimeout must be a positive duration " +
failures.Add( $"(was {options.OfflineTimeout}); it drives the offline-check PeriodicTimer cadence.");
$"ScadaBridge:HealthMonitoring:ReportInterval must be a positive duration " +
$"(was {options.ReportInterval}); it is used directly as a PeriodicTimer period.");
}
if (options.OfflineTimeout <= TimeSpan.Zero) builder.RequireThat(options.CentralOfflineTimeout > TimeSpan.Zero,
{ $"ScadaBridge:HealthMonitoring:CentralOfflineTimeout must be a positive duration " +
failures.Add( $"(was {options.CentralOfflineTimeout}).");
$"ScadaBridge:HealthMonitoring:OfflineTimeout must be a positive duration " +
$"(was {options.OfflineTimeout}); it drives the offline-check PeriodicTimer cadence.");
}
if (options.CentralOfflineTimeout <= TimeSpan.Zero) // Valid when CentralOfflineTimeout >= OfflineTimeout (both already
{ // required to be positive above). The De Morgan'd guard !(both positive
failures.Add( // AND Central < Offline) is true unless BOTH timeouts are positive and
$"ScadaBridge:HealthMonitoring:CentralOfflineTimeout must be a positive duration " + // Central is strictly smaller — so it stays silent when either field is
$"(was {options.CentralOfflineTimeout})."); // non-positive, leaving that failure to the dedicated positive-duration
} // checks above rather than double-firing here.
builder.RequireThat(
if (options.OfflineTimeout > TimeSpan.Zero !(options.OfflineTimeout > TimeSpan.Zero
&& options.CentralOfflineTimeout > TimeSpan.Zero && options.CentralOfflineTimeout > TimeSpan.Zero
&& options.CentralOfflineTimeout < options.OfflineTimeout) && options.CentralOfflineTimeout < options.OfflineTimeout),
{ $"ScadaBridge:HealthMonitoring:CentralOfflineTimeout ({options.CentralOfflineTimeout}) " +
failures.Add( $"must be >= OfflineTimeout ({options.OfflineTimeout}): the synthetic 'central' site has " +
$"ScadaBridge:HealthMonitoring:CentralOfflineTimeout ({options.CentralOfflineTimeout}) " + "no heartbeat source and is fed only by the slower self-report loop, so it needs at " +
$"must be >= OfflineTimeout ({options.OfflineTimeout}): the synthetic 'central' site has " + "least as much offline grace as a real site.");
"no heartbeat source and is fed only by the slower self-report loop, so it needs at " +
"least as much offline grace as a real site.");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
} }
} }
@@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -105,10 +105,16 @@ public static class SiteServiceRegistration
public static void BindSharedOptions(IServiceCollection services, IConfiguration config) public static void BindSharedOptions(IServiceCollection services, IConfiguration config)
{ {
services.Configure<NodeOptions>(config.GetSection("ScadaBridge:Node")); services.Configure<NodeOptions>(config.GetSection("ScadaBridge:Node"));
services.Configure<ClusterOptions>(config.GetSection("ScadaBridge:Cluster")); // Bind + eagerly validate: ClusterOptionsValidator is registered (TryAddEnumerable)
// by the ClusterInfrastructure module, so chaining ValidateOnStart() here makes a bad
// ScadaBridge:Cluster section fail fast at host build instead of lazily on first resolve.
services.AddOptions<ClusterOptions>().Bind(config.GetSection("ScadaBridge:Cluster")).ValidateOnStart();
services.Configure<DatabaseOptions>(config.GetSection("ScadaBridge:Database")); services.Configure<DatabaseOptions>(config.GetSection("ScadaBridge:Database"));
services.Configure<CommunicationOptions>(config.GetSection("ScadaBridge:Communication")); services.Configure<CommunicationOptions>(config.GetSection("ScadaBridge:Communication"));
services.Configure<HealthMonitoringOptions>(config.GetSection("ScadaBridge:HealthMonitoring")); // Bind + eagerly validate: HealthMonitoringOptionsValidator is registered (TryAddEnumerable)
// by the HealthMonitoring module, so chaining ValidateOnStart() here makes a bad
// ScadaBridge:HealthMonitoring section fail fast at host build instead of lazily on first resolve.
services.AddOptions<HealthMonitoringOptions>().Bind(config.GetSection("ScadaBridge:HealthMonitoring")).ValidateOnStart();
services.Configure<NotificationOptions>(config.GetSection("ScadaBridge:Notification")); services.Configure<NotificationOptions>(config.GetSection("ScadaBridge:Notification"));
services.Configure<LoggingOptions>(config.GetSection("ScadaBridge:Logging")); services.Configure<LoggingOptions>(config.GetSection("ScadaBridge:Logging"));
@@ -1,3 +1,5 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host; namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary> /// <summary>
@@ -10,92 +12,98 @@ public static class StartupValidator
/// <param name="configuration">The application configuration to validate.</param> /// <param name="configuration">The application configuration to validate.</param>
public static void Validate(IConfiguration configuration) public static void Validate(IConfiguration configuration)
{ {
var errors = new List<string>(); // Resolve the same locals the original imperative validator used, so the
// cross-field predicates below can close over them. ConfigPreflight.Require
// passes config[key] to each predicate, but the cross-field rules ignore that
// argument and read these resolved values instead — preserving the exact
// conditions (and therefore the byte-identical failure messages and ordering)
// of the original StartupValidator.
var nodeSection = configuration.GetSection("ScadaBridge:Node"); var nodeSection = configuration.GetSection("ScadaBridge:Node");
var role = nodeSection["Role"]; var role = nodeSection["Role"];
if (string.IsNullOrEmpty(role) || (role != "Central" && role != "Site"))
errors.Add("ScadaBridge:Node:Role must be 'Central' or 'Site'");
if (string.IsNullOrEmpty(nodeSection["NodeHostname"]))
errors.Add("ScadaBridge:Node:NodeHostname is required");
var portStr = nodeSection["RemotingPort"]; var portStr = nodeSection["RemotingPort"];
if (!int.TryParse(portStr, out var port) || port < 1 || port > 65535) bool portValid = int.TryParse(portStr, out var port) && port >= 1 && port <= 65535;
errors.Add("ScadaBridge:Node:RemotingPort must be 1-65535");
if (role == "Site" && string.IsNullOrEmpty(nodeSection["SiteId"]))
errors.Add("ScadaBridge:Node:SiteId is required for Site nodes");
if (role == "Central")
{
var dbSection = configuration.GetSection("ScadaBridge:Database");
if (string.IsNullOrEmpty(dbSection["ConfigurationDb"]))
errors.Add("ScadaBridge:Database:ConfigurationDb connection string required for Central");
var secSection = configuration.GetSection("ScadaBridge:Security");
if (string.IsNullOrEmpty(secSection["LdapServer"]))
errors.Add("ScadaBridge:Security:LdapServer required for Central");
if (string.IsNullOrEmpty(secSection["JwtSigningKey"]))
errors.Add("ScadaBridge:Security:JwtSigningKey required for Central");
}
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>(); var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
if (seedNodes == null || seedNodes.Count < 2)
errors.Add("ScadaBridge:Cluster:SeedNodes must have at least 2 entries");
if (role == "Site") // GrpcPort: default 8083 when absent; only fails the range rule when the key is
{ // present AND invalid. The out-param assignment mirrors the original so the
var grpcPortStr = nodeSection["GrpcPort"]; // resolved grpcPort feeds the cross-field rules even on a parse failure.
int grpcPort = 8083; // NodeOptions default when the key is absent var grpcPortStr = nodeSection["GrpcPort"];
if (grpcPortStr != null && (!int.TryParse(grpcPortStr, out grpcPort) || grpcPort < 1 || grpcPort > 65535)) int grpcPort = 8083; // NodeOptions default when the key is absent
errors.Add("ScadaBridge:Node:GrpcPort must be 1-65535"); bool grpcValid = !(grpcPortStr != null && (!int.TryParse(grpcPortStr, out grpcPort) || grpcPort < 1 || grpcPort > 65535));
// Host-007 / REQ-HOST-4: the gRPC (Kestrel HTTP/2) port and the Akka // MetricsPort: default 8084 when absent; same parse-or-default contract as GrpcPort.
// remoting port must differ. Identical values make Kestrel and var metricsPortStr = nodeSection["MetricsPort"];
// Akka.Remote contend for the same TCP port and fail opaquely at int metricsPort = 8084; // NodeOptions default when the key is absent
// runtime. Uses the resolved GrpcPort, including the 8083 default. bool metricsValid = !(metricsPortStr != null && (!int.TryParse(metricsPortStr, out metricsPort) || metricsPort < 1 || metricsPort > 65535));
if (port == grpcPort)
errors.Add("ScadaBridge:Node:GrpcPort must differ from RemotingPort");
var metricsPortStr = nodeSection["MetricsPort"]; ConfigPreflight.For(configuration)
int metricsPort = 8084; // NodeOptions default when the key is absent // Role / NodeHostname / RemotingPort (unconditional)
if (metricsPortStr != null && (!int.TryParse(metricsPortStr, out metricsPort) || metricsPort < 1 || metricsPort > 65535)) .Require("ScadaBridge:Node:Role",
errors.Add("ScadaBridge:Node:MetricsPort must be 1-65535"); _ => !(string.IsNullOrEmpty(role) || (role != "Central" && role != "Site")),
"must be 'Central' or 'Site'")
// Host-007 / REQ-HOST-4: the Kestrel metrics (HTTP/1.1) listener port .Require("ScadaBridge:Node:NodeHostname",
// must differ from BOTH the Akka remoting port and the gRPC port. _ => !string.IsNullOrEmpty(nodeSection["NodeHostname"]),
// A collision makes the metrics listener contend with Akka.Remote or "is required")
// the gRPC listener for the same TCP port and fail opaquely at runtime. .Require("ScadaBridge:Node:RemotingPort",
// Uses the resolved MetricsPort, including the 8084 default. _ => portValid,
if (metricsPort == port) "must be 1-65535")
errors.Add("ScadaBridge:Node:MetricsPort must differ from RemotingPort"); // SiteId (Site only) — note: OUTSIDE the big Site block in the original,
if (metricsPort == grpcPort) // so it must run before the unconditional SeedNodes-count rule.
errors.Add("ScadaBridge:Node:MetricsPort must differ from GrpcPort"); .When(role == "Site", p => p
.Require("ScadaBridge:Node:SiteId",
var dbSection = configuration.GetSection("ScadaBridge:Database"); _ => !string.IsNullOrEmpty(nodeSection["SiteId"]),
if (string.IsNullOrEmpty(dbSection["SiteDbPath"])) "is required for Site nodes"))
errors.Add("ScadaBridge:Database:SiteDbPath required for Site nodes"); // Central-only database/security rules.
.When(role == "Central", p => p
// Host-004: a seed node must reference an Akka.Remote endpoint, never the .Require("ScadaBridge:Database:ConfigurationDb",
// Kestrel HTTP/2 gRPC port. A seed entry whose port equals this node's _ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["ConfigurationDb"]),
// GrpcPort would make a joining node attempt an Akka.Remote TCP "connection string required for Central")
// association against the gRPC listener and fail. .Require("ScadaBridge:Security:LdapServer",
if (seedNodes != null) _ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["LdapServer"]),
"required for Central")
.Require("ScadaBridge:Security:JwtSigningKey",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["JwtSigningKey"]),
"required for Central"))
// SeedNodes count (unconditional, after SiteId).
.Require("ScadaBridge:Cluster:SeedNodes",
_ => seedNodes != null && seedNodes.Count >= 2,
"must have at least 2 entries")
// The big Site-only block: GrpcPort/MetricsPort validity + cross-field
// collisions + SiteDbPath + seed-node-port loop, in the original order.
.When(role == "Site", p =>
{ {
foreach (var seed in seedNodes) // Host-007 / REQ-HOST-4: GrpcPort range, then GrpcPort vs RemotingPort.
{ p.Require("ScadaBridge:Node:GrpcPort", _ => grpcValid, "must be 1-65535");
if (SeedNodePort(seed) == grpcPort) // Identical GrpcPort/RemotingPort make Kestrel and Akka.Remote contend
errors.Add( // for the same TCP port. Uses the resolved GrpcPort, including 8083.
$"ScadaBridge:Cluster:SeedNodes entry '{seed}' must not target the gRPC port " + p.Require("ScadaBridge:Node:GrpcPort", _ => port != grpcPort, "must differ from RemotingPort");
$"({grpcPort}); seed nodes must reference Akka remoting ports");
}
}
}
if (errors.Count > 0) // Host-007 / REQ-HOST-4: MetricsPort range, then MetricsPort vs both ports.
throw new InvalidOperationException( p.Require("ScadaBridge:Node:MetricsPort", _ => metricsValid, "must be 1-65535");
$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}"); // The Kestrel metrics (HTTP/1.1) listener port must differ from BOTH the
// Akka remoting port and the gRPC port. Uses the resolved MetricsPort (8084 default).
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsPort != port, "must differ from RemotingPort");
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsPort != grpcPort, "must differ from GrpcPort");
p.Require("ScadaBridge:Database:SiteDbPath",
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["SiteDbPath"]),
"required for Site nodes");
// Host-004: a seed node must reference an Akka.Remote endpoint, never the
// Kestrel HTTP/2 gRPC port. A seed entry whose port equals this node's
// GrpcPort would make a joining node attempt an Akka.Remote TCP
// association against the gRPC listener and fail.
foreach (var seed in seedNodes ?? Enumerable.Empty<string>())
{
p.Require("ScadaBridge:Cluster:SeedNodes",
_ => SeedNodePort(seed) != grpcPort,
$"entry '{seed}' must not target the gRPC port " +
$"({grpcPort}); seed nodes must reference Akka remoting ports");
}
})
.ThrowIfInvalid();
} }
/// <summary> /// <summary>
@@ -28,6 +28,7 @@
<!-- Transitive override: Akka.Hosting 1.5.62 pins OpenTelemetry.Api 1.9.0 which is flagged <!-- Transitive override: Akka.Hosting 1.5.62 pins OpenTelemetry.Api 1.9.0 which is flagged
(GHSA-g94r-2vxg-569j, GHSA-8785-wc3w-h8q6). Bumping directly clears both advisories. --> (GHSA-g94r-2vxg-569j, GHSA-8785-wc3w-h8q6). Bumping directly clears both advisories. -->
<PackageReference Include="OpenTelemetry.Api" /> <PackageReference Include="OpenTelemetry.Api" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
<PackageReference Include="ZB.MOM.WW.Health" /> <PackageReference Include="ZB.MOM.WW.Health" />
<PackageReference Include="ZB.MOM.WW.Health.Akka" /> <PackageReference Include="ZB.MOM.WW.Health.Akka" />
<PackageReference Include="ZB.MOM.WW.Health.EntityFrameworkCore" /> <PackageReference Include="ZB.MOM.WW.Health.EntityFrameworkCore" />
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.Security;
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// minimum-byte length contract co-located with the type that enforces it. /// minimum-byte length contract co-located with the type that enforces it.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class SecurityOptionsValidator : IValidateOptions<SecurityOptions> public sealed class SecurityOptionsValidator : OptionsValidatorBase<SecurityOptions>
{ {
/// <summary> /// <summary>
/// The configuration section name <see cref="SecurityOptions"/> is bound /// The configuration section name <see cref="SecurityOptions"/> is bound
@@ -41,30 +41,16 @@ public sealed class SecurityOptionsValidator : IValidateOptions<SecurityOptions>
public const string ConfigSectionName = "Security"; public const string ConfigSectionName = "Security";
/// <inheritdoc /> /// <inheritdoc />
public ValidateOptionsResult Validate(string? name, SecurityOptions options) protected override void Validate(ValidationBuilder builder, SecurityOptions options)
{ {
ArgumentNullException.ThrowIfNull(options); builder.RequireThat(!string.IsNullOrWhiteSpace(options.LdapServer),
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapServer)} is required " +
"but was empty or whitespace — set it to the LDAP server hostname or IP " +
"(e.g. \"ldap.example.com\").");
var failures = new List<string>(); builder.RequireThat(!string.IsNullOrWhiteSpace(options.LdapSearchBase),
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapSearchBase)} is required " +
if (string.IsNullOrWhiteSpace(options.LdapServer)) "but was empty or whitespace — set it to the search-base DN " +
{ "(e.g. \"dc=example,dc=com\").");
failures.Add(
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapServer)} is required " +
"but was empty or whitespace — set it to the LDAP server hostname or IP " +
"(e.g. \"ldap.example.com\").");
}
if (string.IsNullOrWhiteSpace(options.LdapSearchBase))
{
failures.Add(
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapSearchBase)} is required " +
"but was empty or whitespace — set it to the search-base DN " +
"(e.g. \"dc=example,dc=com\").");
}
return failures.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(failures);
} }
} }
@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authorization" /> <PackageReference Include="Microsoft.AspNetCore.Authorization" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Novell.Directory.Ldap.NETStandard" /> <PackageReference Include="Novell.Directory.Ldap.NETStandard" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>