From 6dab72a47963d4eda72db56fc698e850712c0cdc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 09:42:49 -0400 Subject: [PATCH] refactor(host): extract MapOtOpcUaMeshGrpcServices (one authority for the five mesh maps) Behaviour-preserving: Program.cs:743-784 collapses to a single call, same gates, same order, every existing per-map rationale comment carried over. A new endpoint-metadata census (MeshGrpcAnonymousMetadataTests) drives the SAME production mapper across all three role shapes (admin-only / driver-only / fused), so the guard can never assert a shape production no longer has - archreview 03/S14, T1. --- .../Configuration/MeshGrpcEndpoints.cs | 97 +++++++++++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 48 +----- .../MeshGrpcAnonymousMetadataTests.cs | 150 ++++++++++++++++++ 3 files changed, 253 insertions(+), 42 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs new file mode 100644 index 00000000..c99c1194 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs @@ -0,0 +1,97 @@ +using ZB.MOM.WW.LocalDb.Replication; +using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc; +using ZB.MOM.WW.OtOpcUa.Host.Grpc; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// Maps the mesh gRPC ingress endpoints (artifact serve, central ack, telemetry stream, node +/// command) and the LocalDb sync endpoint. +/// +/// +/// +/// Do not "tidy" this into Program.cs. A standing census guard +/// (MeshGrpcAnonymousMetadataTests) drives THIS method, so a map that moves back +/// inline leaves the guard asserting a shape production no longer has — which is exactly +/// how the AdminUI cookie FallbackPolicy silently blocked every mesh gRPC call on a +/// login-enabled admin node for seven phases of live gates (archreview 03/S14). +/// +/// +public static class MeshGrpcEndpoints +{ + /// + /// Maps the mesh gRPC ingress endpoints and the LocalDb sync endpoint, gated on the same + /// role + dedicated-port checks Program.cs used inline before this extraction. + /// + /// The built application to map endpoints on. + /// Whether this node carries the admin role. + /// Whether this node carries the driver role. + /// + /// The dedicated h2c port bound for the LocalDb passive sync endpoint, or 0 when it is + /// not bound. + /// + /// + /// The dedicated h2c port bound for the ConfigServe artifact + central command endpoints, or + /// 0 when it is not bound. + /// + /// + /// The dedicated h2c port bound for the telemetry stream + node command endpoints, or + /// 0 when it is not bound. + /// + public static void MapOtOpcUaMeshGrpcServices( + this WebApplication app, + bool hasAdmin, + bool hasDriver, + int syncListenPort, + int configServeGrpcPort, + int telemetryListenPort) + { + ArgumentNullException.ThrowIfNull(app); + + // Passive LocalDb sync endpoint. Gated on the same port check that bound the listener, so a + // default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be + // safe even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey + // configured — but not mapping it at all is the stronger default. + if (hasDriver && syncListenPort > 0) + { + app.MapZbLocalDbSync(); + } + + // Central config-serve artifact endpoint (per-cluster mesh Phase 3). Mapped only on + // admin-role nodes (which hold the central SQL connection) and only when a dedicated h2c + // port is bound. The ConfigServeAuthInterceptor is the ONLY inbound auth and fail-closes + // with no ConfigServe:ApiKey. + if (hasAdmin && configServeGrpcPort > 0) + { + app.MapGrpcService(); + + // Central command ingress (mesh gRPC command plane): the node→central deployment ack. It + // rides the SAME listener rather than claiming a port of its own — a second cleartext h2c + // port would buy nothing but another firewall rule, and both endpoints are admin-role and + // dialled by the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping + // it under Dps costs nothing (nothing dials it) whereas gating it would mean flipping the + // mode on a running fleet requires a central restart before any node can ack — the same + // reasoning that registers CentralCommunicationActor in both modes. + // CentralCommandAuthInterceptor (added at AddGrpc in Program.cs) is the ONLY inbound auth + // and fail-closes per cluster. + app.MapGrpcService(); + } + + // Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by + // TelemetryStreamAuthInterceptor (fail-closed on empty Telemetry:ApiKey). + // + // The node-side command endpoint (mesh gRPC command plane, Phase 1) rides the SAME + // driver-role h2c listener rather than claiming a fourth port: central already dials this + // node here, both directions are per-cluster-keyed, and a separate port would be one more + // thing to open on every site firewall for no isolation gain. Mapping it is what makes + // MeshCommandAuthInterceptor live — it is registered in Program.cs and is a pass-through + // until a call matches this service's method prefix. It fail-closes with no + // MeshTransport:GrpcPsk, so an operator who maps the service without setting a key gets a + // closed endpoint, not an open one. + if (hasDriver && telemetryListenPort > 0) + { + app.MapGrpcService(); + app.MapGrpcService(); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 12eeee6b..40ae23ab 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -740,48 +740,12 @@ if (hasAdmin) app.MapOtOpcUaDeployApi(app.Configuration); } -// Passive LocalDb sync endpoint. Gated on the same port check that bound the listener, so a -// default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be safe -// even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey configured — but -// not mapping it at all is the stronger default. -if (hasDriver && syncListenPort > 0) -{ - app.MapZbLocalDbSync(); -} - -// Central config-serve artifact endpoint (per-cluster mesh Phase 3). Mapped only on admin-role -// nodes (which hold the central SQL connection) and only when a dedicated h2c port is bound. The -// ConfigServeAuthInterceptor is the ONLY inbound auth and fail-closes with no ConfigServe:ApiKey. -if (hasAdmin && configServeGrpcPort > 0) -{ - app.MapGrpcService(); - - // Central command ingress (mesh gRPC command plane): the node→central deployment ack. It - // rides the SAME listener rather than claiming a port of its own — a second cleartext h2c port - // would buy nothing but another firewall rule, and both endpoints are admin-role and dialled by - // the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping it under Dps - // costs nothing (nothing dials it) whereas gating it would mean flipping the mode on a running - // fleet requires a central restart before any node can ack — the same reasoning that registers - // CentralCommunicationActor in both modes. CentralCommandAuthInterceptor (added at AddGrpc - // above) is the ONLY inbound auth and fail-closes per cluster. - app.MapGrpcService(); -} - -// Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by -// TelemetryStreamAuthInterceptor (fail-closed on empty Telemetry:ApiKey). -// -// The node-side command endpoint (mesh gRPC command plane, Phase 1) rides the SAME driver-role -// h2c listener rather than claiming a fourth port: central already dials this node here, both -// directions are per-cluster-keyed, and a separate port would be one more thing to open on every -// site firewall for no isolation gain. Mapping it is what makes MeshCommandAuthInterceptor live — -// it is registered above and is a pass-through until a call matches this service's method prefix. -// It fail-closes with no MeshTransport:GrpcPsk, so an operator who maps the service without setting -// a key gets a closed endpoint, not an open one. -if (hasDriver && telemetryListenPort > 0) -{ - app.MapGrpcService(); - app.MapGrpcService(); -} +// Mesh gRPC ingress: the LocalDb passive sync endpoint, the ConfigServe artifact + central +// command endpoints, and the telemetry-stream + node-command endpoints. Extracted to +// MeshGrpcEndpoints so a standing census test drives the SAME production mapping (see that +// file's remarks — archreview 03/S14). +app.MapOtOpcUaMeshGrpcServices( + hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort); app.MapOtOpcUaHealth(); app.MapOtOpcUaMetrics(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs new file mode 100644 index 00000000..1c734779 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs @@ -0,0 +1,150 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// R3-01 (T1-T3) — an endpoint-metadata census over MapOtOpcUaMeshGrpcServices, driven in +/// all three role shapes a node can actually carry (admin-only, driver-only, fused). +/// +/// +/// +/// Why this drives the PRODUCTION mapper, not a hand-copied shape. The Critical this +/// plan fixes (archreview 03/S14) survived seven phases of live gates precisely because every +/// prior test built its own pipeline by hand — MeshGrpcBoundaryTests, +/// ConfigServeListenerTests and LocalDbSyncListenerTests all reproduce the +/// author's mental model of the mapping rather than the host's actual composition. This +/// census calls — the SAME method +/// Program.cs calls — so it is structurally impossible for the guard to assert a shape +/// production no longer has. +/// +/// +/// Filters out grpc-dotnet's own catch-all infrastructure. Every MapGrpcService +/// call also registers a per-service "Unimplemented method for X" fallback, and the FIRST +/// such call in an app additionally registers one shared "Unimplemented service" catch-all — +/// neither carries a route pattern beginning with /, and neither ever reaches real +/// logic (they always answer Unimplemented regardless of caller identity), so their +/// authorization metadata carries no security meaning. +/// keeps only the five real, callable RPC methods. +/// +/// +/// Guard-of-the-guard. Every assertion below pins an EXACT expected count and the +/// exact set of route patterns, never just "not empty" — a census that silently found zero +/// endpoints (a typo'd role gate, a helper that never got called) would pass a bare +/// non-empty check trivially. It cannot pass an exact-count-and-set check by accident. +/// +/// +public sealed class MeshGrpcAnonymousMetadataTests +{ + private const string ArtifactFetch = "/deployment_artifact.v1.DeploymentArtifactService/Fetch"; + private const string CentralReportApply = "/mesh_transport.v1.CentralCommandService/ReportApply"; + private const string TelemetrySubscribe = "/telemetry.v1.TelemetryStreamService/Subscribe"; + private const string NodeExecute = "/mesh_transport.v1.NodeCommandService/Execute"; + private const string LocalDbSyncSync = "/localdb_sync.v1.LocalDbSync/Sync"; + + /// The five known mesh gRPC method prefixes — the ONLY endpoints this map may anonymise. + private static readonly string[] AllFivePrefixes = + [LocalDbSyncSync, ArtifactFetch, CentralReportApply, TelemetrySubscribe, NodeExecute]; + + [Fact] + public void AdminOnly_MapsExactlyTheConfigServeEndpoints() + { + // Admin-only: no driver role, so neither LocalDbSync nor the telemetry/node-command pair are + // mapped even though a port is configured for them below (0 here, but the shape is the point). + var endpoints = RealMethodEndpoints(BuildApp( + hasAdmin: true, hasDriver: false, + syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0)); + + endpoints.Count.ShouldBe(2); + endpoints.Select(RoutePatternOf).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true); + } + + [Fact] + public void DriverOnly_WithNoSyncPort_MapsExactlyTheTelemetryAndNodeCommandEndpoints() + { + var endpoints = RealMethodEndpoints(BuildApp( + hasAdmin: false, hasDriver: true, + syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056)); + + endpoints.Count.ShouldBe(2); + endpoints.Select(RoutePatternOf).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true); + } + + [Fact] + public void DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint() + { + var endpoints = RealMethodEndpoints(BuildApp( + hasAdmin: false, hasDriver: true, + syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 4056)); + + endpoints.Count.ShouldBe(3); + endpoints.Select(RoutePatternOf).ShouldBe( + [LocalDbSyncSync, TelemetrySubscribe, NodeExecute], ignoreOrder: true); + } + + [Fact] + public void Fused_WithAllThreePorts_MapsAllFiveEndpoints() + { + // The docker-dev central pair's shape (admin,driver,cluster-MAIN) once all three dedicated + // h2c ports are configured — the shape 03/S14 called out as breaking the deploy plane for + // the whole fleet, not only the four sites. + var endpoints = RealMethodEndpoints(BuildApp( + hasAdmin: true, hasDriver: true, + syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056)); + + endpoints.Count.ShouldBe(5); + endpoints.Select(RoutePatternOf).ShouldBe(AllFivePrefixes, ignoreOrder: true); + } + + [Fact] + public void NeitherRoleNorAnyPort_MapsNothing() + { + // The default-OFF shape: every dedicated port is 0. Nothing about the mesh gRPC surface + // should exist on a node that configured none of it. + var endpoints = RealMethodEndpoints(BuildApp( + hasAdmin: false, hasDriver: false, + syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 0)); + + endpoints.ShouldBeEmpty(); + } + + // --------------------------------------------------------------------------------------- + // harness + // --------------------------------------------------------------------------------------- + + private static WebApplication BuildApp( + bool hasAdmin, + bool hasDriver, + int syncListenPort, + int configServeGrpcPort, + int telemetryListenPort) + { + var builder = WebApplication.CreateBuilder(); + builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!; + builder.Services.AddGrpc(); + + var app = builder.Build(); + app.MapOtOpcUaMeshGrpcServices( + hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort); + return app; + } + + /// + /// The real, callable RPC method endpoints — excludes grpc-dotnet's per-app "Unimplemented + /// service" catch-all and per-service "Unimplemented method for X" fallback (see class + /// remarks). Both carry a route pattern that never starts with /, which is what this + /// filters on. + /// + private static List RealMethodEndpoints(WebApplication app) => + ((IEndpointRouteBuilder)app).DataSources + .SelectMany(d => d.Endpoints) + .OfType() + .Where(e => e.RoutePattern.RawText?.StartsWith('/') == true) + .ToList(); + + private static string RoutePatternOf(RouteEndpoint e) => e.RoutePattern.RawText!; +}