9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
148 lines
8.8 KiB
C#
148 lines
8.8 KiB
C#
using ZB.MOM.WW.Configuration;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host;
|
|
|
|
/// <summary>
|
|
/// Validates required configuration before Akka.NET actor system creation.
|
|
/// Runs early in startup to fail fast with clear error messages.
|
|
/// </summary>
|
|
public static class StartupValidator
|
|
{
|
|
/// <summary>Validates required configuration values and throws <see cref="InvalidOperationException"/> listing all errors if any are found.</summary>
|
|
/// <param name="configuration">The application configuration to validate.</param>
|
|
public static void Validate(IConfiguration configuration)
|
|
{
|
|
// 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 role = nodeSection["Role"];
|
|
|
|
var portStr = nodeSection["RemotingPort"];
|
|
bool portValid = int.TryParse(portStr, out var port) && port >= 1 && port <= 65535;
|
|
|
|
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
|
|
|
|
// 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
|
|
// resolved grpcPort feeds the cross-field rules even on a parse failure.
|
|
var grpcPortStr = nodeSection["GrpcPort"];
|
|
int grpcPort = 8083; // NodeOptions default when the key is absent
|
|
bool grpcValid = !(grpcPortStr != null && (!int.TryParse(grpcPortStr, out grpcPort) || grpcPort < 1 || grpcPort > 65535));
|
|
|
|
// MetricsPort: default 8084 when absent; same parse-or-default contract as GrpcPort.
|
|
var metricsPortStr = nodeSection["MetricsPort"];
|
|
int metricsPort = 8084; // NodeOptions default when the key is absent
|
|
bool metricsValid = !(metricsPortStr != null && (!int.TryParse(metricsPortStr, out metricsPort) || metricsPort < 1 || metricsPort > 65535));
|
|
|
|
ConfigPreflight.For(configuration)
|
|
// Role / NodeHostname / RemotingPort (unconditional)
|
|
.Require("ScadaBridge:Node:Role",
|
|
_ => !(string.IsNullOrEmpty(role) || (role != "Central" && role != "Site")),
|
|
"must be 'Central' or 'Site'")
|
|
.Require("ScadaBridge:Node:NodeHostname",
|
|
_ => !string.IsNullOrEmpty(nodeSection["NodeHostname"]),
|
|
"is required")
|
|
.Require("ScadaBridge:Node:RemotingPort",
|
|
_ => portValid,
|
|
"must be 1-65535")
|
|
// SiteId (Site only) — note: OUTSIDE the big Site block in the original,
|
|
// so it must run before the unconditional SeedNodes-count rule.
|
|
.When(role == "Site", p => p
|
|
.Require("ScadaBridge:Node:SiteId",
|
|
_ => !string.IsNullOrEmpty(nodeSection["SiteId"]),
|
|
"is required for Site nodes"))
|
|
// Central-only database/security rules.
|
|
.When(role == "Central", p => p
|
|
.Require("ScadaBridge:Database:ConfigurationDb",
|
|
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["ConfigurationDb"]),
|
|
"connection string required for Central")
|
|
.Require("ScadaBridge:Database:MachineDataDb",
|
|
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["MachineDataDb"]),
|
|
"connection string required for Central")
|
|
// The LDAP server key moved into the nested Security:Ldap
|
|
// sub-section (bound to the shared LdapOptions). Validate the nested key so
|
|
// the pre-host preflight still fails fast on a missing LDAP server for
|
|
// Central. The full LDAP option set (SearchBase / ServiceAccountDn /
|
|
// transport) is additionally validated post-host by the shared
|
|
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth).
|
|
.Require("ScadaBridge:Security:Ldap:Server",
|
|
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security:Ldap")["Server"]),
|
|
"required for Central")
|
|
.Require("ScadaBridge:Security:JwtSigningKey",
|
|
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["JwtSigningKey"]),
|
|
"required for Central")
|
|
// The inbound API-key pepper
|
|
// backs the peppered-HMAC secret compare in the shared
|
|
// ZB.MOM.WW.Auth.ApiKeys verifier (wired by AddZbApiKeyAuth at the
|
|
// Central composition root). A missing or too-short pepper does not
|
|
// fault at boot — the verifier just fails every secret compare, so the
|
|
// inbound API silently serves 401s to otherwise-valid keys. Validate it
|
|
// here (Central-only, pre-host) so a misconfigured pepper fails fast at
|
|
// startup with a clear message instead of as a runtime auth blackout.
|
|
// The Require predicate receives config[key] directly; the >=16-char
|
|
// floor matches the test pepper's minimum and the secret-strength
|
|
// baseline used elsewhere.
|
|
.Require("ScadaBridge:InboundApi:ApiKeyPepper",
|
|
value => !string.IsNullOrEmpty(value) && value.Length >= 16,
|
|
"is required and must be at least 16 characters for Central (backs the inbound API-key peppered-HMAC verifier)"))
|
|
// 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 =>
|
|
{
|
|
// GrpcPort range, then GrpcPort vs RemotingPort.
|
|
p.Require("ScadaBridge:Node:GrpcPort", _ => grpcValid, "must be 1-65535");
|
|
// Identical GrpcPort/RemotingPort make Kestrel and Akka.Remote contend
|
|
// for the same TCP port. Uses the resolved GrpcPort, including 8083.
|
|
p.Require("ScadaBridge:Node:GrpcPort", _ => port != grpcPort, "must differ from RemotingPort");
|
|
|
|
// MetricsPort range, then MetricsPort vs both ports.
|
|
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsValid, "must be 1-65535");
|
|
// 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");
|
|
|
|
// 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>
|
|
/// Extracts the TCP port from an Akka seed-node address of the form
|
|
/// <c>akka.tcp://system@host:port</c>. Returns <c>-1</c> when no port can be parsed.
|
|
/// </summary>
|
|
private static int SeedNodePort(string seedNode)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(seedNode))
|
|
return -1;
|
|
|
|
var lastColon = seedNode.LastIndexOf(':');
|
|
if (lastColon < 0 || lastColon == seedNode.Length - 1)
|
|
return -1;
|
|
|
|
return int.TryParse(seedNode[(lastColon + 1)..], out var port) ? port : -1;
|
|
}
|
|
}
|