diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshPskProvider.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshPskProvider.cs new file mode 100644 index 00000000..cbdbc987 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Communication/IMeshPskProvider.cs @@ -0,0 +1,202 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.OtOpcUa.Cluster; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Communication; + +/// +/// Central's per-cluster pre-shared key lookup for the gRPC command plane. A node holds exactly +/// one key (MeshTransport:GrpcPsk, its own cluster's); central talks to every cluster, so +/// it needs one key per Cluster — both to authenticate itself when dialling a node and to +/// verify an inbound deployment ack. +/// +/// +/// +/// Fail-closed by contract. Every failure — unknown cluster, absent secret, blank +/// value, a store that throws — is reported as "no key" (false), and the caller +/// denies the inbound call or skips the outbound dial. There is no arm on which a caller may +/// proceed unauthenticated; a missing key must cost delivery, not authentication. +/// +/// +/// The Invalidate hooks exist because central's cluster set is DB-dynamic. They are +/// deliberately unconsumed at Phase 0 — Phase 1A wires them to the contact refresh, which is +/// the point at which central learns a cluster's membership (and so possibly its key) has +/// changed. +/// +/// +public interface IMeshPskProvider +{ + /// Gets the pre-shared key for one application Cluster. + /// The application Cluster id, matched case-insensitively. + /// The key, or null when none could be established. + /// true when a usable key was found; otherwise false. + bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk); + + /// Drops any cached key for one cluster, so the next lookup re-reads the store. + /// The application Cluster id, matched case-insensitively. + void Invalidate(string clusterId); + + /// Drops every cached key. + void InvalidateAll(); +} + +/// +public sealed class MeshPskProvider : IMeshPskProvider +{ + /// + /// Prefix of the secret holding a cluster's key — the full name is + /// OT-MESH-PSK-{clusterId}. The same name a node's + /// ${secret:OT-MESH-PSK-<clusterId>} reference expands, so central and the node + /// read one row rather than two conventions that drift. + /// + public const string SecretNamePrefix = "OT-MESH-PSK-"; + + private readonly IOptions _options; + private readonly ISecretResolver _secretResolver; + private readonly ILogger _logger; + + /// + /// Cached store resolutions, keyed by cluster id. Holds only values that came from the + /// Secrets store: the config map is re-read on every lookup (it is an in-memory dictionary), + /// which is what keeps the override unconditional rather than merely first-past-the-post. + /// + /// + /// under so a + /// burst of concurrent inbound acks for one cluster resolves the secret once instead of + /// stampeding the store. Negatives are never retained — see . + /// + private readonly ConcurrentDictionary> _cache = + new(StringComparer.OrdinalIgnoreCase); + + /// Creates the provider. + /// Mesh transport options; ClusterPsks is the override map. + /// The shared secret store read seam. + /// Logger for lookup-failure diagnostics. + public MeshPskProvider( + IOptions options, + ISecretResolver secretResolver, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(secretResolver); + ArgumentNullException.ThrowIfNull(logger); + + _options = options; + _secretResolver = secretResolver; + _logger = logger; + } + + /// + public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk) + { + psk = null; + + if (string.IsNullOrWhiteSpace(clusterId)) + { + _logger.LogWarning( + "Mesh PSK lookup was asked for a blank cluster id; refusing rather than resolving a " + + "key that would belong to no cluster."); + return false; + } + + // Config map first, on every call. Blank counts as absent: an empty + // MeshTransport__ClusterPsks__ is an unset environment variable, not an operator + // deciding this cluster has an empty key. + if (_options.Value.ClusterPsks.TryGetValue(clusterId, out var configured) + && !string.IsNullOrWhiteSpace(configured)) + { + psk = configured; + return true; + } + + var entry = _cache.GetOrAdd( + clusterId, + static (id, self) => new Lazy( + () => self.ResolveFromStore(id), LazyThreadSafetyMode.ExecutionAndPublication), + this); + + var resolved = entry.Value; + if (string.IsNullOrWhiteSpace(resolved)) + { + // Never retain a negative. A store that was briefly unreachable would otherwise stay + // "no key" for this cluster until something called Invalidate — and at Phase 0 nothing + // does, so a transient fault would present as a permanent, silent command outage. + _cache.TryRemove(new KeyValuePair>(clusterId, entry)); + return false; + } + + psk = resolved; + return true; + } + + /// + public void Invalidate(string clusterId) + { + if (string.IsNullOrWhiteSpace(clusterId)) + return; + + _cache.TryRemove(clusterId, out _); + } + + /// + public void InvalidateAll() => _cache.Clear(); + + /// + /// Reads OT-MESH-PSK-{clusterId} from the Secrets store, converting every failure mode + /// into null. + /// + /// The application Cluster id. + /// The stored key, or null when it could not be established. + /// + /// + /// Blocking on the async resolver is deliberate. The consumer is a gRPC server + /// interceptor, whose Authorize is synchronous in all four sibling interceptors, + /// and making this seam async would push that shape onto every one of them. The block + /// happens at most once per cluster per process — every later lookup is a dictionary hit + /// — and the store is a node-local SQLite file, not a network call. + /// + /// + /// The catch is broad on purpose: this is an authentication boundary, and the set of + /// exceptions a secret store can raise (missing KEK, corrupt row, IO) is neither closed + /// nor interesting here. Anything other than a usable key is "no key". + /// + /// + private string? ResolveFromStore(string clusterId) + { + SecretName name; + try + { + name = new SecretName(SecretNamePrefix + clusterId); + } + catch (ArgumentException ex) + { + // Cluster ids are operator-authored and may carry characters SecretName's allow-list + // rejects. Log the id — it is not sensitive — and never the key. + _logger.LogWarning( + ex, + "Cluster {ClusterId} cannot be expressed as a secret name, so its mesh PSK cannot be " + + "resolved from the store. Supply it through MeshTransport:ClusterPsks instead, or " + + "rename the cluster to [a-z0-9._/-].", + clusterId); + return null; + } + + try + { + return _secretResolver.GetAsync(name, CancellationToken.None).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to resolve the mesh PSK for cluster {ClusterId} from secret {SecretName}. " + + "Commands to that cluster and acks from it are refused until it resolves.", + clusterId, + name.Value); + return null; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index 89d2622c..0ffdd314 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -19,6 +19,10 @@ Google.Protobuf + Grpc.Core.Api flow transitively from Commons (the generated client). --> + +