diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 9a766a4d..8eb7deff 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -60,18 +60,19 @@ var configuration = new ConfigurationBuilder() // scadaproj#4) — an expander left on SQLite there would resolve pre-host ${secret:} references // from a stale/empty local store, silently diverging from what the node's own hub serves. Every // other case (sites, SqlServer mode, replication off) keeps the local SQLite path exactly as it -// always was. Central+Grpc with a BLANK connection string deliberately falls through to the -// SQLite path too: that boot is about to fail in AddScadaBridgeSecrets with the message naming -// Secrets:SqlServer:ConnectionString, so the throw is not duplicated here. +// always was. A blank or ${secret:}-valued connection string fails HERE, through the same +// EnsureCentralSharedStoreConnectionString check AddScadaBridgeSecrets runs — this is the +// earliest code that would otherwise hand the bad value to SqlConnection, whose generic +// "initialization string" format error would bury the designed message. var expanderUsesSharedSqlStore = string.Equals( configuration["ScadaBridge:Node:Role"], "Central", StringComparison.OrdinalIgnoreCase) - && SecretsRegistration.UsesGrpcHub(configuration) - && !string.IsNullOrWhiteSpace(configuration[SecretsRegistration.HubConnectionStringKey]); + && SecretsRegistration.UsesGrpcHub(configuration); var expanderServices = new ServiceCollection(); if (expanderUsesSharedSqlStore) { + SecretsRegistration.EnsureCentralSharedStoreConnectionString(configuration); expanderServices.AddZbSecretsSqlServerStore(configuration, "Secrets"); } else diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs index ddcbe189..9ecb761a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs @@ -185,6 +185,59 @@ public static class SecretsRegistration && ResolveReplicationMode(config) == SecretsReplicationMode.Grpc; } + /// + /// The one definition of "a Central node in Grpc mode has a usable shared-store connection + /// string", shared by and the Layer-A expander in + /// Program.cs so the two can never diverge. The expander runs FIRST on a real boot — + /// without this sharing, a bad value would die inside SqlConnection with a generic + /// "initialization string" format error before the designed message ever fired. + /// + /// Application configuration. + /// The validated connection string. + /// + /// is blank or contains a ${secret:} reference. + /// + internal static string EnsureCentralSharedStoreConnectionString(IConfiguration config) + { + var hubConnectionString = config[HubConnectionStringKey]; + + if (string.IsNullOrWhiteSpace(hubConnectionString)) + { + // Fail closed, matching the mode's philosophy: central in Grpc mode REQUIRES the + // shared SQL store. An independent local store per central node is exactly the + // divergence scadaproj#4 recorded — one central hub answered authenticated followers + // with an EMPTY manifest, so a hub failover would "succeed" against nothing and stop + // convergence silently. + throw new InvalidOperationException( + $"{HubConnectionStringKey} is empty, but this node is Central with " + + $"{ReplicationModeKey}=Grpc. Central in Grpc mode requires the " + + "SHARED SQL-Server secret store — an independent local store per " + + "central node is the divergence scadaproj#4 recorded (a hub " + + "failover would 'succeed' against an empty manifest). Supply the " + + "connection string via appsettings or the environment " + + "(Secrets__SqlServer__ConnectionString)."); + } + + if (hubConnectionString.Contains("${secret:", StringComparison.Ordinal)) + { + // The secrets store's own connection string can never be a secret reference: the + // pre-host expander needs the store this string points at in order to resolve it — + // bootstrap circularity, the same rule the class remarks document for the hub bearer + // token. The message deliberately does not echo the configured value: a mistyped + // value here could be a pasted real credential. + throw new InvalidOperationException( + $"{HubConnectionStringKey} contains a ${{secret:}} reference. The " + + "secret store's own connection string can never be a secret " + + "reference — the pre-host expander needs the store this string " + + "points at in order to resolve it (bootstrap circularity, the " + + "same rule as Secrets:GrpcHub:BearerToken). Supply a literal " + + "value via appsettings or the environment " + + "(Secrets__SqlServer__ConnectionString)."); + } + + return hubConnectionString; + } + /// /// Registers the host container's secret store: a plain local SQLite store by default, or a /// replicating topology when is set — a shared SQL-Server @@ -212,45 +265,11 @@ public static class SecretsRegistration switch (role) { case SecretsNodeRole.Central: - // Both pre-checks run BEFORE any registration so the boot failure names the - // real problem instead of surfacing later as a package validation message or a - // malformed-connection-string fault on first use. - var hubConnectionString = config[HubConnectionStringKey]; - - if (string.IsNullOrWhiteSpace(hubConnectionString)) - { - // Fail closed, matching the mode's philosophy: central in Grpc mode - // REQUIRES the shared SQL store. An independent local store per central - // node is exactly the divergence scadaproj#4 recorded — one central hub - // answered authenticated followers with an EMPTY manifest, so a hub - // failover would "succeed" against nothing and stop convergence silently. - throw new InvalidOperationException( - $"{HubConnectionStringKey} is empty, but this node is Central with " - + $"{ReplicationModeKey}=Grpc. Central in Grpc mode requires the " - + "SHARED SQL-Server secret store — an independent local store per " - + "central node is the divergence scadaproj#4 recorded (a hub " - + "failover would 'succeed' against an empty manifest). Supply the " - + "connection string via appsettings or the environment " - + "(Secrets__SqlServer__ConnectionString)."); - } - - if (hubConnectionString.Contains("${secret:", StringComparison.Ordinal)) - { - // The secrets store's own connection string can never be a secret - // reference: the pre-host expander needs the store this string points at - // in order to resolve it — bootstrap circularity, the same rule the class - // remarks document for the hub bearer token. The message deliberately - // does not echo the configured value: a mistyped value here could be a - // pasted real credential. - throw new InvalidOperationException( - $"{HubConnectionStringKey} contains a ${{secret:}} reference. The " - + "secret store's own connection string can never be a secret " - + "reference — the pre-host expander needs the store this string " - + "points at in order to resolve it (bootstrap circularity, the " - + "same rule as Secrets:GrpcHub:BearerToken). Supply a literal " - + "value via appsettings or the environment " - + "(Secrets__SqlServer__ConnectionString)."); - } + // Runs BEFORE any registration so the boot failure names the real problem + // instead of surfacing later as a package validation message or a + // malformed-connection-string fault on first use. On a real boot the Layer-A + // expander in Program.cs has already run the same check even earlier. + EnsureCentralSharedStoreConnectionString(config); // Central's store is the SHARED SQL-Server store: registers // SqlServerSecretStore as ISecretStore BEFORE calling AddZbSecrets internally,