using System.Security.Cryptography; using System.Text; using Grpc.Core; using Grpc.Core.Interceptors; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Cluster; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// /// Gates the driver-node telemetry stream endpoint (per-cluster mesh Phase 5). The /// TelemetryStreamService.Subscribe RPC streams a node's live alerts / script-logs / /// driver-health / driver-resilience-status to whoever dials it; anything able to reach a driver /// node's telemetry port could otherwise pull that live feed out-of-band. This interceptor is the /// ONLY inbound auth on that endpoint. /// /// /// /// Scoped by method path. Only calls under /// /telemetry.v1.TelemetryStreamService/ are gated; every other method on the shared /// gRPC pipeline (config-serve, LocalDb sync) passes through untouched, so this can share one /// AddGrpc registration with and /// . /// /// /// Fail-closed. With no Telemetry:ApiKey configured, NO dial is accepted, /// authenticated or not. Treating "no key" as "no auth required" would expose the live /// telemetry feed on exactly the default shape most nodes ship with. The caller dialing in /// must present the same TelemetryDial:ApiKey (the shared node key), so a key typo /// stops telemetry delivery outright rather than degrading to unauthenticated. /// /// /// Comparison is over UTF-8 bytes, so a /// wrong key cannot be recovered byte-by-byte from response timing. Length differences are /// unavoidably observable and are not sensitive. /// /// /// Exactly one public constructor. Grpc.AspNetCore silently stops invoking an /// interceptor that has more than one public ctor — see /// TelemetryStreamAuthInterceptorTests.HasExactlyOnePublicConstructor. /// /// public sealed class TelemetryStreamAuthInterceptor : Interceptor { private const string ServicePrefix = "/telemetry.v1.TelemetryStreamService/"; private const string AuthorizationHeader = "authorization"; private const string BearerPrefix = "Bearer "; private readonly IOptions _options; private readonly ILogger _logger; /// Creates the interceptor. /// Telemetry options; ApiKey is the expected bearer token. /// Logger for denial diagnostics. public TelemetryStreamAuthInterceptor( IOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); _options = options; _logger = logger; } /// public override Task UnaryServerHandler( TRequest request, ServerCallContext context, UnaryServerMethod continuation) { Authorize(context); return continuation(request, context); } /// public override Task DuplexStreamingServerHandler( IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context, DuplexStreamingServerMethod continuation) { Authorize(context); return continuation(requestStream, responseStream, context); } /// public override Task ClientStreamingServerHandler( IAsyncStreamReader requestStream, ServerCallContext context, ClientStreamingServerMethod continuation) { Authorize(context); return continuation(requestStream, context); } /// public override Task ServerStreamingServerHandler( TRequest request, IServerStreamWriter responseStream, ServerCallContext context, ServerStreamingServerMethod continuation) { Authorize(context); return continuation(request, responseStream, context); } /// /// Throws with if this is /// a telemetry-stream call that does not carry the configured bearer token. Non-telemetry /// calls return immediately. /// private void Authorize(ServerCallContext context) { if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal)) return; var expected = _options.Value.ApiKey; // Whitespace-only counts as unconfigured: an all-blank expected key would otherwise be // satisfied by an all-blank bearer token, which is no gate at all. if (string.IsNullOrWhiteSpace(expected)) { _logger.LogWarning( "Rejected a telemetry stream call to {Method}: no Telemetry:ApiKey is configured, so " + "the telemetry endpoint is closed. Configure the shared node key on this node and every " + "dialing caller.", context.Method); throw new RpcException(new Status( StatusCode.PermissionDenied, "Telemetry stream is not accepting connections: no API key is configured on this node.")); } var presented = ExtractBearerToken(context.RequestHeaders); if (presented is null || !FixedTimeEquals(presented, expected)) { _logger.LogWarning( "Rejected a telemetry stream call to {Method}: {Reason}.", context.Method, presented is null ? "no bearer token presented" : "bearer token did not match"); throw new RpcException(new Status( StatusCode.PermissionDenied, "Telemetry stream authentication failed.")); } } private static string? ExtractBearerToken(Metadata headers) { // gRPC lowercases header keys on the wire; compare case-insensitively anyway so a hand-built // Metadata in a test behaves the same as a real request. foreach (var entry in headers) { if (!string.Equals(entry.Key, AuthorizationHeader, StringComparison.OrdinalIgnoreCase)) continue; var value = entry.Value; if (value is not null && value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) return value[BearerPrefix.Length..]; } return null; } private static bool FixedTimeEquals(string presented, string expected) => CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected)); }