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>(); // Secrets replication (the gRPC hub, and the SQL-Server ReplicatingSecretStore mode that // preceded it) is retired in ScadaBridge — sites receive secrets exclusively via // Deploy Artifacts, and a replicated site store would publish DeployedSecretsMirror's local // deletes fleet-wide. Checked unconditionally (both roles): a Site used to be the gRPC // hub's follower half, so the flag was never Central-only. var secretsReplicationEnabled = IsSecretsReplicationEnabled(configuration); // 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") // Retired for BOTH roles — a Site could set this too, as the gRPC hub's // follower half. Deliberately NOT folded into a role-scoped block below. .Require("Secrets:Replication:Enabled", _ => !secretsReplicationEnabled, "Secrets replication (the gRPC hub) is retired in ScadaBridge — sites receive " + "secrets with Deploy Artifacts; central uses Secrets:Store:Provider=SqlServer") // 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") // 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 key signs the CLI bearer JWT (/auth/token → ManagementAuthenticator). // JwtTokenService is resolved lazily (first token request), so its own // constructor check cannot fail the boot — a 1-byte key used to boot clean // and mint trivially forgeable tokens (review 2026-10-21 05/U1). HMAC-SHA256 // needs >= 256 bits; measured in UTF-8 bytes, exactly as the service keys the // HMAC. A missing key is reported by the rule above, not twice. .Require("ScadaBridge:Security:JwtSigningKey", value => string.IsNullOrEmpty(value) || System.Text.Encoding.UTF8.GetByteCount(value) >= ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes, $"must be at least {ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes} bytes " + "(256 bits) for HMAC-SHA256 — it signs the CLI bearer tokens, and a short key makes them forgeable") // 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)") // The inbound API key store provider (design 2026-09-02). Not required — an // unset value defaults to Sqlite (InboundApiKeyStoreOptions.ReadProvider) — but // when it IS set, it must be one of the two legal values so a typo fails fast at // boot instead of throwing later out of ReadProvider once the host is already up. .Require("ScadaBridge:InboundApi:ApiKeyStore:Provider", value => string.IsNullOrWhiteSpace(value) || (Enum.TryParse(value, ignoreCase: true, out var provider) && Enum.IsDefined(provider)), "must be 'Sqlite' or 'SqlServer' when set") // The central shared secret store provider (Secrets:Store:Provider — a fleet that // ships secrets to sites on every artifact deploy needs a clustered central pair to // see the same secret set). Not required — an unset value defaults to Sqlite, // per-node, mirroring the inbound API-key store default above — but when it IS set, // it must be one of the two legal values so a typo fails fast at boot instead of // silently keeping the per-node SQLite store it looks like it opted out of. .Require("Secrets:Store:Provider", value => string.IsNullOrWhiteSpace(value) || string.Equals(value, "Sqlite", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "SqlServer", StringComparison.OrdinalIgnoreCase), "must be 'Sqlite' or 'SqlServer' when set")) // SeedNodes count (unconditional, after SiteId). .Require("ScadaBridge:Cluster:SeedNodes", _ => seedNodes != null && seedNodes.Count >= 2, "must have at least 2 entries") // Self-first seed ordering (decision 2026-07-22). Akka runs FirstSeedNodeProcess — // the ONLY bootstrap path that can form a new cluster when no peer answers InitJoin — // exclusively when seed-nodes[0] is this node's own address. Every other node runs // JoinSeedNodeProcess and retries InitJoin forever, so a node listing its partner // first cannot cold-start alone: that is the "registered outage gap" // (docker/README.md), and it is a silent failure at boot rather than a loud one. // Enforced here rather than in ClusterOptionsValidator because only this validator // sees both the node identity and the seed list. .Require("ScadaBridge:Cluster:SeedNodes", _ => seedNodes is not { Count: >= 1 } || SeedNodeIsSelf(seedNodes[0], nodeSection["NodeHostname"], port), "must list this node itself first: seed-nodes[0] has to be this node's own " + $"'akka.tcp://scadabridge@{nodeSection["NodeHostname"]}:{port}'. Akka only lets " + "seed-nodes[0] form a new cluster, so with the partner listed first this node " + "can never start while its peer is down (Component-ClusterInfrastructure.md → " + "Seed Node Ordering)") // The big Site-only block: GrpcPort/MetricsPort validity + cross-field // collisions + 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"); // The gRPC control-plane preshared key. Same argument as the inbound // API-key pepper above: without it the node boots and looks healthy, but // ControlPlaneAuthInterceptor is fail-closed, so every SiteStream call — // live subscriptions, audit pulls, cached-telemetry ingest — is refused // with PermissionDenied. A silent, total loss of the site's central-facing // surface is far worse than a loud boot failure, so require it here. // Central holds the matching value per site in its secret store as // SB-GRPC-PSK-{SiteId}; production supplies this one as // ${secret:SB-GRPC-PSK-}, expanded before the host is built. p.Require("ScadaBridge:Communication:GrpcPsk", value => !string.IsNullOrWhiteSpace(value), "is required for Site nodes: it is the preshared key the gRPC control " + "plane authenticates with, and the interceptor is fail-closed, so an " + "unset key refuses every SiteStream call. Set the same value here (in " + "production as ${secret:SB-GRPC-PSK-}) and under the secret " + "name SB-GRPC-PSK- in central's secret store"); // gRPC (CentralControlService) is the only site→central transport after the // ClusterClient→gRPC migration's Phase 4 — the Akka ClusterClient path and its // CentralContactPoints option are gone. A site with no central gRPC endpoint has // nothing to dial: heartbeats, health reports, notification forwards and audit // ingest all silently fail. The shared CommunicationOptionsValidator only rejects // BLANK entries (it is role-agnostic, and central nodes legitimately leave the list // empty), so the "a Site must have at least one" rule lives here, where the role is // known. The predicate reads index :0 directly, so its non-empty presence proves the // list has a usable first endpoint. p.Require("ScadaBridge:Communication:CentralGrpcEndpoints:0", value => !string.IsNullOrWhiteSpace(value), "is required for Site nodes: gRPC (CentralControlService) is the only " + "site→central transport, so each site must list at least one central gRPC " + "endpoint under ScadaBridge:Communication:CentralGrpcEndpoints " + "(e.g. http://scadabridge-central-a:8083). Central nodes leave it empty."); // The site hot-path audit writer's SQLite file (arch-review remediation). // SqliteAuditWriterOptions.DatabasePath has no default (mirrors // LocalDb:Path) — an unset value would previously fall back to a bare // "auditlog.db" resolved relative to the process CWD, which on the docker // rig is the container's ephemeral overlayfs, not the mounted /app/data // volume: the file, and every pending (not-yet-forwarded) audit row in it, // was silently discarded on every container recreate. AddAuditLog binds // this options type on BOTH roles (SqliteAuditWriterOptionsValidator // deliberately does not check DatabasePath there, so Central's boot is // unaffected — see that validator's remarks), so the Site-only requirement // lives here, the same way GrpcPsk is gated just above. NOTE: unlike every // other key in this method, AuditLog:SiteWriter is a TOP-LEVEL config // section (AddAuditLog binds "AuditLog:SiteWriter" off the configuration // root, not "ScadaBridge:AuditLog:SiteWriter") — no ScadaBridge: prefix. p.Require("AuditLog:SiteWriter:DatabasePath", value => !string.IsNullOrWhiteSpace(value), "is required for Site nodes: the SQLite hot-path audit writer has no " + "default path (mirrors LocalDb:Path) — an unset value would silently " + "resolve to a CWD-relative file on the container's ephemeral overlayfs " + "and lose the pending audit backlog on every redeploy. Point it at the " + "mounted data volume, e.g. /app/data/auditlog.db."); // ScadaBridge:Database:SiteDbPath was required here until LocalDb // Phase 2. The site's tables now live in the consolidated LocalDb // database (LocalDb:Path, which SiteServiceRegistration requires), // and SiteDbPath survives only as the legacy migration source — so // its absence means "nothing to migrate", not a misconfiguration. // DatabaseOptionsValidator still rejects a present-but-blank value. // 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"); // Same failure mode as the gRPC guard: the Kestrel HTTP/1.1 // metrics listener is not an Akka.Remote endpoint, so a seed on // it dials a doomed association forever. p.Require("ScadaBridge:Cluster:SeedNodes", _ => SeedNodePort(seed) != metricsPort, $"entry '{seed}' must not target the metrics port " + $"({metricsPort}); seed nodes must reference Akka remoting ports"); } }) .ThrowIfInvalid(); } /// Non-fatal configuration findings. Collected separately from because this runs before the logger exists; Program.cs logs each entry at Warning once Serilog is configured. /// The application configuration to inspect. /// The warning messages to log; empty when there are none. public static IReadOnlyList CollectWarnings(IConfiguration configuration) { var role = configuration.GetSection("ScadaBridge:Node")["Role"]; var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get>(); var provider = InboundApiKeyStoreOptions.ReadProvider(configuration); var secretsStoreProvider = configuration["Secrets:Store:Provider"]; var secretsReplicationEnabled = IsSecretsReplicationEnabled(configuration); var isClusteredCentral = role == "Central" && seedNodes is { Count: >= 2 }; var warnings = new List(); if (isClusteredCentral && provider == InboundApiKeyStoreProvider.Sqlite) { warnings.Add( $"Inbound API keys use the per-node SQLite store on a {seedNodes!.Count}-node central " + "cluster; a failover will reject every inbound client until keys are re-issued. Set " + "ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer (design 2026-09-02)."); } // Secrets:Store:Provider defaults to Sqlite (per-node) when unset, mirroring the inbound // API-key store default above — so this fires both when the key is absent and when it is // explicitly "Sqlite". A fleet that ships secrets to sites on every artifact deploy needs // both central nodes to deploy the SAME secret set; a per-node store means each node has // silently drifted the moment anyone edits a secret on only one of them. // // No Secrets:Replication:Enabled gate here any more: that flag is now a hard // Validate() failure on either role (the gRPC hub and SQL-Server secrets replication are // both retired), so CollectWarnings never runs with it true on a config that also passed // Validate — the old gate that suppressed this warning when replication was on is dead code // and would otherwise mask the exact per-node-drift risk it exists to catch on any caller // that invokes CollectWarnings directly. if (isClusteredCentral && !string.Equals(secretsStoreProvider, "SqlServer", StringComparison.OrdinalIgnoreCase)) { warnings.Add( "Secrets use a per-node SQLite store on a clustered central — each node would " + "deploy its own secret set to sites; set Secrets:Store:Provider=SqlServer"); } // A config that still carries Secrets:Replication or // Secrets:GrpcHub child keys — left over from before the hub was retired — is not // itself an error (Validate already rejects Enabled=true outright), but every key under // either section is now silently ignored. That is worth a Warning so an operator cleans // QA/prod appsettings rather than assuming a still-present Mode/BearerToken/Endpoint value // does anything. Excludes the bare "Enabled" key itself: "Enabled: false" alone is the // normal, still-valid shape of every existing deployment's config and must not warn. var hasStaleReplicationKeys = configuration.GetSection("Secrets:Replication").GetChildren() .Any(child => !string.Equals(child.Key, "Enabled", StringComparison.OrdinalIgnoreCase)); var hasStaleGrpcHubKeys = configuration.GetSection("Secrets:GrpcHub").GetChildren().Any(); if (!secretsReplicationEnabled && (hasStaleReplicationKeys || hasStaleGrpcHubKeys)) { warnings.Add( "Secrets:Replication / Secrets:GrpcHub settings are ignored — the secrets hub is " + "retired; remove them"); } return warnings; } /// /// Whether Secrets:Replication:Enabled is "set" — read as a raw string rather than /// IConfiguration.GetValue<bool> which throws a raw /// binder on a non-boolean value like "yes" or /// "1", so a malformed leftover key would surface as an unrelated parse crash instead of /// the designed retirement message in . Any value other than absent, /// empty, or (case-insensitively) "false" counts as "set" — deliberately stricter than /// bool parsing, so a typo'd leftover (Enabled: 1, Enabled: yes) still gets the /// explanatory message rather than silently passing as falsy. Shared by /// (which rejects "set" outright) and (which, for the "not set" /// case, separately warns about any OTHER leftover key under the same retired sections). /// private static bool IsSecretsReplicationEnabled(IConfiguration configuration) { var raw = configuration["Secrets:Replication:Enabled"]; return !string.IsNullOrEmpty(raw) && !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase); } /// /// 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; } /// /// Extracts the host from an Akka seed-node address of the form /// akka.tcp://system@host:port. Returns an empty string when no host can be parsed. /// private static string SeedNodeHost(string seedNode) { if (string.IsNullOrWhiteSpace(seedNode)) return string.Empty; var at = seedNode.LastIndexOf('@'); var lastColon = seedNode.LastIndexOf(':'); if (at < 0 || lastColon <= at) return string.Empty; return seedNode[(at + 1)..lastColon]; } /// /// True when addresses this node itself (host AND port). /// Host comparison is case-insensitive because DNS names are; it is otherwise exact — /// Akka does no DNS canonicalisation either, so node-a and /// node-a.example.com are genuinely different seed identities to the cluster. /// private static bool SeedNodeIsSelf(string seedNode, string? nodeHostname, int remotingPort) { return SeedNodePort(seedNode) == remotingPort && string.Equals(SeedNodeHost(seedNode), nodeHostname, StringComparison.OrdinalIgnoreCase); } }