namespace ScadaLink.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 { public static void Validate(IConfiguration configuration) { var errors = new List(); var nodeSection = configuration.GetSection("ScadaLink:Node"); var role = nodeSection["Role"]; if (string.IsNullOrEmpty(role) || (role != "Central" && role != "Site")) errors.Add("ScadaLink:Node:Role must be 'Central' or 'Site'"); if (string.IsNullOrEmpty(nodeSection["NodeHostname"])) errors.Add("ScadaLink:Node:NodeHostname is required"); var portStr = nodeSection["RemotingPort"]; if (!int.TryParse(portStr, out var port) || port < 1 || port > 65535) errors.Add("ScadaLink:Node:RemotingPort must be 1-65535"); if (role == "Site" && string.IsNullOrEmpty(nodeSection["SiteId"])) errors.Add("ScadaLink:Node:SiteId is required for Site nodes"); if (role == "Central") { var dbSection = configuration.GetSection("ScadaLink:Database"); if (string.IsNullOrEmpty(dbSection["ConfigurationDb"])) errors.Add("ScadaLink:Database:ConfigurationDb connection string required for Central"); var secSection = configuration.GetSection("ScadaLink:Security"); if (string.IsNullOrEmpty(secSection["LdapServer"])) errors.Add("ScadaLink:Security:LdapServer required for Central"); if (string.IsNullOrEmpty(secSection["JwtSigningKey"])) errors.Add("ScadaLink:Security:JwtSigningKey required for Central"); } var seedNodes = configuration.GetSection("ScadaLink:Cluster:SeedNodes").Get>(); if (seedNodes == null || seedNodes.Count < 2) errors.Add("ScadaLink:Cluster:SeedNodes must have at least 2 entries"); if (role == "Site") { var grpcPortStr = nodeSection["GrpcPort"]; int grpcPort = 8083; // NodeOptions default when the key is absent if (grpcPortStr != null && (!int.TryParse(grpcPortStr, out grpcPort) || grpcPort < 1 || grpcPort > 65535)) errors.Add("ScadaLink:Node:GrpcPort must be 1-65535"); // Host-007 / REQ-HOST-4: the gRPC (Kestrel HTTP/2) port and the Akka // remoting port must differ. Identical values make Kestrel and // Akka.Remote contend for the same TCP port and fail opaquely at // runtime. Uses the resolved GrpcPort, including the 8083 default. if (port == grpcPort) errors.Add("ScadaLink:Node:GrpcPort must differ from RemotingPort"); var dbSection = configuration.GetSection("ScadaLink:Database"); if (string.IsNullOrEmpty(dbSection["SiteDbPath"])) errors.Add("ScadaLink: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. if (seedNodes != null) { foreach (var seed in seedNodes) { if (SeedNodePort(seed) == grpcPort) errors.Add( $"ScadaLink:Cluster:SeedNodes entry '{seed}' must not target the gRPC port " + $"({grpcPort}); seed nodes must reference Akka remoting ports"); } } } if (errors.Count > 0) throw new InvalidOperationException( $"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}"); } /// /// 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; } }