4d2dfb5b50
MeshGrpcBoundaryTests replaces the MeshClusterClientBoundaryTests deleted in step 1. Three
self-seeded single-member clusters sharing the ActorSystem name, joined ONLY by gRPC, driving
the REAL CentralCommunicationActor -> MeshGrpcCommandDialer -> Kestrel h2c ->
MeshCommandAuthInterceptor -> NodeCommandGrpcService -> NodeCommunicationActor -> EventStream:
1. No_cross_mesh_Akka_association_forms_between_central_and_either_site — the program's own
point, kept as a test rather than a live-gate note: each cluster stays single-member and
none ever holds another's address as a member.
2. One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh — the fan, with each
site holding its OWN per-cluster PSK, so a dial that selected the wrong cluster's key
would be refused at the far side.
3. Falsifiability_control_a_dial_carrying_the_wrong_cluster_key_delivers_nothing — without
it, (2) cannot distinguish "the gRPC boundary carried it" from "something else in the
process did". The same node then accepts the right key, so the silence is the auth gate
and not a dead endpoint.
All three run fully offline — no Docker fixture — and pass.
Comment sweep across every remaining clusterclient|receptionist hit in src/ and tests/:
the program label becomes "mesh gRPC command plane", and every comment that described the
deleted transport as live now describes what actually runs (Dps = pair-local default,
Grpc = cross-mesh). mesh_transport.proto's header/service comments are reworded; no message,
field or enum is touched — the contract is locked. MeshCommActorPathTests is reshaped rather
than retired: the two paths still exist and still matter, but what a mismatch costs is now a
misleading Unavailable from the gRPC ingress rather than a silently dropped receptionist send.
The only surviving mentions of the old names are the ones that must survive: the fail-closed
rules that name "ClusterClient" as a retired value refused at boot, their tests, and this
test class's own account of what it replaces.
161 lines
7.3 KiB
C#
161 lines
7.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Gates the node-side command endpoint (mesh gRPC command plane). The
|
|
/// <c>NodeCommandService.Execute</c> RPC is how central drives this node — restart a driver,
|
|
/// acknowledge an alarm, dispatch a deployment — so anything able to reach a node's command port
|
|
/// could otherwise operate the node. This interceptor is the ONLY inbound auth on that endpoint.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Scoped by method path.</b> Only calls under
|
|
/// <c>/mesh_transport.v1.NodeCommandService/</c> are gated; every other method on the shared
|
|
/// gRPC pipeline (LocalDb sync, config-serve, telemetry) passes through untouched, so this can
|
|
/// share one <c>AddGrpc</c> registration with <see cref="LocalDbSyncAuthInterceptor"/>,
|
|
/// <see cref="ConfigServeAuthInterceptor"/> and <see cref="TelemetryStreamAuthInterceptor"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Fail-closed.</b> With no <c>MeshTransport:GrpcPsk</c> configured, NO command is
|
|
/// accepted, authenticated or not. Treating "no key" as "no auth required" would expose the
|
|
/// command plane on exactly the default shape every node ships with — and unlike the three
|
|
/// sibling endpoints, this one is a write surface, not a read one. Central must present the
|
|
/// same per-cluster key, so a key typo stops commands outright rather than degrading to
|
|
/// unauthenticated.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>No <c>x-otopcua-cluster</c> requirement here, deliberately.</b> That header is a
|
|
/// central-side routing hint — the caller's own claim about which cluster it is addressing.
|
|
/// Requiring a node to check it would gate on a value the caller chooses, which proves
|
|
/// nothing the key has not already proven; the key IS the cluster boundary, so a caller
|
|
/// holding another cluster's PSK is rejected by the comparison below.
|
|
/// </para>
|
|
/// <para>
|
|
/// Comparison is <see cref="CryptographicOperations.FixedTimeEquals"/> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Header reads go through <see cref="GrpcRequestHeaders"/>, shared with
|
|
/// <see cref="CentralCommandAuthInterceptor"/> — the two are clones, and header parsing is
|
|
/// the one place a clone pair can diverge into two different answers about which of several
|
|
/// <c>authorization</c> headers counts.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Exactly one public constructor.</b> <c>Grpc.AspNetCore</c> silently stops invoking an
|
|
/// interceptor that has more than one public ctor — see
|
|
/// <c>MeshCommandAuthInterceptorTests.HasExactlyOnePublicConstructor</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class MeshCommandAuthInterceptor : Interceptor
|
|
{
|
|
private const string ServicePrefix = "/mesh_transport.v1.NodeCommandService/";
|
|
|
|
private readonly IOptions<MeshTransportOptions> _options;
|
|
private readonly ILogger<MeshCommandAuthInterceptor> _logger;
|
|
|
|
/// <summary>Creates the interceptor.</summary>
|
|
/// <param name="options">Mesh transport options; <c>GrpcPsk</c> is the expected bearer token.</param>
|
|
/// <param name="logger">Logger for denial diagnostics.</param>
|
|
public MeshCommandAuthInterceptor(
|
|
IOptions<MeshTransportOptions> options,
|
|
ILogger<MeshCommandAuthInterceptor> logger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
|
|
_options = options;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
|
TRequest request,
|
|
ServerCallContext context,
|
|
UnaryServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
Authorize(context);
|
|
return continuation(request, context);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
|
|
IAsyncStreamReader<TRequest> requestStream,
|
|
IServerStreamWriter<TResponse> responseStream,
|
|
ServerCallContext context,
|
|
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
Authorize(context);
|
|
return continuation(requestStream, responseStream, context);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
|
|
IAsyncStreamReader<TRequest> requestStream,
|
|
ServerCallContext context,
|
|
ClientStreamingServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
Authorize(context);
|
|
return continuation(requestStream, context);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Task ServerStreamingServerHandler<TRequest, TResponse>(
|
|
TRequest request,
|
|
IServerStreamWriter<TResponse> responseStream,
|
|
ServerCallContext context,
|
|
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
Authorize(context);
|
|
return continuation(request, responseStream, context);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throws <see cref="RpcException"/> with <see cref="StatusCode.PermissionDenied"/> if this is
|
|
/// a node-command call that does not carry the configured bearer token. Non-command calls
|
|
/// return immediately.
|
|
/// </summary>
|
|
private void Authorize(ServerCallContext context)
|
|
{
|
|
if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal))
|
|
return;
|
|
|
|
var expected = _options.Value.GrpcPsk;
|
|
if (string.IsNullOrWhiteSpace(expected))
|
|
{
|
|
_logger.LogWarning(
|
|
"Rejected a mesh command call to {Method}: no MeshTransport:GrpcPsk is configured, so " +
|
|
"the node command endpoint is closed. Set this node's per-cluster pre-shared key " +
|
|
"(MeshTransport__GrpcPsk), ideally as a ${{secret:OT-MESH-PSK-<clusterId>}} reference, " +
|
|
"and give central the same key for this cluster.",
|
|
context.Method);
|
|
throw new RpcException(new Status(
|
|
StatusCode.PermissionDenied,
|
|
"Node command service is not accepting calls: no pre-shared key is configured on this node."));
|
|
}
|
|
|
|
var presented = GrpcRequestHeaders.BearerToken(context.RequestHeaders);
|
|
if (presented is null || !FixedTimeEquals(presented, expected))
|
|
{
|
|
_logger.LogWarning(
|
|
"Rejected a mesh command 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,
|
|
"Node command authentication failed."));
|
|
}
|
|
}
|
|
|
|
private static bool FixedTimeEquals(string presented, string expected)
|
|
=> CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
|
|
}
|