using ZB.MOM.WW.Configuration; namespace ZB.MOM.WW.ScadaBridge.Host; /// /// Validates required configuration before Akka.NET actor system creation. /// Runs early in startup to fail fast with clear error messages. /// public static class StartupValidator { /// Validates required configuration values and throws listing all errors if any are found. /// The application configuration to validate. 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>(); // 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()) { 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(); } /// /// Extracts the TCP port from an Akka seed-node address of the form /// akka.tcp://system@host:port. Returns -1 when no port can be parsed. /// 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; } }