fix(secrets): share the central connstr validation with the Layer-A expander
The blank/${secret:} pre-checks lived only in AddScadaBridgeSecrets, but on
a real central boot the Layer-A expander runs FIRST and would hand a bad
value to SqlConnection, burying the designed message under a generic
'initialization string' format error. Extracted both checks into
EnsureCentralSharedStoreConnectionString — one definition, called by the
expander (earliest point) and by registration (covers embedded/test
composition) — same single-source lesson as the UsesGrpcHub predicate.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
@@ -60,18 +60,19 @@ var configuration = new ConfigurationBuilder()
|
|||||||
// scadaproj#4) — an expander left on SQLite there would resolve pre-host ${secret:} references
|
// 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
|
// 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
|
// 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
|
// always was. A blank or ${secret:}-valued connection string fails HERE, through the same
|
||||||
// SQLite path too: that boot is about to fail in AddScadaBridgeSecrets with the message naming
|
// EnsureCentralSharedStoreConnectionString check AddScadaBridgeSecrets runs — this is the
|
||||||
// Secrets:SqlServer:ConnectionString, so the throw is not duplicated here.
|
// earliest code that would otherwise hand the bad value to SqlConnection, whose generic
|
||||||
|
// "initialization string" format error would bury the designed message.
|
||||||
var expanderUsesSharedSqlStore =
|
var expanderUsesSharedSqlStore =
|
||||||
string.Equals(
|
string.Equals(
|
||||||
configuration["ScadaBridge:Node:Role"], "Central", StringComparison.OrdinalIgnoreCase)
|
configuration["ScadaBridge:Node:Role"], "Central", StringComparison.OrdinalIgnoreCase)
|
||||||
&& SecretsRegistration.UsesGrpcHub(configuration)
|
&& SecretsRegistration.UsesGrpcHub(configuration);
|
||||||
&& !string.IsNullOrWhiteSpace(configuration[SecretsRegistration.HubConnectionStringKey]);
|
|
||||||
|
|
||||||
var expanderServices = new ServiceCollection();
|
var expanderServices = new ServiceCollection();
|
||||||
if (expanderUsesSharedSqlStore)
|
if (expanderUsesSharedSqlStore)
|
||||||
{
|
{
|
||||||
|
SecretsRegistration.EnsureCentralSharedStoreConnectionString(configuration);
|
||||||
expanderServices.AddZbSecretsSqlServerStore(configuration, "Secrets");
|
expanderServices.AddZbSecretsSqlServerStore(configuration, "Secrets");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -185,6 +185,59 @@ public static class SecretsRegistration
|
|||||||
&& ResolveReplicationMode(config) == SecretsReplicationMode.Grpc;
|
&& ResolveReplicationMode(config) == SecretsReplicationMode.Grpc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one definition of "a Central node in Grpc mode has a usable shared-store connection
|
||||||
|
/// string", shared by <see cref="AddScadaBridgeSecrets"/> and the Layer-A expander in
|
||||||
|
/// <c>Program.cs</c> so the two can never diverge. The expander runs FIRST on a real boot —
|
||||||
|
/// without this sharing, a bad value would die inside <c>SqlConnection</c> with a generic
|
||||||
|
/// "initialization string" format error before the designed message ever fired.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">Application configuration.</param>
|
||||||
|
/// <returns>The validated connection string.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// <see cref="HubConnectionStringKey"/> is blank or contains a <c>${secret:}</c> reference.
|
||||||
|
/// </exception>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers the host container's secret store: a plain local SQLite store by default, or a
|
/// Registers the host container's secret store: a plain local SQLite store by default, or a
|
||||||
/// replicating topology when <see cref="ReplicationEnabledKey"/> is set — a shared SQL-Server
|
/// replicating topology when <see cref="ReplicationEnabledKey"/> is set — a shared SQL-Server
|
||||||
@@ -212,45 +265,11 @@ public static class SecretsRegistration
|
|||||||
switch (role)
|
switch (role)
|
||||||
{
|
{
|
||||||
case SecretsNodeRole.Central:
|
case SecretsNodeRole.Central:
|
||||||
// Both pre-checks run BEFORE any registration so the boot failure names the
|
// Runs BEFORE any registration so the boot failure names the real problem
|
||||||
// real problem instead of surfacing later as a package validation message or a
|
// instead of surfacing later as a package validation message or a
|
||||||
// malformed-connection-string fault on first use.
|
// malformed-connection-string fault on first use. On a real boot the Layer-A
|
||||||
var hubConnectionString = config[HubConnectionStringKey];
|
// expander in Program.cs has already run the same check even earlier.
|
||||||
|
EnsureCentralSharedStoreConnectionString(config);
|
||||||
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).");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Central's store is the SHARED SQL-Server store: registers
|
// Central's store is the SHARED SQL-Server store: registers
|
||||||
// SqlServerSecretStore as ISecretStore BEFORE calling AddZbSecrets internally,
|
// SqlServerSecretStore as ISecretStore BEFORE calling AddZbSecrets internally,
|
||||||
|
|||||||
Reference in New Issue
Block a user