feat(grpc): host CentralControlService on the central node (T1A.2)
Central now ALSO listens for the seven site→central control messages over gRPC, alongside the existing ClusterClient path. Nothing flips to gRPC yet — sites keep CentralTransport=Akka (T1A.3's job); central simply starts also accepting. - CentralControlGrpcService (Communication.Grpc): decodes each RPC onto the SAME in-process message the ClusterClient path carries, Asks the existing CentralCommunicationActor (zero handler-logic changes), encodes the reply via the T1A.1 mapper. Readiness-gated like SiteStreamGrpcServer.SetReady — Unavailable until AkkaHostedService hands the actor over. Heartbeat stays fire-and-forget (Tell, always-OK, never gated on readiness). Ingest reuses the shared SiteStreamGrpcServer.AuditIngestAskTimeout constant. Fault→status mapping is retry-aware: Unavailable (never dispatched, safe to cross-node retry) vs DeadlineExceeded/Internal (it ran, do not re-send elsewhere). - CentralControlAuthInterceptor (Host): a SEPARATE interceptor class, not a variant constructor on ControlPlaneAuthInterceptor. Central's model is per-site (verify the Bearer token against the key for the site in the required x-scadabridge-site header, via ISitePskProvider) where a site verifies its one own-key — a genuinely different model. Fail-closed on every branch: missing or blank header, unresolvable key, and mismatched token all → PermissionDenied, never pass-through. One public constructor only (the explicit-prefix ctor is internal), pinned by a reflection test — a second public ctor makes Grpc.AspNetCore's GetFactory() throw per-call and silently disables the gate. - Explicit Kestrel h2c listener on new option ScadaBridge:Node:CentralGrpcPort (default 8083, symmetric with sites), mirroring the Site branch. Additive to central's :5000 HTTP/1 surface, which is untouched — gRPC does NOT go through Traefik (HTTP/1 only). Registered by type on AddGrpc; service mapped with MapGrpcService. Port range-validated by NodeOptionsValidator. - Rig: publish the central gRPC port 9013:8083 / 9014:8083 on both central nodes so a later task can exercise it. Tests: CentralControlEndToEndTests (Host.Tests, TestServer + real interceptor + real service over a stub actor) proves auth positives/negatives are distinguishable and covers unary + the ingest bridge shapes; the interceptor is registered BY TYPE, never in DI. CentralControlAuthInterceptorTests pins the per-site gate + one-public-ctor invariant. CentralControlGrpcServiceTests (Communication.Tests, TestKit) covers the readiness gate, fire-and-forget heartbeat, and the DeadlineExceeded-vs-Unavailable status mapping. No active <Protobuf> item. Communication.Tests (356) + Host.Tests (384) green.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Gates the central-hosted gRPC control plane (<c>CentralControlService</c>) with each site's
|
||||
/// preshared key. The sibling of <see cref="ControlPlaneAuthInterceptor"/>, but with the
|
||||
/// verification model inverted: a site checks one bearer token against its own single key, whereas
|
||||
/// central must check the presented token against the key belonging to the SITE that sent it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a separate class rather than a second constructor on
|
||||
/// <see cref="ControlPlaneAuthInterceptor"/>.</b> <c>Grpc.AspNetCore</c> registers a
|
||||
/// type-registered interceptor through <c>InterceptorRegistration.GetFactory()</c>, which throws
|
||||
/// <c>"Multiple constructors accepting all given argument types have been found"</c> the moment a
|
||||
/// second public constructor is applicable. That throw lands inside the pipeline on every call and
|
||||
/// surfaces as <c>Unknown / "Exception was thrown by handler"</c> — the node boots healthy and
|
||||
/// every gated call dies looking like a handler bug, with correct/wrong/no key all producing the
|
||||
/// identical error. It shipped once with a fully green suite and was caught only on the rig.
|
||||
/// Central's model genuinely differs (per-site key by header, not the site's one own-key), so it
|
||||
/// gets its own class with its own single public constructor rather than a variant ctor on the
|
||||
/// site interceptor. Both classes are pinned by a reflection test asserting exactly one public
|
||||
/// constructor.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fail-closed on every branch.</b> A gated call is refused with
|
||||
/// <see cref="StatusCode.PermissionDenied"/> when: the <c>x-scadabridge-site</c> header is
|
||||
/// missing or blank; no key can be resolved for that site (<see cref="ISitePskProvider"/> throws);
|
||||
/// or the presented bearer token does not match. There is no pass-through — an unresolvable or
|
||||
/// absent identity never degrades to "let it in". Non-gated services (should any share the
|
||||
/// listener) return immediately, matching the site interceptor's shape.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The comparison is <see cref="CryptographicOperations.FixedTimeEquals"/> over UTF-8, the same
|
||||
/// constant-time compare the site interceptor and LocalDb sync use.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CentralControlAuthInterceptor : Interceptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Service prefixes gated by default — the one central-hosted control-plane service. Taken
|
||||
/// from the generated <c>package scadabridge.centralcontrol.v1; service CentralControlService</c>.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> DefaultGatedPrefixes =
|
||||
new[] { $"/{CentralControlService.Descriptor.FullName}/" };
|
||||
|
||||
private readonly IReadOnlyList<string> _gatedPrefixes;
|
||||
private readonly ISitePskProvider _pskProvider;
|
||||
private readonly ILogger<CentralControlAuthInterceptor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the interceptor gating <see cref="DefaultGatedPrefixes"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This must remain the ONLY public constructor</b> — see the class remarks for why a
|
||||
/// second one silently disables the gate. Pinned by
|
||||
/// <c>CentralControlAuthInterceptorTests.TheInterceptorHasExactlyOnePublicConstructor</c>.
|
||||
/// </remarks>
|
||||
/// <param name="pskProvider">Resolves each site's preshared key.</param>
|
||||
/// <param name="logger">Logger for denial diagnostics.</param>
|
||||
public CentralControlAuthInterceptor(
|
||||
ISitePskProvider pskProvider,
|
||||
ILogger<CentralControlAuthInterceptor> logger)
|
||||
: this(pskProvider, logger, DefaultGatedPrefixes)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the interceptor gating an explicit prefix set. <b>Internal</b> — a public second
|
||||
/// constructor would reintroduce the ambiguous-constructor defect described on the class.
|
||||
/// </summary>
|
||||
/// <param name="pskProvider">Resolves each site's preshared key.</param>
|
||||
/// <param name="logger">Logger for denial diagnostics.</param>
|
||||
/// <param name="gatedPrefixes">Method-path prefixes to gate.</param>
|
||||
internal CentralControlAuthInterceptor(
|
||||
ISitePskProvider pskProvider,
|
||||
ILogger<CentralControlAuthInterceptor> logger,
|
||||
IReadOnlyList<string> gatedPrefixes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pskProvider);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(gatedPrefixes);
|
||||
|
||||
_pskProvider = pskProvider;
|
||||
_logger = logger;
|
||||
_gatedPrefixes = gatedPrefixes;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ServerCallContext context,
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
await AuthorizeAsync(context).ConfigureAwait(false);
|
||||
return await continuation(request, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
await AuthorizeAsync(context).ConfigureAwait(false);
|
||||
await continuation(requestStream, responseStream, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
ServerCallContext context,
|
||||
ClientStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
await AuthorizeAsync(context).ConfigureAwait(false);
|
||||
return await continuation(requestStream, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
await AuthorizeAsync(context).ConfigureAwait(false);
|
||||
await continuation(request, responseStream, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="RpcException"/> with <see cref="StatusCode.PermissionDenied"/> unless a
|
||||
/// gated call carries a valid <c>x-scadabridge-site</c> header AND a bearer token matching
|
||||
/// that site's resolved key. Non-gated calls return immediately.
|
||||
/// </summary>
|
||||
private async Task AuthorizeAsync(ServerCallContext context)
|
||||
{
|
||||
if (!IsGated(context.Method))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var siteId = ExtractSiteId(context.RequestHeaders);
|
||||
if (string.IsNullOrWhiteSpace(siteId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a central control-plane call to {Method}: the required "
|
||||
+ "'{Header}' metadata header is missing or blank, so there is no per-site key to "
|
||||
+ "verify against.",
|
||||
context.Method, ControlPlaneCredentials.SiteHeader);
|
||||
throw Denied("missing site identity header");
|
||||
}
|
||||
|
||||
string expected;
|
||||
try
|
||||
{
|
||||
expected = await _pskProvider.GetAsync(siteId, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail-closed: an unresolvable key is a denial, never a pass-through. The provider
|
||||
// has already logged the specific cause (missing secret / missing config entry).
|
||||
_logger.LogWarning(ex,
|
||||
"Rejected a central control-plane call to {Method}: no preshared key could be "
|
||||
+ "resolved for site {SiteId}.",
|
||||
context.Method, siteId);
|
||||
throw Denied("no key configured for the presented site");
|
||||
}
|
||||
|
||||
var presented = ExtractBearerToken(context.RequestHeaders);
|
||||
if (presented is null || !FixedTimeEquals(presented, expected))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a central control-plane call to {Method} from site {SiteId}: {Reason}.",
|
||||
context.Method, siteId,
|
||||
presented is null ? "no bearer token presented" : "bearer token did not match");
|
||||
throw Denied("control plane authentication failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static RpcException Denied(string reason)
|
||||
=> new(new Status(StatusCode.PermissionDenied, $"Control plane authentication failed: {reason}."));
|
||||
|
||||
private bool IsGated(string method)
|
||||
{
|
||||
foreach (var prefix in _gatedPrefixes)
|
||||
{
|
||||
if (method.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ExtractSiteId(Metadata headers)
|
||||
{
|
||||
foreach (var entry in headers)
|
||||
{
|
||||
if (string.Equals(entry.Key, ControlPlaneCredentials.SiteHeader,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return entry.Value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractBearerToken(Metadata headers)
|
||||
{
|
||||
// gRPC lowercases header keys on the wire; compare case-insensitively 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, ControlPlaneCredentials.AuthorizationHeader,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = entry.Value;
|
||||
if (value is not null && value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value["Bearer ".Length..];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string presented, string expected)
|
||||
=> CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
Reference in New Issue
Block a user