using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Hosting.WindowsServices; using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection; using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection; namespace ZB.MOM.WW.ScadaBridge.Host; /// /// Which half of a clustered secrets topology this node composes. Passed in by the caller rather /// than read from configuration, because both composition roots already know statically which one /// they are — Program.cs for central and for a site — /// and a role read from a key could be got wrong in a way that silently turns a site into a hub. /// public enum SecretsNodeRole { /// The central cluster. Hosts the gRPC secrets hub; never sweeps from one. Central, /// A site cluster. Sweeps from central's hub; never hosts one. Site, } /// /// The clustered-replication transport, selected by Secrets:Replication:Mode. /// public enum SecretsReplicationMode { /// /// Local store bidirectionally synced with a shared SQL-Server hub. The historical mode and /// therefore the default, so an existing configuration that sets only /// Secrets:Replication:Enabled keeps behaving exactly as it did. /// SqlServer, /// /// Local store on every node, converged by a pull-only gRPC hub that central hosts. The /// production topology (scadaproj#3). /// Grpc, } /// /// Single composition-root entry point for the host container's secret store, shared by the /// central-role registrations in Program.cs and the site-role registrations in /// so both roles wire secrets identically. /// /// /// /// Clustered replication is opt-in and off by default. ScadaBridge is hub-and-spoke — one /// central cluster plus N separate site clusters — and every supported topology gives each SITE /// node a LOCAL store, so a site keeps resolving secrets straight through a WAN outage. What /// differs between the modes is how convergence works, and — in gRPC mode — where central's own /// store lives. /// /// /// is the production topology (scadaproj#3): /// central hosts a pull-only gRPC hub and each site node sweeps it. It exists because /// requires every site node to hold a connection /// string to central's database, which violates ScadaBridge's standing rule that sites talk to /// central, not to central's DB. Pull-only is a property of the wire contract rather than a /// setting: the proto carries no write RPC, so a site cannot push even if misconfigured, and /// secrets therefore originate at central only. Central's own store in this mode is the SHARED /// SQL-Server store (AddZbSecretsSqlServerStore), not local SQLite: both central nodes /// read and write ONE copy of every row, so the two hub instances serve identical manifests by /// construction. The alternative — an independent local store per central node — is the recorded /// defect (scadaproj#4): one central answering authenticated followers with an empty manifest, /// turning site-side hub failover into a silent convergence stop. The accepted trade is that /// central secret resolution now depends on SQL Server availability — acceptable because central /// is already SQL-coupled to its core, and a SQL outage makes the hub throw (sites keep /// last-known-good), never serve empty. The pre-host ${secret:} expander in /// Program.cs follows this store swap, so pre-host references resolve from the same store /// the running node serves. /// /// /// The SQL-Server gate is not a style preference. AddZbSecretsSqlServerReplication /// validates its options EAGERLY at registration time, so calling it unconditionally would throw at /// startup on every node where Secrets:SqlServer:ConnectionString is unset — which is every /// dev box, every docker node and every existing deployment. Both the explicit /// Secrets:Replication:Enabled flag and a non-blank connection string are therefore required /// before the SQL-Server package is touched at all; otherwise this is byte-for-byte the plain /// local-SQLite registration it has always been. /// /// /// The gRPC mode deliberately does NOT degrade the same way. A missing connection string in /// SQL-Server mode falls back to a local-only store with a loud warning, and that behaviour is /// preserved untouched. gRPC mode instead fails at startup when its endpoint or bearer token is /// missing — or, on central, when the shared-store connection string is — because the fallback is /// the exact outcome the hub exists to prevent — a site serving secrets that silently never /// converge with central — and because selecting the mode takes an explicit second key, so an /// operator who typed it meant it. /// /// /// Bootstrap constraint. A replication credential can never itself come from the replicated /// set — a node cannot read the hub to learn how to reach the hub. In SQL-Server mode the hub /// connection string must arrive from outside it: an environment variable, or a ${secret:} /// reference seeded in that node's own LOCAL store (the pre-host expander in Program.cs /// runs against the local SQLite store before the host container exists, so such a reference does /// resolve). In gRPC mode the rule is stricter on central: Secrets:SqlServer:ConnectionString /// can never be a ${secret:} reference at all, because on central the pre-host expander /// itself runs against the shared SQL store (see Program.cs) and would need this very /// string to reach the store that resolves it — registration rejects such a value outright. The /// same holds for Secrets:GrpcHub:BearerToken, which is supplied from appsettings or the /// environment exactly as the mesh pre-shared keys are. /// /// public static class SecretsRegistration { /// Configuration section holding the core secrets options. public const string SecretsSectionPath = "Secrets"; /// Configuration section holding the SQL-Server hub options. public const string SqlServerSectionPath = "Secrets:SqlServer"; /// /// Configuration section holding the gRPC hub options. One section serves both halves: central /// reads BearerToken + MaxNamesPerRequest from it, a site reads Endpoint, /// BearerToken and the sweep timings. /// public const string GrpcHubSectionPath = "Secrets:GrpcHub"; /// Configuration key gating clustered replication. Absent or false = off. public const string ReplicationEnabledKey = "Secrets:Replication:Enabled"; /// /// Configuration key selecting the replication transport. Absent or blank = /// , the historical behaviour. /// public const string ReplicationModeKey = "Secrets:Replication:Mode"; /// Configuration key holding the shared SQL-Server hub connection string. public const string HubConnectionStringKey = "Secrets:SqlServer:ConnectionString"; /// /// Reads . An absent or blank value is /// ; anything unrecognised throws. /// /// /// The value is validated whenever it is present, even with replication switched off. A typo /// only surfaces at the boot that introduces it that way — deferring the check to whenever /// somebody later flips would fail a node in an unrelated /// change, pointing at a key nobody touched. /// /// Application configuration. /// The selected mode. /// The configured value is not a known mode. public static SecretsReplicationMode ResolveReplicationMode(IConfiguration config) { ArgumentNullException.ThrowIfNull(config); var raw = config[ReplicationModeKey]; if (string.IsNullOrWhiteSpace(raw)) { return SecretsReplicationMode.SqlServer; } if (!Enum.TryParse(raw.Trim(), ignoreCase: true, out SecretsReplicationMode mode) || !Enum.IsDefined(mode)) { throw new InvalidOperationException( $"{ReplicationModeKey} is '{raw}', which is not a known secrets replication mode. " + $"Valid values are '{nameof(SecretsReplicationMode.SqlServer)}' and " + $"'{nameof(SecretsReplicationMode.Grpc)}'."); } return mode; } /// /// Whether this node participates in the pull-only gRPC secrets hub. /// /// /// The single predicate behind BOTH the service registration and /// , so the two can never disagree. That matters more /// than tidiness: mapping the hub endpoint without registering it would map a service whose /// fail-closed bearer interceptor was never attached — an unauthenticated hub serving every /// secret central holds. /// /// Application configuration. /// when replication is enabled in gRPC mode. public static bool UsesGrpcHub(IConfiguration config) { ArgumentNullException.ThrowIfNull(config); return config.GetValue(ReplicationEnabledKey) && ResolveReplicationMode(config) == SecretsReplicationMode.Grpc; } /// /// Resolves the content root the HOST will use, from outside the host — the Layer-A /// ${secret:} expander in Program.cs runs before any builder exists, so /// builder.Environment.ContentRootPath is not available to it. /// /// /// Mirrors the framework's own resolution order. The Windows-Service branch is the one that /// is easy to get wrong: UseWindowsService sets both the current directory and the /// content root to , but only once the host is built — /// at expander time the process working directory is still C:\Windows\system32, so /// taking the current directory there would compare the store path against the wrong root. /// /// The absolute content-root path the host will resolve. public static string ResolveHostContentRoot() => Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT") ?? Environment.GetEnvironmentVariable("DOTNET_CONTENTROOT") ?? (WindowsServiceHelpers.IsWindowsService() ? AppContext.BaseDirectory : Directory.GetCurrentDirectory()); /// /// Composes the SQLite secrets stack for the Layer-A pre-host ${secret:} expander. /// /// /// /// This exists as a named method rather than an inline AddZbSecrets call so that the /// content-root argument is covered by a test (PreHostSecretsContentRootTests). The /// argument is load-bearing and its absence is SILENT: the expander composes into a throwaway /// that has no IHostEnvironment, so without it the /// under-content-root rule does not run, the migrator creates the store at the rejected path, /// and the boot then fails once the real host registers IHostEnvironment — leaving an /// empty database behind. That "the file is there, it's just empty" artifact is the confusing /// half of the 2026-08-09 MxGateway outage the guard exists to prevent. /// /// /// A correctly configured path shows NO symptom either way, so this cannot be verified by /// observing a clean boot — the exposure is that the guard is not running when the store is /// created, not that any particular path is wrong. /// /// /// The throwaway expander service collection. /// Application configuration. /// The host's content root, from . /// The same collection, for chaining. public static IServiceCollection AddPreHostSqliteExpander( this IServiceCollection services, IConfiguration config, string contentRoot) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(config); ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot); // Do NOT drop the third argument. The 3-argument overload still compiles and silently // reopens the gap described above; the regression would arrive disguised as removing a // redundant parameter. return services.AddZbSecrets(config, SecretsSectionPath, contentRoot); } /// /// 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) { ArgumentNullException.ThrowIfNull(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 /// hub, or the pull-only gRPC hub whose half is chosen by . In gRPC /// mode a central node's store is the SHARED SQL-Server store, and registration fails closed /// when is blank or is a ${secret:} reference. /// /// The service collection to register into. /// Application configuration for options binding. /// Which half of a clustered topology this node composes. /// The same instance, for chaining. public static IServiceCollection AddScadaBridgeSecrets( this IServiceCollection services, IConfiguration config, SecretsNodeRole role) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(config); // UsesGrpcHub is the single predicate shared with MapScadaBridgeSecretsHub — the branch // condition must never be re-derived here, or registration and endpoint mapping could // disagree about whether the hub is on. if (UsesGrpcHub(config)) { switch (role) { case SecretsNodeRole.Central: // 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, // displacing SQLite — do NOT also call AddZbSecrets here. One copy of every // row means both central hubs serve identical manifests by construction, which // is what makes site-side hub failover between them safe. services.AddZbSecretsSqlServerStore( config, SecretsSectionPath, SqlServerSectionPath); // Throws when Secrets:GrpcHub:BearerToken is unset. Deliberately not caught: a // hub that starts and refuses every follower looks like a network fault from // the site end, and would be discovered as a stale secret rather than as a // boot failure. services.AddZbSecretsGrpcHub(config, GrpcHubSectionPath); break; case SecretsNodeRole.Site: // A site keeps its LOCAL SQLite store — it resolves secrets straight through a // WAN outage, and sites talk to central, never to central's database. Neither // gRPC extension registers the local store — pull-only means local writes // never leave the node, so there is nothing to decorate and the store stays // exactly as configured. Register it first; the sweep resolves it. services.AddZbSecrets(config, SecretsSectionPath); // Throws when Secrets:GrpcHub:Endpoint or :BearerToken is unset. Same reasoning. services.AddZbSecretsGrpcHubClient(config, GrpcHubSectionPath); break; default: throw new ArgumentOutOfRangeException(nameof(role), role, "Unknown SecretsNodeRole — a new role must decide explicitly which half " + "of the gRPC hub it composes rather than fall into either branch."); } return services; } // Reached with the mode already validated (UsesGrpcHub resolves it on every path), so a // false answer here means SqlServer mode or replication off — never an unvetted mode value. var enabled = config.GetValue(ReplicationEnabledKey); var connectionString = config[HubConnectionStringKey]; if (enabled && !string.IsNullOrWhiteSpace(connectionString)) { // Registers the validated hub options, decorates ISecretStore with // ReplicatingSecretStore, and adds the hub-schema migration + bidirectional sweep // hosted services. Calls AddZbSecrets internally — do NOT also call it here. return services.AddZbSecretsSqlServerReplication( config, SecretsSectionPath, SqlServerSectionPath); } if (enabled) { // Asked for replication but gave nothing to replicate against. Falling back to the // local-only store keeps the node up, but it is silently NOT participating in the // cluster, so say so loudly rather than letting it look healthy. Serilog.Log.Warning( "{Key} is true but {ConnKey} is empty — clustered secret replication is DISABLED " + "and this node's secrets are local-only. The hub connection string cannot come " + "from the hub itself; supply it via environment variable or seed it in this " + "node's local store.", ReplicationEnabledKey, HubConnectionStringKey); } return services.AddZbSecrets(config, SecretsSectionPath); } /// /// Maps the central-hosted secrets hub endpoint, but only on a node whose configuration /// actually registered it. A no-op otherwise. /// /// /// Central-only by construction: registers the hub service /// and its bearer interceptor solely for , and a site node /// never reaches this call. The endpoint lands on the shared endpoint routing, so it is served /// on the dedicated h2c control-plane listener (ScadaBridge:Node:CentralGrpcPort, /// default 8083) — the same listener and the same addressing convention sites already use to /// reach CentralControlService. /// /// The endpoint route builder. /// Application configuration. /// The same instance, for chaining. public static IEndpointRouteBuilder MapScadaBridgeSecretsHub( this IEndpointRouteBuilder endpoints, IConfiguration config) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(config); if (!UsesGrpcHub(config)) { return endpoints; } return endpoints.MapZbSecretsHub(); } }