namespace ZB.MOM.WW.Secrets.Replicator.Grpc; /// /// Configuration for a site node that follows the central-hosted secrets hub. Bound from a /// configuration section by AddZbSecretsGrpcHubClient. /// /// /// /// Every field here describes how to reach the hub. There is deliberately no knob that /// changes the direction of replication: this package is pull-only by construction and the wire /// contract carries no write RPC, so a follower cannot be configured into pushing. /// /// public sealed class SecretsGrpcHubClientOptions { /// /// The hub's gRPC address, as an absolute http or https URI (for example /// https://central:5001). Required. /// /// /// A plain http endpoint is accepted so an h2c-only or in-cluster deployment can be /// stood up, but it sends in cleartext on every call — anyone /// on the path reads the hub credential and can then pull central's entire secret inventory. /// Treat http as a development and loopback-only convenience; a site node reaching /// central across a plant network must use https. This is documentation rather than a /// rule the validator enforces, because "internal" is a property of a deployment's network and /// cannot be decided from a URI string. /// public string Endpoint { get; set; } = string.Empty; /// /// Additional hub addresses tried, in order, when a call to (or an /// earlier fallback) fails with a transport-level gRPC error. Optional; empty by default. /// Meaningful only when every listed endpoint serves the SAME store — for ScadaBridge that /// is the central pair over one shared SQL Server store. Do NOT list endpoints backed by /// independent stores: failing over to an emptier hub is a silent convergence stop, which is /// the exact defect (scadaproj#4) the shared store exists to prevent. /// /// /// Getter-only so the configuration binder adds entries to this instance rather than needing a /// setter. Mutating the list after has run is unsupported — the DI /// extension reads it exactly once, at registration, to shape the channel graph. Each entry /// carries the same http-cleartext caveat as : a plain http /// fallback sends in cleartext on every failed-over call. /// public IList FallbackEndpoints { get; } = []; /// /// Shared bearer token presented on every call. Required — an unset token is a hard startup /// failure rather than an anonymous call, because the hub authenticates on this alone. /// /// /// This value is never logged, never included in an exception message, and never echoed back by /// validation: a startup log is read by far more people than the deployment secret store is. /// Supply it the way the mesh PSKs are supplied — appsettings or environment — not as a /// ${secret:} reference, which would need the very store this client exists to fill. /// public string BearerToken { get; set; } = string.Empty; /// /// How often the follower sweeps the hub for rows it is missing or behind on. Defaults to /// 30 seconds. Must be positive. /// public TimeSpan SyncInterval { get; set; } = TimeSpan.FromSeconds(30); /// /// When (the default), a sweep runs at startup before the first interval /// elapses, so a node that was down while central wrote does not serve stale secrets for a whole /// interval after it comes back. /// public bool SyncOnStartup { get; set; } = true; /// /// Deadline applied to each hub call. Defaults to 30 seconds — long enough for a busy central /// node, short enough that an unreachable hub fails the sweep and is retried on the next tick /// instead of pinning the sweep loop indefinitely. /// public TimeSpan CallDeadline { get; set; } = TimeSpan.FromSeconds(30); /// /// How often an in-sync sweep (nothing to pull) is allowed to log an Information-level /// heartbeat. Defaults to one hour. Must be positive. /// /// /// A converged follower sweeps every — 30 seconds by default — and /// finds nothing new almost every time. Logging that outcome at Information on every sweep /// would drown the log; staying silent forever would make "the sweep loop is alive and the hub /// still agrees with this node" unobservable between the rare sweeps that actually pull /// something. Every sweep still logs at Debug regardless of this interval, so a verbose log /// level shows the true cadence when it is needed. /// public TimeSpan InSyncLogInterval { get; set; } = TimeSpan.FromHours(1); /// /// Throws if the options are unusable. Called by the DI extension at registration time so a /// misconfigured node fails at startup rather than on the first silent sweep failure. /// /// /// The configuration section the values were bound from, so each message names the key an /// operator actually has to edit rather than the default one. /// /// /// The endpoint is missing or is not an absolute http/https URI, a fallback endpoint is blank, /// not an absolute http/https URI, or duplicates another endpoint, the bearer token is missing, /// or an interval is not positive. /// public void Validate(string sectionPath = DefaultSectionPath) { ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath); if (string.IsNullOrWhiteSpace(Endpoint)) { throw new InvalidOperationException( $"gRPC secrets hub client requires an Endpoint ({sectionPath}:Endpoint)."); } if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out Uri? uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { // Rejected here rather than at GrpcChannel.ForAddress, which throws deep inside the // first sweep — by which point the failure looks like a hub outage instead of a typo. throw new InvalidOperationException( $"gRPC secrets hub client Endpoint must be an absolute http or https URI " + $"({sectionPath}:Endpoint, was '{Endpoint}')."); } var seenEndpoints = new HashSet(StringComparer.OrdinalIgnoreCase) { Endpoint }; foreach (string fallback in FallbackEndpoints) { if (string.IsNullOrWhiteSpace(fallback)) { throw new InvalidOperationException( $"gRPC secrets hub client fallback endpoints must not be blank " + $"({sectionPath}:FallbackEndpoints)."); } if (!Uri.TryCreate(fallback, UriKind.Absolute, out Uri? fallbackUri) || (fallbackUri.Scheme != Uri.UriSchemeHttp && fallbackUri.Scheme != Uri.UriSchemeHttps)) { throw new InvalidOperationException( $"gRPC secrets hub client fallback endpoints must be absolute http or https " + $"URIs ({sectionPath}:FallbackEndpoints, entry '{fallback}')."); } if (!seenEndpoints.Add(fallback)) { // A duplicated endpoint silently halves the failover the operator thinks they // configured: the rotation would try the same address twice and call it two tries. throw new InvalidOperationException( $"gRPC secrets hub client fallback endpoints must not repeat the Endpoint or " + $"each other ({sectionPath}:FallbackEndpoints, entry '{fallback}')."); } } if (string.IsNullOrWhiteSpace(BearerToken)) { // Fail closed. An empty token would otherwise be presented as `Bearer `, which the hub // rejects on every call — an authentication outage that looks like a network problem. throw new InvalidOperationException( $"gRPC secrets hub client requires a BearerToken ({sectionPath}:BearerToken); " + "it is unset, so this node has no way to authenticate to the hub."); } if (SyncInterval <= TimeSpan.Zero) { throw new InvalidOperationException( $"gRPC secrets hub client SyncInterval must be positive " + $"({sectionPath}:SyncInterval, was {SyncInterval})."); } if (CallDeadline <= TimeSpan.Zero) { throw new InvalidOperationException( $"gRPC secrets hub client CallDeadline must be positive " + $"({sectionPath}:CallDeadline, was {CallDeadline})."); } if (InSyncLogInterval <= TimeSpan.Zero) { throw new InvalidOperationException( $"gRPC secrets hub client InSyncLogInterval must be positive " + $"({sectionPath}:InSyncLogInterval, was {InSyncLogInterval})."); } } /// The configuration section these options are bound from unless a caller overrides it. public const string DefaultSectionPath = "Secrets:GrpcHub"; }