diff --git a/Directory.Packages.props b/Directory.Packages.props index 5d1900f5..e80bb109 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -104,11 +104,11 @@ - - - - - + + + + + diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json index 77beee54..dd0c4a47 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json +++ b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json @@ -30,8 +30,10 @@ "_comment": "Read ONLY when Secrets:Replication:Enabled is true AND Mode=Grpc; inert otherwise. ONE section, both halves: CENTRAL reads BearerToken + MaxNamesPerRequest and hosts the hub on its CentralGrpcPort h2c listener (default 8083, alongside CentralControlService); a SITE reads Endpoint + BearerToken + the sweep timings and pulls. Replication is pull-only by wire contract - the proto has no write RPC - so secrets originate at central and a site cannot push. FAIL-CLOSED: unlike SqlServer mode there is no local-only fallback; a missing BearerToken (either role) or Endpoint (site) is a startup failure naming the key.", "_bearerToken": "Shared credential every follower presents. Supply it from appsettings or the environment (Secrets__GrpcHub__BearerToken), NOT as a ${secret:...} reference - resolving that reference is what the hub exists to make possible, so it cannot come from the hub. Same rationale and same handling as the mesh pre-shared keys. Never commit a real value here; the empty default below is fail-closed, not open.", "BearerToken": "", - "_endpoint": "SITE ONLY. Absolute http/https URI of a CENTRAL node's gRPC (h2c) port - the same address family as ScadaBridge:Communication:CentralGrpcEndpoints, e.g. 'http://central-a-host:8083'. NOT via Traefik (HTTP/1 only). NOTE the asymmetry with CentralGrpcEndpoints: that is a LIST and fails over across the central pair, whereas the hub client dials a SINGLE endpoint. A sweep against a downed central-a therefore stalls rather than failing over - which is survivable because the sweep is best-effort (one warning per interval, then retry) and the site keeps serving its full local last-known-good store, but it does mean secrets stop converging until that central node returns.", + "_endpoint": "SITE ONLY. Absolute http/https URI of a CENTRAL node's gRPC (h2c) port - the same address family as ScadaBridge:Communication:CentralGrpcEndpoints, e.g. 'http://central-a-host:8083'. NOT via Traefik (HTTP/1 only). With FallbackEndpoints left empty the hub client dials this SINGLE endpoint only: a sweep against a downed central-a then stalls rather than failing over - survivable because the sweep is best-effort (one warning per interval, then retry) and the site keeps serving its full local last-known-good store, but secrets stop converging until that central node returns. Listing the second central under FallbackEndpoints removes that stall.", "Endpoint": "", + "_fallbackEndpoints": "SITE ONLY. List the SECOND central node's hub endpoint here (same URI form as Endpoint, e.g. 'http://central-b-host:8083') so a sweep fails over across the central pair instead of stalling on a downed primary. Safe ONLY because both central nodes serve ONE shared SQL secret store, so either hub answers with the same manifest - NEVER list an endpoint backed by an independent store: failing over to an emptier hub is a silent convergence stop, the exact defect scadaproj#4 recorded. Empty by default; leaving it empty keeps the pre-0.5.0 single-endpoint behavior byte-identical.", + "FallbackEndpoints": [], "SyncInterval": "00:00:30", "SyncOnStartup": true, "CallDeadline": "00:00:30", diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs index 13592f99..25157dae 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs @@ -1,3 +1,4 @@ +using Grpc.Net.Client; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; @@ -6,6 +7,7 @@ using Microsoft.Extensions.Hosting; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Replication; using ZB.MOM.WW.Secrets.Replicator.Grpc; +using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection; using ZB.MOM.WW.Secrets.Replicator.SqlServer; using ZB.MOM.WW.Secrets.Sqlite; @@ -38,6 +40,27 @@ public class SecretsReplicationWiringTests // Syntactically valid, deliberately unreachable. Never dialled by these tests. private const string DummyHubEndpoint = "http://unused.invalid:8083"; + // Syntactically valid, deliberately unreachable. Never dialled by these tests. + private const string DummyFallbackHubEndpoint = "http://unused-fallback.invalid:8083"; + + /// + /// Keyed service key of the dialing fallback endpoint 0 — the 0.5.0 + /// package contract: the primary channel keeps + /// unchanged and fallback + /// i gets :fallback:i appended. + /// + private const string FallbackChannelKey = + SecretsGrpcHubClientExtensions.ChannelServiceKey + ":fallback:0"; + + /// + /// The package-internal reader seam the follower sweep pulls through + /// (ISecretsHubReader). Internal to the package, so it is obtained by full name and + /// resolved by — visibility never gates DI resolution, only compile-time + /// references. + /// + private static readonly Type HubReaderInterface = typeof(SecretsHubAuthInterceptor).Assembly + .GetType("ZB.MOM.WW.Secrets.Replicator.Grpc.ISecretsHubReader", throwOnError: true)!; + // Not a credential — a non-blank placeholder, which is all the fail-closed validators check. private const string DummyBearerToken = "test-hub-token"; @@ -326,6 +349,44 @@ public class SecretsReplicationWiringTests Assert.Null(provider.GetService()); } + /// + /// A configured fallback endpoint swaps the sweep's reader for the package's failover + /// composition and adds one keyed channel per fallback. Safe ONLY because both central nodes + /// serve one shared SQL store (scadaproj#4) — the type-name assertion is deliberate: the + /// failover reader is internal to the package, and its name is the observable contract here. + /// + [Fact] + public void GrpcMode_Site_WithFallbackEndpoint_ResolvesTheFailoverReader() + { + IConfiguration config = BuildConfig( + [ + .. GrpcSiteConfig(), + ("Secrets:GrpcHub:FallbackEndpoints:0", DummyFallbackHubEndpoint), + ]); + + using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Site); + + object reader = provider.GetRequiredService(HubReaderInterface); + Assert.Equal("FailoverSecretsHubReader", reader.GetType().Name); + Assert.NotNull(provider.GetKeyedService(FallbackChannelKey)); + } + + /// + /// Zero fallbacks pins the unchanged default: the reader stays the plain single-endpoint + /// client and no fallback channel enters the container — exactly the pre-0.5.0 shape every + /// existing deployment is in. + /// + [Fact] + public void GrpcMode_Site_WithoutFallbackEndpoints_KeepsThePlainHubClient() + { + using ServiceProvider provider = + BuildProvider(BuildConfig(GrpcSiteConfig()), SecretsNodeRole.Site); + + object reader = provider.GetRequiredService(HubReaderInterface); + Assert.Equal("GrpcSecretsHubClient", reader.GetType().Name); + Assert.Null(provider.GetKeyedService(FallbackChannelKey)); + } + // --------------------------------------------------------------------------------------- // gRPC mode fails closed. There is no local-only fallback here, unlike SQL-Server mode: // a site quietly serving secrets that never converge is the outcome the hub exists to prevent.