merge: ClusterClient→gRPC Phase 0 — mesh_transport.v1 contract, DTO mapper + wire goldens, both PSK auth interceptors + per-cluster provider, Grpc mode + validators, rig dark config (gate PASS 4/4; #542/#543 filed)

This commit is contained in:
Joseph Doherty
2026-08-06 05:04:16 -04:00
27 changed files with 3439 additions and 37 deletions
+45
View File
@@ -348,6 +348,20 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
# gRPC command transport (Phase 0 of the ClusterClient→gRPC migration, docs/plans/2026-08-06-
# clusterclient-to-grpc-design.md). SHIPPED DARK: Mode above stays ClusterClient, so none of
# this is dialled or served yet — only MeshTransportOptionsValidator's Grpc-mode rules read
# it. GrpcPsk is this node's OWN per-cluster key (MAIN, since central is cluster-MAIN);
# CentralGrpcEndpoints is the gRPC counterpart of CentralContactPoints above. ClusterPsks is
# CENTRAL-SIDE ONLY (IMeshPskProvider's override map, consulted before the Secrets store) —
# one entry per cluster central will dial once the switch flips, so only the central nodes
# carry it.
MeshTransport__GrpcPsk: "dev-mesh-psk-MAIN"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
MeshTransport__ClusterPsks__MAIN: "dev-mesh-psk-MAIN"
MeshTransport__ClusterPsks__SITE-A: "dev-mesh-psk-SITE-A"
MeshTransport__ClusterPsks__SITE-B: "dev-mesh-psk-SITE-B"
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
@@ -473,6 +487,14 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
# gRPC command transport (Phase 0) — see central-1. SHIPPED DARK (Mode stays ClusterClient).
# ClusterPsks is central-side only; both central nodes carry the same three-cluster map.
MeshTransport__GrpcPsk: "dev-mesh-psk-MAIN"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
MeshTransport__ClusterPsks__MAIN: "dev-mesh-psk-MAIN"
MeshTransport__ClusterPsks__SITE-A: "dev-mesh-psk-SITE-A"
MeshTransport__ClusterPsks__SITE-B: "dev-mesh-psk-SITE-B"
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
@@ -594,6 +616,13 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
# gRPC command transport (Phase 0) — see central-1. SHIPPED DARK (Mode stays ClusterClient).
# GrpcPsk is this node's own key for its cluster (SITE-A); CentralGrpcEndpoints is the gRPC
# counterpart of CentralContactPoints above. No ClusterPsks here — that map is central-side
# only.
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-A"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
<<: [*secrets-env, *ldap-env, *apikeys-env]
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
# mem_reservation are inherited from the *otopcua-host anchor.
@@ -701,6 +730,11 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
# gRPC command transport (Phase 0) — see central-1/site-a-1. SHIPPED DARK. Same SITE-A key as
# site-a-1 (must be byte-identical — both nodes authenticate against the same cluster's PSK).
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-A"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
@@ -789,6 +823,12 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
# gRPC command transport (Phase 0) — see central-1. SHIPPED DARK. GrpcPsk is this node's own
# key for its cluster (SITE-B); CentralGrpcEndpoints mirrors CentralContactPoints above. No
# ClusterPsks here — that map is central-side only.
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-B"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
@@ -861,6 +901,11 @@ services:
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}"
MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053"
MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053"
# gRPC command transport (Phase 0) — see central-1/site-b-1. SHIPPED DARK. Same SITE-B key as
# site-b-1 (must be byte-identical).
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-B"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
+16 -2
View File
@@ -165,8 +165,8 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
| Key | Type | Default | Meaning |
|---|---|---|---|
| `Mode` | string | `Dps` | `Dps` = DistributedPubSub (the transport this repo has always used). `ClusterClient` = the mesh boundary. Any other value **refuses to start**. |
| `CentralContactPoints` | string[] | `[]` | Node-side only: central node **addresses** (`akka.tcp://<system>@<host>:<port>`). Required under `ClusterClient`, ignored under `Dps`. |
| `Mode` | string | `Dps` | `Dps` = DistributedPubSub (the transport this repo has always used). `ClusterClient` = the mesh boundary. `Grpc` = the `mesh_transport.v1` gRPC contract — **shipped dark, cutover pending** (see below). Any other value **refuses to start**. |
| `CentralContactPoints` | string[] | `[]` | Node-side only: central node **addresses** (`akka.tcp://<system>@<host>:<port>`). Required under `ClusterClient`, ignored under `Dps`/`Grpc`. |
| `ContactRefreshSeconds` | int | `60` | Central-side only: how often central rebuilds its ClusterClient contact set from the `ClusterNode` rows. |
**Addresses only — no actor-path suffix.** `/system/receptionist` is appended at construction time; a contact carrying `/user/` or `/system/` is rejected at startup, because a doubled suffix produces a path that resolves to nothing and drops every command in silence.
@@ -181,6 +181,20 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
On the docker-dev rig every node carries these keys already; flip the whole rig with `OTOPCUA_MESH_MODE=ClusterClient` at `docker compose up`.
**`Grpc` mode — shipped dark, cutover pending** ([ClusterClient→gRPC migration design](plans/2026-08-06-clusterclient-to-grpc-design.md)). A third `Mode` value replaces `ClusterClient` with unary gRPC calls on the `mesh_transport.v1` contract: central dials each node's `NodeCommandService`, a node posts its deployment ack to central's `CentralCommandService`. Unlike `ClusterClient`, the command plane is authenticated end to end (`GrpcPsk` below) rather than relying on the mesh boundary alone. As of Phase 0 only the contract, DTO mapper, options and the two auth interceptors exist — the compiled default and the docker-dev rig both stay on `ClusterClient`, and setting `Mode=Grpc` today validates the keys below but nothing yet dials or serves over the contract.
| Key | Type | Default | Meaning |
|---|---|---|---|
| `GrpcPsk` | string? | `null` (unset) | Node-side only, required under `Grpc` on a driver-role node: this node's pre-shared key for its own `Cluster`, presented as the bearer on outbound acks and required on inbound `NodeCommandService` calls. Production form is `${secret:OT-MESH-PSK-<clusterId>}`; supply a literal only on a rig, via `MeshTransport__GrpcPsk` — never commit one. |
| `CentralGrpcEndpoints` | string[] | `[]` | Node-side only, required under `Grpc` on a driver-role node: full gRPC base URLs (`scheme://host:port`, e.g. `http://central-1:4055` for h2c) of the central pair's `CentralCommandService`, tried in order, first success wins. The `Grpc` counterpart of `CentralContactPoints`, and replaces it — a node on `Grpc` never reads the Akka contact points. |
| `DispatchTimeoutSeconds` | int | `10` | Central-side per-node deadline for one `NodeCommandService.Execute` call. Must be positive under `Grpc`; validated on driver-role nodes (covers the central pair, which is always fused). |
| `AckTimeoutSeconds` | int | `10` | Node-side per-endpoint deadline for one `CentralCommandService.ReportApply` call. Must be positive under `Grpc`. Applied per endpoint walked in `CentralGrpcEndpoints`, not per ack, so a dead first endpoint costs this deadline before the second is tried. |
| `ClusterPsks` | `Dictionary<string,string>` | `{}` (case-insensitive keys) | **Central-side only** override map, keyed by `Cluster` id: `IMeshPskProvider` consults this map *before* the Secrets store when central builds a channel to (or verifies an ack from) a given cluster. Production leaves this empty and resolves `OT-MESH-PSK-{clusterId}` from Secrets instead; the map exists so the docker-dev rig and integration tests can supply literals via the environment (`MeshTransport__ClusterPsks__SITE-A`) without standing up a KEK. |
**`ClusterPsks` / `OT-MESH-PSK-<clusterId>` and `SecretName`'s allow-list.** `SecretName` (`ZB.MOM.WW.Secrets.Abstractions`) lowercases the secret name and restricts it to `[a-z0-9._/-]` — a `Cluster` id that carries any character outside that set cannot be expressed as `OT-MESH-PSK-<clusterId>` at all, so `MeshPskProvider.TryGetPsk` fails closed for that cluster on every call that reaches the store (`SecretName`'s constructor throws, `MeshPskProvider.ResolveFromStore` catches it, logs a warning, and returns no key) — `MeshTransport:ClusterPsks` is the only path that still works for such an id. The rig's three ids (`MAIN`, `SITE-A`, `SITE-B`) are all within the allow-list and resolve normally either way.
The `Grpc` mode's node-side comm actors and central-side services are not yet wired to `Mode` — Phase 0 lands only the contract, mapper, options and the two auth interceptors (`MeshCommandAuthInterceptor` node-side, `CentralCommandAuthInterceptor` central-side), both dark. See the [design doc](plans/2026-08-06-clusterclient-to-grpc-design.md) and [tracking doc](plans/2026-08-06-clusterclient-to-grpc-tracking.md) for the remaining phases.
### `ConfigSource` / `ConfigServe` (config fetch-and-cache)
- **Purpose:** [per-cluster mesh](plans/2026-07-21-per-cluster-mesh-design.md) **Phase 3**. `ConfigSource:Mode` selects where a **driver** node reads its deployed configuration: `Direct` (read `Deployment.ArtifactBlob` from central SQL, today's behaviour) or `FetchAndCache` (fetch the artifact bytes from central over a gRPC stream, verify SHA-256, cache in LocalDb, and read **only** the cache — no central-SQL config read). `ConfigServe` is the **central** (admin-role) serve side that a `FetchAndCache` node dials.
@@ -45,20 +45,113 @@ deadline is the only witness. Restart the pair.
---
## Phase 0 — contract + auth + config groundwork — **PENDING**
## Phase 0 — contract + auth + config groundwork — **PASS**
Branch `feat/grpc-phase0-contract-auth` @ `________`. Image rebuilt, all containers recreated.
Branch `feat/grpc-phase0-contract-auth` @ `c2bfad4a`. Image rebuilt, all six containers
recreated (`docker compose -f docker-dev/docker-compose.yml up -d --build`, run from the
worktree at `/Users/dohertj2/Desktop/OtOpcUa/.worktrees/grpc-phase0` so the image carries this
branch). The rig was already running (from the main checkout's compose file) before this run;
the rebuild recreated all eight containers (sql kept, migrator + cluster-seed one-shots re-ran,
central-1/-2 + site-a-1/-2 + site-b-1/-2 + traefik recreated) under the fixed `otopcua-dev`
project name, so this run is not a concurrent-rebuild-invalidated one — nothing else touched
the stack afterwards.
| # | Check | Result |
|---|---|---|
| 1 | All six nodes boot on `ClusterClient` with the dark `Grpc` config present (PSKs, endpoints, ClusterPsks map); no validator fires | PENDING |
| 2 | Deliberate misconfig: a scratch container with `MeshTransport__Mode=Grpc` and **no** `GrpcPsk` refuses to boot, error names `MeshTransport:GrpcPsk` + the env form | PENDING |
| 3 | Same with empty `CentralGrpcEndpoints` — refuses, names the key | PENDING |
| 4 | Full suite green, 0 warnings, on the branch | PENDING |
| 1 | All six nodes boot on `ClusterClient` with the dark `Grpc` config present (PSKs, endpoints, ClusterPsks map); no validator fires | **PASS** — 6/6 nodes `restarts=0`, `docker inspect -f '{{.State.Status}} restarts={{.RestartCount}}'` reports `running restarts=0` on every node; `docker logs <node> | grep -iE "OptionsValidationException\|MeshTransport.*(is empty\|refuses to start)"` = 0 lines on all six |
| 2 | Deliberate misconfig: a scratch container with `MeshTransport__Mode=Grpc` and **no** `GrpcPsk` refuses to boot, error names `MeshTransport:GrpcPsk` + the env form | **PASS** — `Hosting failed to start` + `OptionsValidationException` naming `MeshTransport:GrpcPsk` and `MeshTransport__GrpcPsk`, below |
| 3 | Same with empty `CentralGrpcEndpoints` — refuses, names the key | **PASS** — `OptionsValidationException` naming `MeshTransport:CentralGrpcEndpoints` and `MeshTransport__CentralGrpcEndpoints__0`, below |
| 4 | Full suite green, 0 warnings, on the branch | **PASS** — `dotnet test ZB.MOM.WW.OtOpcUa.slnx --nologo`: 0 failures on a clean run (see evidence below); no `.cs` files changed on this branch since the last full rebuild, so the pre-existing ~868-warning baseline is unchanged (nothing to newly warn about) |
Note: the new gRPC services are not yet mapped in Phase 0, so no wire probes here — the
interceptor negative/positive matrix is unit-tested and wire-probed in 1A/1B.
**Check 1 evidence** — no validator fired on any of the six nodes, all dark `Grpc` keys present:
```
$ for c in central-1 central-2 site-a-1 site-a-2 site-b-1 site-b-2; do
docker inspect -f '{{.State.Status}} restarts={{.RestartCount}}' otopcua-dev-$c-1
done
running restarts=0 # central-1
running restarts=0 # central-2
running restarts=0 # site-a-1
running restarts=0 # site-a-2
running restarts=0 # site-b-1
running restarts=0 # site-b-2
$ docker logs otopcua-dev-central-1-1 2>&1 | grep -iE "MeshTransport|ClusterClient"
[08:53:26 INF] Mesh ClusterClient for cluster MAIN created with 2 contact point(s)
[08:53:26 INF] Mesh ClusterClient for cluster SITE-A created with 2 contact point(s)
[08:53:26 INF] Mesh ClusterClient for cluster SITE-B created with 2 contact point(s)
$ docker logs otopcua-dev-site-a-1-1 2>&1 | grep -iE "cluster.client|receptionist"
[08:53:26 INF] Connected to [akka.tcp://otopcua@central-2:4053/system/receptionist]
$ docker inspect otopcua-dev-site-a-1-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep MeshTransport
MeshTransport__CentralGrpcEndpoints__1=http://central-2:4055
MeshTransport__CentralGrpcEndpoints__0=http://central-1:4055
MeshTransport__Mode=ClusterClient
MeshTransport__GrpcPsk=dev-mesh-psk-SITE-A
MeshTransport__CentralContactPoints__1=akka.tcp://otopcua@central-2:4053
MeshTransport__CentralContactPoints__0=akka.tcp://otopcua@central-1:4053
$ curl -sf http://localhost:9200/health/ready
{"status":"Healthy", ..., "akka":{"status":"Healthy","description":"Akka cluster member status: Up",
"data":{"selfAddress":"akka.tcp://otopcua@central-2:4053","selfRoles":["admin","cluster-MAIN","driver"],
"memberCount":2,"unreachableCount":0,"leader":"akka.tcp://otopcua@central-1:4053"}}}
```
**Check 2 evidence** — scratch container from `otopcua-host:dev`, site-a-1's real environment
with `MeshTransport__GrpcPsk` removed and `Mode` forced to `Grpc`:
```
$ docker run --rm --name scratch-check2-noPsk --network otopcua-dev_default \
--env-file check2-env.txt otopcua-host:dev
[08:54:23 ERR] Hosting failed to start
Microsoft.Extensions.Options.OptionsValidationException: MeshTransport:GrpcPsk is empty. Under
Grpc this per-cluster pre-shared key is the whole authentication boundary of the command plane
in both directions — without it this node's inbound command service fails closed and every ack
it posts to central is rejected. Supply it via the environment (MeshTransport__GrpcPsk), in
production as ${secret:OT-MESH-PSK-<clusterId>}.
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
...
```
**Check 3 evidence** — same base env, `GrpcPsk` present, both `CentralGrpcEndpoints` indices
unset:
```
$ docker run -d --name scratch-check3-noEndpoints --network otopcua-dev_default \
--env-file check3-env.txt otopcua-host:dev
$ docker logs scratch-check3-noEndpoints 2>&1 | grep -A2 "OptionsValidationException"
[08:55:55 ERR] Hosting failed to start
Microsoft.Extensions.Options.OptionsValidationException: MeshTransport:CentralGrpcEndpoints is
empty. Under Grpc a driver node has nowhere to post its deployment ack, and every deployment
would time out naming a node that is running perfectly. Supply them via the environment
(MeshTransport__CentralGrpcEndpoints__0).
```
Both scratch containers removed after capture (`docker rm -f scratch-check2-noPsk
scratch-check3-noEndpoints`); the rig's six real nodes were never affected (still `restarts=0`
after both runs).
**Check 4 evidence** — full suite, clean re-run (a first run hit one race-condition flake in
`RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed`,
which the test's own name identifies as exercising a race; re-ran in isolation and it passed,
then a full second run passed clean end to end — pre-existing timing sensitivity, not a
regression from this branch, which touched only `docker-dev/docker-compose.yml` and
`docs/Configuration.md`, no `.cs` files):
```
$ dotnet test ZB.MOM.WW.OtOpcUa.slnx --nologo
...
Passed! - Failed: 0, Passed: 212, Skipped: 4, Total: 216, Duration: 3m56s - ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.dll (net10.0)
$ echo $?
0
```
Rig left running on `ClusterClient` (its normal state) after the gate.
---
## Phase 1A — central→node command plane — **PENDING**
@@ -22,7 +22,7 @@ posture).
| Phase | Scope | Branch | Status | PR / merge SHA | Live gate |
|---|---|---|---|---|---|
| 0 | Proto + mappers + goldens; both auth interceptors; `IMeshPskProvider`; options/validators (+`Grpc` value, SplitTopology relaxation); rig dark config; stale-comment fixes; FleetStatus issue filed | `feat/grpc-phase0-contract-auth` | **NOT STARTED** | — | PENDING |
| 0 | Proto + mappers + goldens; both auth interceptors; `IMeshPskProvider`; options/validators (+`Grpc` value, SplitTopology relaxation); rig dark config; stale-comment fixes; FleetStatus issue filed | `feat/grpc-phase0-contract-auth` | **DONE — AWAITING PR/MERGE (user-owned)** — all 6 tasks complete @ `23555ed0`; every task spec ✅ + code ✅ (Batch B on Opus with a closed fix loop); issues #542 + #543 filed | — | **PASS 4/4** |
| 1A | Central→node command plane (`NodeCommandService` on the Telemetry listener; `MeshGrpcCommandDialer`; third mode branch in `CentralCommunicationActor`) — **worktree A** | `feat/grpc-node-command` | NOT STARTED | — | PENDING |
| 1B | Node→central ack plane (`CentralCommandService` on the ConfigServe listener; `GrpcApplyAckSender`; third branch in `NodeCommunicationActor`) — **worktree B**, rebases on 1A | `feat/grpc-central-ack` | NOT STARTED | — | PENDING |
| 2 | Baseline reproduction on pre-flip build, then full-rig cutover (`OTOPCUA_MESH_MODE` default → `Grpc`) + soak + kill drills | (rig commit) | NOT STARTED | — | PENDING |
@@ -45,6 +45,58 @@ phase DoD — PR + merge are user-owned.**
| 2026-08-06 | `Dps` mode survives for single-mesh topologies; only `ClusterClient` is deleted; the three stale "Phase 6 deletes DPS" comments get corrected, not obeyed | design §3.6/§4.2 |
| 2026-08-06 | No transport interface — third mode branch inside the two choke-point actors (ScadaBridge §7.1 economy) | design §4.3 |
| 2026-08-06 | Coexistence granularity: central's mode governs commands, each node's mode governs its acks (per-pair flips possible — better than ScadaBridge's central-wide flag) | design §3.6 |
| 2026-08-06 | `AlarmCommandMessage.operation` is a proto `string`, NOT an enum — the CLR `AlarmCommand.Operation` is an unconstrained string, and an enum would silently decode unknown operations to `UNSPECIFIED` (the drop class this migration removes). Plan T0.1's "AlarmCommand verb" enum wording was a plan defect; only `ApplyOutcome` carries the enum-0 rule. T0.2's goldens must pin pass-through of an unrecognized operation string | proto comment + this log (T0.1 review) |
| 2026-08-06 | `correlation_id` lives on the envelope AND as inner fields on the three commands whose CLR record carries one — each command round-trips independently in the T0.2 mapper; the envelope covers `AlarmCommand` (no CLR correlation member). For the three, the inner field is authoritative on decode | proto comment + this log (T0.1 review) |
## Phase 1A security register (from the Batch B Opus code review — HARD GATE: all four close BEFORE `CentralCommandService` is mapped)
1. **Known-cluster-set membership gate in `TryGetPsk`** — central rebuilds its cluster set from
`ClusterNode` rows every `ContactRefreshSeconds`; refuse a cluster id outside that set in
O(1) with no store touch. Closes BOTH open review findings at once: (a) the timing oracle
that currently defeats the uniform-denial anti-enumeration text (unknown cluster pays a
store read on every probe because negatives are deliberately never cached; known cluster is
cached — a stable remotely-measurable distinguisher), and (b) the pre-auth resource
exhaustion / audit-flood vector (each distinct forged `x-otopcua-cluster` header costs a
secret-store read + a `secret.resolve.not-found` audit row into `ConfigAuditLog` via the
audit singleton, attacker-chosen `Target` values, before any credential check). Namespace
confinement (`OT-MESH-PSK-` prefix + `SecretName` allow-list) means no disclosure risk —
this is flood/oracle only.
2. **`bool Matches(string clusterId, string presented)` on `IMeshPskProvider`** — keep
`FixedTimeEquals` inside the provider so a future verify-side caller cannot write `==` and
have it compile. Do before 1B adds the dial-side caller.
3. **DI-shape pin test** — `IMeshPskProvider` is `hasAdmin`-only and its pairing with the
central interceptor is by convention; a driver-only node injecting it throws on first call,
not at boot. Add a Host.IntegrationTests check (admin host resolves it; driver-only host
does NOT) when Phase 1 maps the services.
4. **Integration gate over real Kestrel h2c** — both new interceptors have unit coverage only;
the one-public-ctor reflection test approximates but cannot prove the interceptor is
actually invoked. Wire-probe in the 1A/1B live gates (already in the live-gate tables).
Non-gating notes from the same review: central-side unseeded-PSK is invisible until a deploy
stalls (consider a "PSK seeded for every known cluster?" `/hosts`/health surface — the
#521/#541 shape); `SecretName` lowercases and allows only `[a-z0-9._/-]` (underscore IS
allowed — the review's `Site_A` example was wrong, caught + corrected by the T0.6 agent
against the pinned 0.2.3 DLL), so a cluster id with any character outside that set fails
closed at the store and must use `ClusterPsks` — documented in `docs/Configuration.md`
(rig ids `MAIN`/`SITE-A`/`SITE-B` are fine); sibling interceptors
still use `IsNullOrEmpty` (whitespace key opens them) — family-wide backport is out of this
program's scope, file alongside the FleetStatus issue in T0.6; consider a non-throwing counter
metric if the poison-guard silent catch ever fires in production.
## Phase 1 dispatch notes (from Task 0.2 reviews — fold into the 1A/1B implementer prompts)
- The alarm arm of `FromEnvelope` necessarily discards the envelope correlation
(`AlarmCommand` has no CLR member for it) — the 1A router must read
`envelope.CorrelationId` itself for logging.
- `ApplyAck` is the ONE record of the five that never rides `CommandEnvelope`:
`GrpcApplyAckSender` (1B) calls `MeshTransportDtoMapper.ToProto(ApplyAck)` directly on
`ReportApply` — do not expect (or add) a sixth `ToEnvelope` overload.
- Consider a thin try/catch at the `Execute`/`ReportApply` service boundary wrapping
`FromEnvelope`/`FromProto` failures with the envelope's correlation_id (readable as a plain
string even when inner ids fail to parse) so an operator can correlate a rejected call.
- Revisit the untyped `ToEnvelope(object, CorrelationId alarmCorrelationId)` signature when
the 1A router lands — if the router prefers one always-honoured transport correlation,
change the signature then, before it has more callers.
## Defect log
@@ -66,7 +118,8 @@ Checks a gate could not drive, named + carried to the next gate (proportionate-g
| Issue | What | Status |
|---|---|---|
| *(Gitea # TBD in Phase 0, T0.6)* | `FleetStatusBroadcaster` derives "fleet" from central's own gossip ring — silently central-pair-blind since mesh Phase 6; pre-existing, transport-independent, out of this program's scope | to file |
| [lmxopcua#542](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/542) | `FleetStatusBroadcaster` derives "fleet" from central's own gossip ring — silently central-pair-blind since mesh Phase 6; pre-existing, transport-independent, out of this program's scope | FILED 2026-08-06 |
| [lmxopcua#543](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/543) | The three pre-existing auth interceptors (`TelemetryStream`/`ConfigServe`/`LocalDbSync`) treat a whitespace-only key as SET (`IsNullOrEmpty`), so a whitespace `Telemetry:ApiKey` etc. opens the endpoint to a whitespace bearer; the two new mesh interceptors use `IsNullOrWhiteSpace`. Backport is a behaviour change to shipped endpoints — own issue, out of this program's scope | FILED 2026-08-06 |
## Known constraints carried from the research (do not rediscover)
@@ -3,12 +3,12 @@
"executionModel": "opus-subagents; sequential within a worktree, 1A and 1B concurrent in separate worktrees (git worktree add ../OtOpcUa-1B feat/grpc-central-ack); 1A merges first, 1B rebases; stop at each phase DoD (PR/merge user-owned)",
"worktrees": {},
"tasks": [
{"id": 0, "subject": "Task 0.1: mesh_transport.proto + contract lock", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.3 (batch, same agent)"], "status": "pending"},
{"id": 1, "subject": "Task 0.2: DTO mapper + round-trip goldens", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": [], "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 0.3: MeshTransportOptions + validators + SplitTopology relaxation", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.1 (batch, same agent)"], "status": "pending"},
{"id": 3, "subject": "Task 0.4: node-side MeshCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.5 (batch, same agent)"], "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 0.5: IMeshPskProvider + CentralCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.4 (batch, same agent)"], "status": "pending", "blockedBy": [2]},
{"id": 5, "subject": "Task 0.6: rig config + FleetStatus issue + docs stub + Phase 0 gate", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "small", "parallelizableWith": [], "status": "pending", "blockedBy": [0, 1, 2, 3, 4]},
{"id": 0, "subject": "Task 0.1: mesh_transport.proto + contract lock", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.3 (batch, same agent)"], "status": "completed", "commit": "c0ccfa27", "review": "spec ✅ + code ✅"},
{"id": 1, "subject": "Task 0.2: DTO mapper + round-trip goldens", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": [], "status": "completed", "blockedBy": [0], "commit": "545413a8", "review": "spec ✅ + code ✅ (serial)"},
{"id": 2, "subject": "Task 0.3: MeshTransportOptions + validators + SplitTopology relaxation", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "standard", "parallelizableWith": ["0.1 (batch, same agent)"], "status": "completed", "commit": "aa21f61f", "review": "spec ✅ + code ✅"},
{"id": 3, "subject": "Task 0.4: node-side MeshCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.5 (batch, same agent)"], "status": "completed", "blockedBy": [2], "commit": "fbda015c + fixes 24e55d33/a4631410", "review": "spec ✅ + code ✅ (opus, fix loop closed)"},
{"id": 4, "subject": "Task 0.5: IMeshPskProvider + CentralCommandAuthInterceptor", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "high-risk", "parallelizableWith": ["0.4 (batch, same agent)"], "status": "completed", "blockedBy": [2], "commit": "68a416e4 + fixes 24e55d33/a4631410", "review": "spec ✅ + code ✅ (opus, fix loop closed); 4 items gated on Phase 1A — see tracking §Phase 1A security register"},
{"id": 5, "subject": "Task 0.6: rig config + FleetStatus issue + docs stub + Phase 0 gate", "phase": "0", "branch": "feat/grpc-phase0-contract-auth", "classification": "small", "parallelizableWith": [], "status": "completed", "blockedBy": [0, 1, 2, 3, 4], "commit": "23555ed0", "review": "code ✅", "gate": "PASS 4/4; issues #542 #543 filed"},
{"id": 6, "subject": "Task 1A.1: NodeCommandGrpcService on the telemetry listener", "phase": "1A", "branch": "feat/grpc-node-command", "classification": "standard", "parallelizableWith": ["1B.1 (other worktree)"], "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 1A.2: MeshGrpcCommandDialer + third branch in CentralCommunicationActor", "phase": "1A", "branch": "feat/grpc-node-command", "classification": "high-risk", "parallelizableWith": ["1B.2 (other worktree)"], "status": "pending", "blockedBy": [6]},
{"id": 8, "subject": "Task 1A.3: 1A live gate (central pair flipped, rig-only, reverted)", "phase": "1A", "branch": "feat/grpc-node-command", "classification": "small", "parallelizableWith": [], "status": "pending", "blockedBy": [7]},
@@ -6,11 +6,18 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Mode"/> is a deliberate <b>dark switch</b>, not a permanent knob: both
/// transports live in the tree for one phase so the docker-dev rig can run the same
/// deployment over each and compare, and so a bad Phase 2 rolls back with a config change
/// rather than a revert. Phase 6 deletes the DistributedPubSub branch along with the single
/// mesh that made it viable.
/// <see cref="Mode"/> is a deliberate <b>dark switch</b>: the transports live in the tree
/// side by side so the docker-dev rig can run the same deployment over each and compare, and
/// so a bad rollout rolls back with a config change rather than a revert.
/// </para>
/// <para>
/// <b>The DistributedPubSub branch is retained, not scheduled for deletion.</b> An earlier
/// comment here promised Phase 6 would delete it along with the single mesh; that was
/// deliberately reversed — see <c>docs/Configuration.md</c> § "The compiled transport-mode
/// defaults were deliberately NOT flipped". DPS remains the correct and default transport for
/// a single-mesh topology, which every integration test and every profile that leaves this
/// key unset still runs. What is being replaced is <see cref="ModeClusterClient"/>, by
/// <see cref="ModeGrpc"/>.
/// </para>
/// <para>
/// <b>Discovery is deliberately asymmetric</b>, matching the sister project. Central
@@ -32,9 +39,19 @@ public sealed class MeshTransportOptions
public const string ModeClusterClient = "ClusterClient";
/// <summary>
/// <see cref="ModeDps"/> (default) or <see cref="ModeClusterClient"/>. Any other value fails
/// the host at startup rather than silently falling back — a transport that is neither of
/// these delivers nothing, and the symptom would be commands that vanish.
/// Commands cross the mesh boundary as unary gRPC calls on the <c>mesh_transport.v1</c>
/// contract — central dials each node's <c>NodeCommandService</c>, a node posts its
/// deployment ack to central's <c>CentralCommandService</c>. The replacement for
/// <see cref="ModeClusterClient"/>; unlike it, the command plane is authenticated
/// (<see cref="GrpcPsk"/>).
/// </summary>
public const string ModeGrpc = "Grpc";
/// <summary>
/// <see cref="ModeDps"/> (default), <see cref="ModeClusterClient"/> or
/// <see cref="ModeGrpc"/>. Any other value fails the host at startup rather than silently
/// falling back — a transport that is none of these delivers nothing, and the symptom would
/// be commands that vanish.
/// </summary>
public string Mode { get; set; } = ModeDps;
@@ -58,4 +75,91 @@ public sealed class MeshTransportOptions
/// process — migration, direct SQL, a second admin node — is picked up without a restart.
/// </summary>
public int ContactRefreshSeconds { get; set; } = 60;
/// <summary>
/// <c>MeshTransport:CentralGrpcEndpoints</c> — node-side only, and only under
/// <see cref="ModeGrpc"/>: the gRPC base addresses of the central pair's
/// <c>CentralCommandService</c>, e.g. <c>http://central-1:4055</c>. Full URLs
/// (<c>scheme://host:port</c>), not bare <c>host:port</c> — <c>http://</c> is h2c.
/// </summary>
/// <remarks>
/// Tried <b>in order, stopping at the first success</b>, which is what preserves the
/// receptionist's one-ack-per-node-per-dispatch semantic: either central node routes the ack
/// to the same <c>ConfigPublishCoordinator</c> singleton proxy, so which one answers does not
/// matter, but two answering would double-count. The gRPC counterpart of
/// <see cref="CentralContactPoints"/>, and it replaces it — a node running
/// <see cref="ModeGrpc"/> never reads the Akka contact points.
/// </remarks>
public string[] CentralGrpcEndpoints { get; set; } = [];
/// <summary>
/// <c>MeshTransport:GrpcPsk</c> — this node's pre-shared key for the command plane, one per
/// application <c>Cluster</c>. Under <see cref="ModeGrpc"/> it is both the bearer this node
/// presents when posting an ack to central and the bearer it requires on inbound
/// <c>NodeCommandService</c> calls.
/// </summary>
/// <remarks>
/// <para>
/// Production form is a secret reference — <c>${secret:OT-MESH-PSK-&lt;clusterId&gt;}</c>,
/// e.g. <c>${secret:OT-MESH-PSK-SITE-A}</c> — resolved pre-host by the existing
/// <c>SecretReferenceExpander</c>, so it works on a driver-only node with no ConfigDb.
/// Supply a literal only on a rig, via the environment
/// (<c>MeshTransport__GrpcPsk</c>); never commit one.
/// </para>
/// <para>
/// <b>Per-Cluster, not fleet-wide, on purpose.</b> The three existing shared-bearer gRPC
/// channels are read/telemetry planes; this one carries <c>RestartDriver</c>,
/// <c>AlarmCommand</c> and deployment dispatch, so a single fleet key would let one
/// compromised site node command every other site and forge acks fleet-wide.
/// </para>
/// <para>
/// Nullable because <b>unset must be distinguishable from blank at the boundary that
/// fails closed</b>: the serve-side interceptor rejects every call when no key is
/// configured rather than defaulting to an empty comparison that any empty bearer would
/// satisfy.
/// </para>
/// </remarks>
public string? GrpcPsk { get; set; }
/// <summary>
/// <c>MeshTransport:DispatchTimeoutSeconds</c> — central-side deadline, in seconds, for one
/// <c>NodeCommandService.Execute</c> call to one node. Must be greater than 0 under
/// <see cref="ModeGrpc"/>.
/// </summary>
/// <remarks>
/// Bounded per node because a fan-out dispatch dials every enabled node in the cluster: an
/// unbounded call to one unreachable node would stall the commands queued behind it. Expiry
/// is logged and dropped — the transport is fire-and-forget and layers no retry, so the
/// deploy path's 2-minute apply deadline stays the only recovery mechanism.
/// Validation of this key is scoped to driver-role nodes; that covers the central pair only
/// because central nodes are always fused (<c>admin,driver,cluster-*</c>). A pure-admin
/// central node would leave this unvalidated on the node that uses it.
/// </remarks>
public int DispatchTimeoutSeconds { get; set; } = 10;
/// <summary>
/// <c>MeshTransport:AckTimeoutSeconds</c> — node-side deadline, in seconds, for one
/// <c>CentralCommandService.ReportApply</c> call to one central endpoint. Must be greater
/// than 0 under <see cref="ModeGrpc"/>.
/// </summary>
/// <remarks>
/// Applied per endpoint, not per ack: the node walks <see cref="CentralGrpcEndpoints"/> in
/// order, so a dead first endpoint costs this deadline before the second is tried.
/// </remarks>
public int AckTimeoutSeconds { get; set; } = 10;
/// <summary>
/// <c>MeshTransport:ClusterPsks:{clusterId}</c> — central-side override map of per-cluster
/// PSKs, consulted <i>before</i> the Secrets store when central builds a channel to (or
/// verifies an ack from) a given <c>Cluster</c>. Keyed case-insensitively.
/// </summary>
/// <remarks>
/// <b>Production leaves this empty</b> and the runtime provider resolves
/// <c>OT-MESH-PSK-{clusterId}</c> from the Secrets store instead; the map exists so the
/// docker-dev rig and integration tests can supply literals through the environment
/// (<c>MeshTransport__ClusterPsks__SITE-A</c>) without standing up a KEK. Central needs a map
/// rather than a single key because its cluster set is DB-dynamic — one key per site pair it
/// talks to — whereas a node holds exactly its own (<see cref="GrpcPsk"/>).
/// </remarks>
public Dictionary<string, string> ClusterPsks { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
@@ -15,6 +16,22 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// </remarks>
public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTransportOptions>
{
private readonly IConfiguration _configuration;
/// <summary>
/// DI-constructed by <c>AddValidatedOptions</c> (a plain <c>AddSingleton</c>), so a
/// constructor dependency is safe here. <see cref="IConfiguration"/> — not
/// <c>IClusterRoleInfo</c> — is the source of this node's roles: <c>IClusterRoleInfo</c>'s
/// implementation needs the <c>ActorSystem</c>, which does not exist yet at
/// <c>ValidateOnStart</c> time. Same shape as <see cref="ConfigSourceOptionsValidator"/> and
/// <see cref="TelemetryOptionsValidator"/>.
/// </summary>
/// <param name="configuration">The application configuration, read for <c>Cluster:Roles</c>.</param>
public MeshTransportOptionsValidator(IConfiguration configuration)
{
_configuration = configuration;
}
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, MeshTransportOptions options)
{
@@ -25,13 +42,14 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
var isDps = string.Equals(options.Mode, MeshTransportOptions.ModeDps, StringComparison.OrdinalIgnoreCase);
var isClusterClient = string.Equals(
options.Mode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase);
var isGrpc = string.Equals(options.Mode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase);
if (!isDps && !isClusterClient)
if (!isDps && !isClusterClient && !isGrpc)
{
errors.Add(
$"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)} is "
+ $"'{options.Mode}'. Expected '{MeshTransportOptions.ModeDps}' or "
+ $"'{MeshTransportOptions.ModeClusterClient}'.");
+ $"'{options.Mode}'. Expected '{MeshTransportOptions.ModeDps}', "
+ $"'{MeshTransportOptions.ModeClusterClient}' or '{MeshTransportOptions.ModeGrpc}'.");
}
if (options.ContactRefreshSeconds <= 0)
@@ -76,6 +94,78 @@ public sealed class MeshTransportOptionsValidator : IValidateOptions<MeshTranspo
}
}
// The Grpc surface below is node-side: the PSK a driver presents (and requires), the central
// endpoints it posts its ack to, and the two call deadlines. Scoped to the driver role for the
// same reason the ClusterClient block above is scoped to its mode — an admin-only central node
// dials nodes rather than being dialled and resolves per-cluster keys at runtime through
// MeshTransportOptions.ClusterPsks / the Secrets store, so requiring a single GrpcPsk and an
// endpoint list of it would demand configuration it never reads. Roles come from
// configuration, not IClusterRoleInfo (see the ctor remark), and through
// RoleParser.ResolveEffectiveRoles so the OTOPCUA_ROLES fallback applies (Gitea #532) — a node
// configured only through the env var is still the driver that has to dial central, and
// exempting it here would leave its acks rejected unauthenticated at runtime.
var roles = RoleParser.ResolveEffectiveRoles(
_configuration.GetSection("Cluster:Roles").Get<string[]>());
var isDriver = Array.IndexOf(roles, RoleParser.Driver) >= 0;
if (isGrpc && isDriver)
{
if (string.IsNullOrWhiteSpace(options.GrpcPsk))
{
errors.Add(
$"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.GrpcPsk)} is "
+ $"empty. Under {MeshTransportOptions.ModeGrpc} this per-cluster pre-shared key is "
+ "the whole authentication boundary of the command plane in both directions — "
+ "without it this node's inbound command service fails closed and every ack it "
+ "posts to central is rejected. Supply it via the environment "
+ $"({MeshTransportOptions.SectionName}__{nameof(MeshTransportOptions.GrpcPsk)}), in "
+ "production as ${secret:OT-MESH-PSK-<clusterId>}.");
}
if (options.CentralGrpcEndpoints.Length == 0)
{
errors.Add(
$"{MeshTransportOptions.SectionName}:"
+ $"{nameof(MeshTransportOptions.CentralGrpcEndpoints)} is empty. Under "
+ $"{MeshTransportOptions.ModeGrpc} a driver node has nowhere to post its "
+ "deployment ack, and every deployment would time out naming a node that is "
+ "running perfectly. Supply them via the environment "
+ $"({MeshTransportOptions.SectionName}__"
+ $"{nameof(MeshTransportOptions.CentralGrpcEndpoints)}__0).");
}
foreach (var endpoint in options.CentralGrpcEndpoints)
{
if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
&& !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
errors.Add(
$"Central gRPC endpoint '{endpoint}' must start with 'http://' (h2c) or "
+ "'https://' — it is a gRPC base address (scheme://host:port), not a bare "
+ "host:port and not an Akka remoting address.");
}
}
// Both deadlines are per-call and there is no retry behind them, so a non-positive value
// is not "wait forever" — it is a call that expires before it is made, i.e. a command
// plane that silently delivers nothing.
if (options.DispatchTimeoutSeconds <= 0)
{
errors.Add(
$"{MeshTransportOptions.SectionName}:"
+ $"{nameof(MeshTransportOptions.DispatchTimeoutSeconds)} must be greater than 0 "
+ $"(was {options.DispatchTimeoutSeconds}).");
}
if (options.AckTimeoutSeconds <= 0)
{
errors.Add(
$"{MeshTransportOptions.SectionName}:"
+ $"{nameof(MeshTransportOptions.AckTimeoutSeconds)} must be greater than 0 "
+ $"(was {options.AckTimeoutSeconds}).");
}
}
return errors.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(string.Join(" ", errors));
@@ -98,14 +98,22 @@ public sealed class SplitTopologyTransportValidator : IValidateOptions<MeshTrans
var telemetryMode = _configuration["Telemetry:Mode"] ?? TelemetryOptions.ModeDps;
var telemetryDialMode = _configuration["TelemetryDial:Mode"] ?? TelemetryDialOptions.ModeDps;
if (!string.Equals(meshMode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase))
// Two accepted values, not one, for the duration of the ClusterClient→gRPC migration: both
// cross a mesh boundary, which is the only property this rule cares about. Without the
// relaxation the very first rollout step — flipping a split-topology node to Grpc — would be a
// host-start failure. It is NOT widened to "anything but Dps": a typo'd mode delivers nothing
// just as surely, so the check stays an explicit allow-list and tightens to Grpc-only once
// ClusterClient is deleted.
if (!string.Equals(meshMode, MeshTransportOptions.ModeClusterClient, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(meshMode, MeshTransportOptions.ModeGrpc, StringComparison.OrdinalIgnoreCase))
{
errors.Add(
$"Cluster:Roles declares a cluster-{clusterId} role (split topology) but "
+ $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)} is "
+ $"'{meshMode}'; a split mesh cannot use DistributedPubSub — set "
+ $"{MeshTransportOptions.SectionName}:{nameof(MeshTransportOptions.Mode)}="
+ $"{MeshTransportOptions.ModeClusterClient}.");
+ $"{MeshTransportOptions.ModeGrpc} (or {MeshTransportOptions.ModeClusterClient}, the "
+ "transport it replaces).");
}
if (isDriver
@@ -0,0 +1,391 @@
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
/// <summary>
/// The one translation between the five cross-mesh command records and their
/// <c>mesh_transport.v1</c> wire shapes (ClusterClient→gRPC migration Phase 0). Both directions live
/// here so a field can only be added to one side without the other by making this file
/// visibly asymmetric.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here guesses.</b> The transport it replaces — an Akka ClusterClient receptionist —
/// dropped an undeliverable command silently, and the entire point of the migration is to stop
/// doing that. So every decode that cannot produce a faithful record throws: an unset oneof, an
/// <c>APPLY_OUTCOME_UNSPECIFIED</c> ack, an enum value from a newer peer. A loud failure on one
/// command is recoverable; a deployment reported applied because an absent enum field decoded to
/// 0 is not.
/// </para>
/// <para>
/// <b>Presence, not emptiness.</b> The two nullable members that cross this contract —
/// <see cref="AlarmCommand.Comment"/> and <see cref="ApplyAck.FailureReason"/> — are
/// <c>optional string</c> on the wire, so <c>null</c> maps to an absent field and <c>""</c> to a
/// present-but-empty one. Collapsing them would report an operator's deliberately blank comment
/// as "no comment supplied", and a blank failure reason as "no reason recorded".
/// <see cref="AlarmCommand.UnshelveAtUtc"/> uses a message field, whose own absence carries the
/// null.
/// </para>
/// <para>
/// <b>Correlation is written twice and read once.</b> Encode stamps the envelope's
/// <c>correlation_id</c> and the command's own from the same source. Decode takes the command's
/// own for the three records that declare one — that member is domain state the record must
/// carry back verbatim — and leaves the envelope's to the transport, which is the only place
/// <see cref="AlarmCommand"/>'s correlation can live at all since the record has no member for it.
/// </para>
/// </remarks>
public static class MeshTransportDtoMapper
{
// ---------------------------------------------------------------- DispatchDeployment
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain dispatch command.</param>
/// <returns>The wire <see cref="DispatchDeploymentCommand"/>.</returns>
public static DispatchDeploymentCommand ToProto(DispatchDeployment message)
{
ArgumentNullException.ThrowIfNull(message);
return new DispatchDeploymentCommand
{
DeploymentId = message.DeploymentId.ToString(),
RevisionHash = message.RevisionHash.Value ?? "",
CorrelationId = message.CorrelationId.ToString(),
};
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire dispatch command.</param>
/// <returns>The domain <see cref="DispatchDeployment"/>.</returns>
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
/// <exception cref="ArgumentException">The revision hash is absent or blank.</exception>
public static DispatchDeployment FromProto(DispatchDeploymentCommand proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new DispatchDeployment(
DeploymentId.Parse(proto.DeploymentId),
RevisionHash.Parse(proto.RevisionHash),
CorrelationId.Parse(proto.CorrelationId));
}
// ---------------------------------------------------------------- RestartDriver
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain restart command.</param>
/// <returns>The wire <see cref="RestartDriverCommand"/>.</returns>
public static RestartDriverCommand ToProto(RestartDriver message)
{
ArgumentNullException.ThrowIfNull(message);
// Required (non-optional) proto string setters throw on null and the record's nullable-ref
// annotations are compile-time only, so a null reaching here is already a producer bug. Coalesce
// rather than throw out of the mapper: a command with a blank field is diagnosable downstream,
// whereas a throw here is the silent drop this transport exists to remove.
return new RestartDriverCommand
{
ClusterId = message.ClusterId ?? "",
DriverInstanceId = message.DriverInstanceId ?? "",
ActorByUserName = message.ActorByUserName ?? "",
CorrelationId = message.CorrelationId.ToString("N"),
};
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire restart command.</param>
/// <returns>The domain <see cref="RestartDriver"/>.</returns>
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
public static RestartDriver FromProto(RestartDriverCommand proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new RestartDriver(
proto.ClusterId,
proto.DriverInstanceId,
proto.ActorByUserName,
Guid.ParseExact(proto.CorrelationId, "N"));
}
// ---------------------------------------------------------------- ReconnectDriver
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain reconnect command.</param>
/// <returns>The wire <see cref="ReconnectDriverCommand"/>.</returns>
public static ReconnectDriverCommand ToProto(ReconnectDriver message)
{
ArgumentNullException.ThrowIfNull(message);
return new ReconnectDriverCommand
{
ClusterId = message.ClusterId ?? "",
DriverInstanceId = message.DriverInstanceId ?? "",
ActorByUserName = message.ActorByUserName ?? "",
CorrelationId = message.CorrelationId.ToString("N"),
};
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire reconnect command.</param>
/// <returns>The domain <see cref="ReconnectDriver"/>.</returns>
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
public static ReconnectDriver FromProto(ReconnectDriverCommand proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new ReconnectDriver(
proto.ClusterId,
proto.DriverInstanceId,
proto.ActorByUserName,
Guid.ParseExact(proto.CorrelationId, "N"));
}
// ---------------------------------------------------------------- AlarmCommand
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain alarm command.</param>
/// <returns>The wire <see cref="AlarmCommandMessage"/>.</returns>
/// <remarks>
/// <see cref="AlarmCommand.Operation"/> is copied verbatim. It is an unconstrained string in the
/// record and stays one on the wire, so an operation this build does not recognise reaches the
/// far side intact instead of collapsing onto an enum's UNSPECIFIED and being dropped.
/// </remarks>
public static AlarmCommandMessage ToProto(AlarmCommand message)
{
ArgumentNullException.ThrowIfNull(message);
var proto = new AlarmCommandMessage
{
AlarmId = message.AlarmId ?? "",
Operation = message.Operation ?? "",
User = message.User ?? "",
};
if (message.Comment is not null)
proto.Comment = message.Comment;
if (message.UnshelveAtUtc is { } unshelveAt)
proto.UnshelveAtUtc = ToUtcTimestamp(unshelveAt);
return proto;
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire alarm command.</param>
/// <returns>The domain <see cref="AlarmCommand"/>.</returns>
public static AlarmCommand FromProto(AlarmCommandMessage proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new AlarmCommand(
proto.AlarmId,
proto.Operation,
proto.User,
proto.HasComment ? proto.Comment : null,
proto.UnshelveAtUtc?.ToDateTime());
}
// ---------------------------------------------------------------- ApplyAck
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain per-node apply acknowledgment.</param>
/// <returns>The wire <see cref="ApplyAckMessage"/>.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <see cref="ApplyAck.Outcome"/> is not a declared <see cref="ApplyAckOutcome"/>.
/// </exception>
public static ApplyAckMessage ToProto(ApplyAck message)
{
ArgumentNullException.ThrowIfNull(message);
var proto = new ApplyAckMessage
{
DeploymentId = message.DeploymentId.ToString(),
NodeId = message.NodeId.Value ?? "",
Outcome = message.Outcome switch
{
ApplyAckOutcome.Applied => ApplyOutcome.Applied,
ApplyAckOutcome.Failed => ApplyOutcome.Failed,
_ => throw new ArgumentOutOfRangeException(
nameof(message), message.Outcome, "Unknown ApplyAckOutcome"),
},
CorrelationId = message.CorrelationId.ToString(),
};
if (message.FailureReason is not null)
proto.FailureReason = message.FailureReason;
return proto;
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire apply acknowledgment.</param>
/// <returns>The domain <see cref="ApplyAck"/>.</returns>
/// <exception cref="InvalidOperationException">
/// The outcome is <c>APPLY_OUTCOME_UNSPECIFIED</c> or a value this build does not know.
/// </exception>
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
/// <exception cref="ArgumentException">The node id is absent or blank.</exception>
public static ApplyAck FromProto(ApplyAckMessage proto)
{
ArgumentNullException.ThrowIfNull(proto);
// proto3 decodes an absent enum field as 0, and the deploy path seals a revision on Applied.
// ApplyAckOutcome has no Unknown member to land on, so an unspecified or unrecognised outcome
// must refuse rather than pick one of the two real answers — either choice would be a lie about
// what the node said, and the "Applied" lie seals a deployment nobody applied.
var outcome = proto.Outcome switch
{
ApplyOutcome.Applied => ApplyAckOutcome.Applied,
ApplyOutcome.Failed => ApplyAckOutcome.Failed,
_ => throw new InvalidOperationException(
$"ApplyAckMessage carries outcome '{proto.Outcome}', which has no ApplyAckOutcome " +
"equivalent. Refusing to decode rather than report an unsaid deployment result."),
};
return new ApplyAck(
DeploymentId.Parse(proto.DeploymentId),
NodeId.Parse(proto.NodeId),
outcome,
proto.HasFailureReason ? proto.FailureReason : null,
CorrelationId.Parse(proto.CorrelationId));
}
// ---------------------------------------------------------------- Envelope
/// <summary>Wraps a dispatch command in its transport envelope.</summary>
/// <param name="message">The domain dispatch command.</param>
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
public static CommandEnvelope ToEnvelope(DispatchDeployment message)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = message.CorrelationId.ToString(),
DispatchDeployment = ToProto(message),
};
}
/// <summary>Wraps a restart command in its transport envelope.</summary>
/// <param name="message">The domain restart command.</param>
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
public static CommandEnvelope ToEnvelope(RestartDriver message)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = message.CorrelationId.ToString("N"),
RestartDriver = ToProto(message),
};
}
/// <summary>Wraps a reconnect command in its transport envelope.</summary>
/// <param name="message">The domain reconnect command.</param>
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
public static CommandEnvelope ToEnvelope(ReconnectDriver message)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = message.CorrelationId.ToString("N"),
ReconnectDriver = ToProto(message),
};
}
/// <summary>Wraps an alarm command in its transport envelope.</summary>
/// <param name="message">The domain alarm command.</param>
/// <param name="correlationId">
/// Transport correlation. Explicit because <see cref="AlarmCommand"/> is the one record with no
/// correlation member of its own, so the envelope is the only place it can be carried.
/// </param>
/// <returns>The envelope.</returns>
public static CommandEnvelope ToEnvelope(AlarmCommand message, CorrelationId correlationId)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = correlationId.ToString(),
AlarmCommand = ToProto(message),
};
}
/// <summary>
/// Untyped dispatch for the transport router, which holds its payload as
/// <see cref="MeshCommand.Message"/> (an <see cref="object"/>).
/// </summary>
/// <param name="message">One of the four central→node command records.</param>
/// <param name="alarmCorrelationId">
/// Consulted <b>only</b> when <paramref name="message"/> is an <see cref="AlarmCommand"/>. The
/// other three carry their own correlation and it is authoritative for them, so passing a
/// different value here cannot make the envelope disagree with the command inside it.
/// </param>
/// <returns>The envelope produced by the matching typed overload.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="message"/> is not one of the four handled command records — the transport
/// would otherwise carry a payload the far side cannot decode.
/// </exception>
public static CommandEnvelope ToEnvelope(object message, CorrelationId alarmCorrelationId)
{
ArgumentNullException.ThrowIfNull(message);
return message switch
{
DispatchDeployment m => ToEnvelope(m),
RestartDriver m => ToEnvelope(m),
ReconnectDriver m => ToEnvelope(m),
AlarmCommand m => ToEnvelope(m, alarmCorrelationId),
_ => throw new ArgumentOutOfRangeException(
nameof(message), message.GetType().FullName, "Not a cross-mesh command record"),
};
}
/// <summary>Unwraps a transport envelope into the domain record it carries.</summary>
/// <param name="envelope">The received envelope.</param>
/// <returns>
/// One of <see cref="DispatchDeployment"/>, <see cref="RestartDriver"/>,
/// <see cref="ReconnectDriver"/> or <see cref="AlarmCommand"/>, boxed — the node-side handlers
/// match on the concrete type exactly as they do under the DPS transport.
/// </returns>
/// <exception cref="InvalidOperationException">
/// The oneof is unset or names a case this build does not handle. Kept unreachable by
/// <c>MeshTransportProtoContract</c> and its contract-lock test; it throws anyway because the
/// alternative is a command that crosses the mesh and disappears.
/// </exception>
public static object FromEnvelope(CommandEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
return envelope.CommandCase switch
{
CommandEnvelope.CommandOneofCase.DispatchDeployment => FromProto(envelope.DispatchDeployment),
CommandEnvelope.CommandOneofCase.RestartDriver => FromProto(envelope.RestartDriver),
CommandEnvelope.CommandOneofCase.ReconnectDriver => FromProto(envelope.ReconnectDriver),
CommandEnvelope.CommandOneofCase.AlarmCommand => FromProto(envelope.AlarmCommand),
_ => throw new InvalidOperationException(
$"CommandEnvelope (correlation '{envelope.CorrelationId}') carries oneof case " +
$"'{envelope.CommandCase}', which this build does not handle."),
};
}
/// <summary>
/// Converts a domain <see cref="DateTime"/> to a proto <see cref="Timestamp"/> without ever
/// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed
/// already-UTC (the producer contract for every <c>…Utc</c> member here).
/// </summary>
private static Timestamp ToUtcTimestamp(DateTime dt)
{
var utc = dt.Kind switch
{
DateTimeKind.Utc => dt,
DateTimeKind.Local => dt.ToUniversalTime(),
_ => DateTime.SpecifyKind(dt, DateTimeKind.Utc),
};
return Timestamp.FromDateTime(utc);
}
}
@@ -0,0 +1,33 @@
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
namespace ZB.MOM.WW.OtOpcUa.Commons.Protos;
/// <summary>
/// The single source of truth for which <see cref="CommandEnvelope.CommandOneofCase"/> variants
/// the cross-mesh command transport actually handles. Both the contract-lock test (which proves
/// this list stays in lock-step with the generated oneof) and the oneof↔domain mapper reference
/// this array, so adding a fifth command to <c>mesh_transport.proto</c> forces a matching entry
/// here or the build/test goes red.
/// </summary>
/// <remarks>
/// Mirrors <see cref="TelemetryProtoContract"/>, and for a sharper reason: a telemetry variant
/// nobody handles loses an observation, whereas a command variant nobody handles loses a
/// deployment, a driver restart or an alarm acknowledgement — silently, since the sender's ack
/// only ever meant "received".
/// </remarks>
public static class MeshTransportProtoContract
{
/// <summary>
/// Exactly the four cross-mesh command oneof cases mirrored from the domain records:
/// DispatchDeployment / RestartDriver / ReconnectDriver / AlarmCommand. The fifth message on
/// this contract, <c>ApplyAckMessage</c>, travels the opposite direction on its own RPC and is
/// not a oneof member, so it is deliberately absent from this list.
/// </summary>
public static readonly CommandEnvelope.CommandOneofCase[] HandledCases =
[
CommandEnvelope.CommandOneofCase.DispatchDeployment,
CommandEnvelope.CommandOneofCase.RestartDriver,
CommandEnvelope.CommandOneofCase.ReconnectDriver,
CommandEnvelope.CommandOneofCase.AlarmCommand,
];
}
@@ -0,0 +1,118 @@
syntax = "proto3";
package mesh_transport.v1;
option csharp_namespace = "ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1";
import "google/protobuf/timestamp.proto";
// ClusterClient→gRPC migration Phase 0: the cross-mesh command plane. The five messages that cross
// an application-Cluster boundary today ride an Akka ClusterClient receptionist; this contract is
// the replacement carrier. Four are central→node commands, one is the node→central deployment ack.
//
// The C# namespace deliberately says "MeshTransport", never "MeshCommand" — the latter is already
// the CLR envelope record ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh.MeshCommand, and a generated type
// of the same name would collide on every file that touches both sides of the mapper.
//
// Additive-only field evolution: never renumber or reuse a tag; a pre-field peer must read a new
// field's proto3 default correctly (which is why nullable domain members are modelled with `optional`
// for explicit presence, and why every enum reserves 0 as UNSPECIFIED). The oneof is locked by
// MeshTransportProtoContract + its test in the Commons test project.
// Central → node. Delivered to the node's NodeCommunicationActor, which re-emits the decoded message
// on the node's EventStream. ONE-WAY semantics, preserved verbatim from the ClusterClient transport:
// the ack means "received and re-emitted", nothing more — not applied, not acknowledged by any
// subscriber. No retry is layered on a failure; a failed Execute is logged per node and dropped,
// which is an honest upgrade over the receptionist's silent drop rather than a semantic change.
service NodeCommandService {
rpc Execute (CommandEnvelope) returns (ExecuteAck);
}
// Node → central. Delivered to central's CentralCommunicationActor, which forwards to the
// ConfigPublishCoordinator singleton proxy — so an ack landing on either node of the central pair
// routes identically, exactly as the receptionist made arrival-node-agnostic. ONE-WAY semantics: the
// ack means "handed to the coordinator", not "the deployment is complete".
service CentralCommandService {
rpc ReportApply (ApplyAckMessage) returns (ReportAck);
}
message CommandEnvelope {
// Transport-level correlation, carried for every command including AlarmCommandMessage, which is
// the one CLR record with no correlation member of its own. The other three ALSO carry their own
// domain correlation field-for-field below; the duplication is deliberate, so each command stays
// independently round-trippable through the mapper and the envelope stays loggable on its own.
string correlation_id = 1;
oneof command {
DispatchDeploymentCommand dispatch_deployment = 2; // <- Messages.Deploy.DispatchDeployment
RestartDriverCommand restart_driver = 3; // <- Messages.Admin.RestartDriver
ReconnectDriverCommand reconnect_driver = 4; // <- Messages.Admin.ReconnectDriver
AlarmCommandMessage alarm_command = 5; // <- OpcUa.AlarmCommand
}
}
// Deliberately empty. A non-OK gRPC status is the ONLY failure signal on this call — putting a
// success flag in the body would invite a caller to read OK+false as a distinct outcome the
// fire-and-forget contract does not have. Extensible later without a version bump.
message ExecuteAck {
}
// Deliberately empty, for the same reason as ExecuteAck.
message ReportAck {
}
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.DispatchDeployment.
message DispatchDeploymentCommand {
string deployment_id = 1; // DeploymentId (Guid) in its "N" hex form
string revision_hash = 2; // RevisionHash — lowercase 64-char SHA-256 hex
string correlation_id = 3; // CorrelationId (Guid) in its "N" hex form
}
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin.RestartDriver.
message RestartDriverCommand {
string cluster_id = 1;
string driver_instance_id = 2;
string actor_by_user_name = 3;
string correlation_id = 4; // Guid in the record; "N" hex form
}
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin.ReconnectDriver.
message ReconnectDriverCommand {
string cluster_id = 1;
string driver_instance_id = 2;
string actor_by_user_name = 3;
string correlation_id = 4; // Guid in the record; "N" hex form
}
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.OpcUa.AlarmCommand.
message AlarmCommandMessage {
string alarm_id = 1;
// The Part 9 operation (Acknowledge / Confirm / OneShotShelve / TimedShelve / Unshelve / Enable /
// Disable / AddComment). A `string` in the CLR record, and kept as one here — the same call the
// telemetry contract makes for transition_kind / state / level. Promoting it to a proto enum would
// put a lossy translation in the mapper for a set the CLR side does not constrain: an operation the
// enum did not know would decode to UNSPECIFIED and the command would be dropped, which is the
// exact silent-drop class this migration exists to remove.
string operation = 2;
string user = 3;
optional string comment = 4; // nullable in the record — presence distinguishes null from ""
google.protobuf.Timestamp unshelve_at_utc = 5; // DateTime? — absent Timestamp encodes null
}
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAck.
message ApplyAckMessage {
string deployment_id = 1; // DeploymentId (Guid) in its "N" hex form
string node_id = 2; // NodeId — a logical host name, already a string
ApplyOutcome outcome = 3;
optional string failure_reason = 4; // nullable in the record — presence distinguishes null from ""
string correlation_id = 5; // CorrelationId (Guid) in its "N" hex form
}
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAckOutcome, shifted by one so 0 stays
// meaningless. proto3 decodes an absent enum field as 0; if 0 were Applied, an ack that lost the
// field would report a deployment applied when the node said nothing at all — and the deploy path
// seals on exactly that signal.
enum ApplyOutcome {
APPLY_OUTCOME_UNSPECIFIED = 0;
APPLY_OUTCOME_APPLIED = 1;
APPLY_OUTCOME_FAILED = 2;
}
@@ -28,6 +28,7 @@
<ItemGroup>
<Protobuf Include="Protos\deployment_artifact.proto" GrpcServices="Both"/>
<Protobuf Include="Protos\telemetry.proto" GrpcServices="Both"/>
<Protobuf Include="Protos\mesh_transport.proto" GrpcServices="Both"/>
</ItemGroup>
<ItemGroup>
@@ -88,8 +88,12 @@ public sealed class AdminOperationsActor : ReceiveActor
/// </summary>
/// <remarks>
/// The fallback keeps ~12 existing tests (and any node wired before per-cluster mesh Phase 2)
/// on exactly the pre-Phase-2 behaviour. It is not a permanent seam: Phase 6 deletes it along
/// with the DistributedPubSub branch and the single mesh.
/// on exactly the pre-Phase-2 behaviour. The DistributedPubSub branch behind it is
/// <b>retained</b>, not scheduled for deletion — an earlier note here promised Phase 6 would
/// remove it, which was deliberately reversed (<c>docs/Configuration.md</c> § "The compiled
/// transport-mode defaults were deliberately NOT flipped"): DPS is still the correct and
/// default transport for a single-mesh topology. What is being replaced is the ClusterClient
/// branch, by gRPC.
/// </remarks>
/// <param name="topic">The legacy DistributedPubSub topic.</param>
/// <param name="message">The command.</param>
@@ -0,0 +1,244 @@
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;
/// <summary>
/// Central's per-cluster pre-shared key lookup for the gRPC command plane. A node holds exactly
/// one key (<c>MeshTransport:GrpcPsk</c>, its own cluster's); central talks to every cluster, so
/// it needs one key per <c>Cluster</c> — both to authenticate itself when dialling a node and to
/// verify an inbound deployment ack.
/// </summary>
/// <remarks>
/// <para>
/// <b>Fail-closed by contract.</b> Every failure — unknown cluster, absent secret, blank
/// value, a store that throws — is reported as "no key" (<c>false</c>), 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.
/// </para>
/// <para>
/// The <c>Invalidate</c> 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.
/// </para>
/// <para>
/// <b><c>Invalidate</c> is not a revocation barrier.</b> It drops the cached entry; it does
/// not wait for, cancel, or supersede a resolve already in flight. A lookup that entered the
/// store before the call can still publish and return the pre-rotation key to its caller, and
/// a lookup racing the eviction can re-cache the old value it had already read. Key rotation
/// is therefore eventually consistent by seconds, not atomic — treat <c>Invalidate</c> as
/// "stop serving this from memory", never as "this key is now dead everywhere".
/// </para>
/// </remarks>
public interface IMeshPskProvider
{
/// <summary>Gets the pre-shared key for one application <c>Cluster</c>.</summary>
/// <param name="clusterId">The application <c>Cluster</c> id, matched case-insensitively.</param>
/// <param name="psk">The key, or <c>null</c> when none could be established.</param>
/// <returns><c>true</c> when a usable key was found; otherwise <c>false</c>.</returns>
bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk);
/// <summary>Drops any cached key for one cluster, so the next lookup re-reads the store.</summary>
/// <param name="clusterId">The application <c>Cluster</c> id, matched case-insensitively.</param>
void Invalidate(string clusterId);
/// <summary>Drops every cached key.</summary>
void InvalidateAll();
}
/// <inheritdoc />
public sealed class MeshPskProvider : IMeshPskProvider
{
/// <summary>
/// Prefix of the secret holding a cluster's key — the full name is
/// <c>OT-MESH-PSK-{clusterId}</c>. The same name a node's
/// <c>${secret:OT-MESH-PSK-&lt;clusterId&gt;}</c> reference expands, so central and the node
/// read one row rather than two conventions that drift.
/// </summary>
public const string SecretNamePrefix = "OT-MESH-PSK-";
/// <summary>
/// Ceiling on one store read. Not a tuning knob — a backstop: the read runs under the
/// <see cref="Lazy{T}"/> publication lock, so a store that never answers would park the
/// calling gRPC thread <i>and</i> every other caller queued behind it for that cluster.
/// Expiry fails closed like any other fault.
/// </summary>
private static readonly TimeSpan StoreReadTimeout = TimeSpan.FromSeconds(3);
private readonly IOptions<MeshTransportOptions> _options;
private readonly ISecretResolver _secretResolver;
private readonly ILogger<MeshPskProvider> _logger;
/// <summary>
/// Cached store resolutions, keyed by cluster id. Holds <b>only</b> 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.
/// </summary>
/// <remarks>
/// <see cref="Lazy{T}"/> under <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> so a
/// burst of concurrent inbound acks for one cluster resolves the secret once instead of
/// stampeding the store. Negatives are never retained — see <see cref="TryGetPsk"/>.
/// </remarks>
private readonly ConcurrentDictionary<string, Lazy<string?>> _cache =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Creates the provider.</summary>
/// <param name="options">Mesh transport options; <c>ClusterPsks</c> is the override map.</param>
/// <param name="secretResolver">The shared secret store read seam.</param>
/// <param name="logger">Logger for lookup-failure diagnostics.</param>
public MeshPskProvider(
IOptions<MeshTransportOptions> options,
ISecretResolver secretResolver,
ILogger<MeshPskProvider> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(secretResolver);
ArgumentNullException.ThrowIfNull(logger);
_options = options;
_secretResolver = secretResolver;
_logger = logger;
}
/// <inheritdoc />
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__<id> 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<string?>(
() => self.ResolveFromStore(id), LazyThreadSafetyMode.ExecutionAndPublication),
this);
string? resolved;
try
{
resolved = entry.Value;
}
catch
{
// ResolveFromStore swallows everything the STORE can throw — but it logs while doing so,
// and a logger provider that throws escapes the factory. Lazy under
// ExecutionAndPublication caches that exception and rethrows it on every later read, so
// without this guard one bad log call would deny the cluster for the life of the process
// (nothing calls Invalidate until Phase 1A). Evict instance-keyed, same as the negative
// path below, and fail closed.
//
// Deliberately silent: the most likely reason we are here is that logging is what threw,
// so logging the failure is the one action with a real chance of throwing again — out of
// an authentication gate, turning a denial into a 500.
_cache.TryRemove(new KeyValuePair<string, Lazy<string?>>(clusterId, entry));
return false;
}
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<string, Lazy<string?>>(clusterId, entry));
return false;
}
psk = resolved;
return true;
}
/// <inheritdoc />
public void Invalidate(string clusterId)
{
if (string.IsNullOrWhiteSpace(clusterId))
return;
_cache.TryRemove(clusterId, out _);
}
/// <inheritdoc />
public void InvalidateAll() => _cache.Clear();
/// <summary>
/// Reads <c>OT-MESH-PSK-{clusterId}</c> from the Secrets store, converting every failure mode
/// into <c>null</c>.
/// </summary>
/// <param name="clusterId">The application <c>Cluster</c> id.</param>
/// <returns>The stored key, or <c>null</c> when it could not be established.</returns>
/// <remarks>
/// <para>
/// Blocking on the async resolver is deliberate. The consumer is a gRPC server
/// interceptor, whose <c>Authorize</c> is synchronous in all four sibling interceptors,
/// and making this seam async would push that shape onto every one of them. What makes
/// the block acceptable is that it is <b>bounded</b> (<see cref="StoreReadTimeout"/>) and
/// happens at most once per cluster per process — every later lookup is a dictionary hit.
/// The bound does not assume anything about what backs the store: this code cannot see
/// whether the resolver reads a local file or crosses a network, so it refuses to wait
/// forever on either.
/// </para>
/// <para>
/// 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".
/// </para>
/// </remarks>
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;
}
using var cts = new CancellationTokenSource(StoreReadTimeout);
try
{
return _secretResolver.GetAsync(name, cts.Token).GetAwaiter().GetResult();
}
catch (Exception ex)
{
// Covers the timeout too — a cancelled read arrives here as OperationCanceledException
// and is treated as every other fault is: no key.
_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;
}
}
}
@@ -68,8 +68,11 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
/// </summary>
/// <remarks>
/// Null in tests and on any node wired before Phase 2, where the fallback reproduces the
/// pre-Phase-2 behaviour exactly. The fallback is not a permanent seam — Phase 6 deletes it
/// along with the DistributedPubSub branch and the single mesh.
/// pre-Phase-2 behaviour exactly. The DistributedPubSub branch behind it is <b>retained</b>,
/// not scheduled for deletion — an earlier note here promised Phase 6 would remove it, which
/// was deliberately reversed (<c>docs/Configuration.md</c> § "The compiled transport-mode
/// defaults were deliberately NOT flipped"): DPS is still the correct and default transport
/// for a single-mesh topology. What is being replaced is the ClusterClient branch, by gRPC.
/// </remarks>
private readonly IActorRef? _meshRouter;
private readonly ILoggingAdapter _log = Context.GetLogger();
@@ -19,6 +19,10 @@
Google.Protobuf + Grpc.Core.Api flow transitively from Commons (the generated client). -->
<PackageReference Include="Grpc.Net.Client"/>
<PackageReference Include="ZB.MOM.WW.Audit"/>
<!-- ClusterClient→gRPC: MeshPskProvider resolves OT-MESH-PSK-{clusterId} from the shared
secret store when MeshTransport:ClusterPsks carries no override. Abstractions only —
the concrete store is the host's choice (SecretsRegistration), not the control plane's. -->
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions"/>
<!-- For ClusterActiveNode — the shared "oldest Up member of role X" primitive. The control
plane and the /health/active tier must give the SAME answer for which node is in charge,
or the tier reports one node active while the Primary-gated data plane runs on another;
@@ -0,0 +1,185 @@
using System.Security.Cryptography;
using System.Text;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Gates the central-side command endpoint (ClusterClient→gRPC migration). The
/// <c>CentralCommandService.ReportApply</c> RPC is how a node tells central its deployment
/// applied; the coordinator counts those acks, so anything able to reach central's command port
/// could otherwise forge a fleet-wide "everything applied". 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.CentralCommandService/</c> are gated; every other method on the
/// shared gRPC pipeline (config-serve, and on a fused node sync + telemetry + the node
/// command service) passes through untouched, so this shares one <c>AddGrpc</c> registration
/// with its siblings.
/// </para>
/// <para>
/// <b>Per-cluster key, not one server key.</b> Unlike the four sibling interceptors there is
/// no single expected token: central talks to N site clusters, each with its own PSK. The
/// caller names its cluster in <c>x-otopcua-cluster</c> and the bearer is verified against
/// <i>that</i> cluster's key via <see cref="IMeshPskProvider"/>. The header is therefore a
/// claim the caller must then prove, not a credential — choosing a different cluster only
/// changes which key it is graded against. A single fleet-wide key would let one compromised
/// site node forge acks on behalf of every other site.
/// </para>
/// <para>
/// <b>Fail-closed.</b> A missing or blank cluster header, and a cluster whose key cannot be
/// established (unseeded, blank, or a secret store that is down), are both denied. There is
/// deliberately no "try every known key" fallback: it would restore exactly the fleet-wide
/// key this design exists to avoid.
/// </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>
/// <b>All three denials return the same status text</b>, unlike the sibling interceptors,
/// which distinguish "no key configured" from "authentication failed". Here that distinction
/// would be a <i>content</i> oracle: the caller supplies the cluster id, so a reply that said
/// "no key for that cluster" would let an unauthenticated caller read central's cluster set
/// straight off the wire. The operator-facing detail goes to the log instead.
/// </para>
/// <para>
/// <b>That is not yet full enumeration resistance, and should not be read as such.</b> A
/// timing oracle survives it: an unknown cluster misses the provider's cache and pays a store
/// read on every probe, while a known one is answered from memory after the first. Closing
/// that needs the lookup to be refused before it reaches the store for any cluster central
/// does not know — which is the Phase 1A known-cluster-set gate, where central learns its
/// cluster set from the DB. Until then, uniform text buys content-level indistinguishability
/// only.
/// </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>CentralCommandAuthInterceptorTests.HasExactlyOnePublicConstructor</c>.
/// </para>
/// </remarks>
public sealed class CentralCommandAuthInterceptor : Interceptor
{
private const string ServicePrefix = "/mesh_transport.v1.CentralCommandService/";
private const string ClusterHeader = "x-otopcua-cluster";
private readonly IMeshPskProvider _pskProvider;
private readonly ILogger<CentralCommandAuthInterceptor> _logger;
/// <summary>Creates the interceptor.</summary>
/// <param name="pskProvider">Supplies the expected bearer token for a given cluster.</param>
/// <param name="logger">Logger for denial diagnostics.</param>
public CentralCommandAuthInterceptor(
IMeshPskProvider pskProvider,
ILogger<CentralCommandAuthInterceptor> logger)
{
ArgumentNullException.ThrowIfNull(pskProvider);
ArgumentNullException.ThrowIfNull(logger);
_pskProvider = pskProvider;
_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 central-command call that does not name a cluster, names one with no resolvable key, or
/// does not carry that cluster's bearer token. Non-command calls return immediately.
/// </summary>
private void Authorize(ServerCallContext context)
{
if (!context.Method.StartsWith(ServicePrefix, StringComparison.Ordinal))
return;
var clusterId = GrpcRequestHeaders.First(context.RequestHeaders, ClusterHeader);
if (string.IsNullOrWhiteSpace(clusterId))
{
_logger.LogWarning(
"Rejected a central command call to {Method}: no {Header} header, so there is no "
+ "cluster whose pre-shared key the caller could be graded against.",
context.Method,
ClusterHeader);
throw new RpcException(new Status(
StatusCode.PermissionDenied,
"Central command authentication failed."));
}
if (!_pskProvider.TryGetPsk(clusterId, out var expected))
{
_logger.LogWarning(
"Rejected a central command call to {Method} claiming cluster {ClusterId}: no "
+ "pre-shared key could be established for it. Seed the secret "
+ "OT-MESH-PSK-<clusterId> on this node, or set "
+ "MeshTransport:ClusterPsks:<clusterId> on a rig.",
context.Method,
clusterId);
throw new RpcException(new Status(
StatusCode.PermissionDenied,
"Central command authentication failed."));
}
var presented = GrpcRequestHeaders.BearerToken(context.RequestHeaders);
if (presented is null || !FixedTimeEquals(presented, expected))
{
_logger.LogWarning(
"Rejected a central command call to {Method} claiming cluster {ClusterId}: {Reason}.",
context.Method,
clusterId,
presented is null ? "no bearer token presented" : "bearer token did not match");
throw new RpcException(new Status(
StatusCode.PermissionDenied,
"Central command authentication failed."));
}
}
private static bool FixedTimeEquals(string presented, string expected)
=> CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
}
@@ -0,0 +1,70 @@
using Grpc.Core;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Request-metadata reads shared by the two mesh-command auth interceptors
/// (<see cref="MeshCommandAuthInterceptor"/>, <see cref="CentralCommandAuthInterceptor"/>).
/// </summary>
/// <remarks>
/// <para>
/// Exists because those two are documented structural clones of each other, and the one place
/// a clone pair can silently diverge is header parsing — where a difference is not a style
/// difference but a different answer to "which of these two <c>authorization</c> headers
/// counts". They had exactly that divergence before this type: one took the first entry, the
/// other scanned for the first <c>Bearer</c>-prefixed entry, so a request carrying
/// <c>authorization: junk</c> followed by <c>authorization: Bearer &lt;psk&gt;</c> was denied
/// by one and accepted by the other.
/// </para>
/// <para>
/// <b>First entry wins, and it must parse.</b> A non-<c>Bearer</c> first entry is a failed
/// authentication, never a reason to keep looking: scanning past it lets a caller smuggle a
/// credential behind a header some intermediary may have inspected, matched or rewritten, and
/// makes which credential is checked depend on ordering nothing guarantees.
/// </para>
/// <para>
/// The three pre-existing sibling interceptors (LocalDb sync, config-serve, telemetry) keep
/// their own private copies deliberately — they are a shipped, live-gated wire contract, and
/// tightening their header handling is a behaviour change to three running endpoints, not a
/// refactor.
/// </para>
/// </remarks>
internal static class GrpcRequestHeaders
{
/// <summary>The bearer-token header, lowercased as gRPC puts it on the wire.</summary>
internal const string Authorization = "authorization";
/// <summary>Scheme prefix expected on the <see cref="Authorization"/> value.</summary>
internal const string BearerPrefix = "Bearer ";
/// <summary>Gets the first value carried under <paramref name="name"/>, or null if absent.</summary>
/// <param name="headers">The call's request metadata.</param>
/// <param name="name">The header name, matched case-insensitively.</param>
/// <returns>The first matching value, or <c>null</c>.</returns>
internal static string? First(Metadata headers, string name)
{
// 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, name, StringComparison.OrdinalIgnoreCase))
return entry.Value;
}
return null;
}
/// <summary>
/// Gets the token from the first <c>authorization</c> header, or null when that header is
/// absent or does not carry the <c>Bearer</c> scheme.
/// </summary>
/// <param name="headers">The call's request metadata.</param>
/// <returns>The bearer token, or <c>null</c>.</returns>
internal static string? BearerToken(Metadata headers)
{
var value = First(headers, Authorization);
return value is not null && value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)
? value[BearerPrefix.Length..]
: null;
}
}
@@ -0,0 +1,160 @@
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 (ClusterClient→gRPC migration). 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));
}
@@ -18,6 +18,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
@@ -526,6 +527,13 @@ if (hasAdmin)
// hosted service has built the system).
builder.Services.TryAddSingleton<Func<ActorSystem>>(sp => () => sp.GetRequiredService<ActorSystem>());
builder.Services.AddSingleton<IManualFailoverService, ManualFailoverService>();
// Per-cluster mesh PSKs (ClusterClient→gRPC). Admin-only, and singleton because its whole job is
// to hold a cache: central talks to N clusters and would otherwise re-read the secret store on
// every inbound ack. A node needs nothing of this — it holds exactly its own MeshTransport:GrpcPsk
// — so registering it fleet-wide would give a driver-only node a lookup it can never use.
// Depends on ISecretResolver, which AddOtOpcUaSecrets registers unconditionally below (DI
// registration order is irrelevant; resolution order is not).
builder.Services.AddSingleton<IMeshPskProvider, MeshPskProvider>();
}
// Registered unconditionally: driver-role nodes resolve Layer-B DriverConfig secrets and have no auth/DP/AdminUI.
@@ -547,6 +555,15 @@ builder.Services.AddOtOpcUaObservability(builder.Configuration);
// — the interceptor is the ONLY inbound auth on any endpoint (the libraries verify nothing).
// Registered whenever either role is present so an admin-only node still serves config and a
// driver-only node still gates sync + telemetry.
//
// The two mesh-command interceptors (ClusterClient→gRPC migration) join on the same terms:
// MeshCommandAuthInterceptor gates the node-side NodeCommandService (driver-role, one key — this
// node's own), CentralCommandAuthInterceptor gates the central-side CentralCommandService
// (admin-role, one key per cluster via IMeshPskProvider). Both are registered NOW and are pure
// pass-throughs until Phase 1 maps those services — an interceptor whose service is unmapped never
// sees a matching path. Registering them alongside the services they gate would put a security
// gate's wiring in the same commit as the write surface it protects; doing it here means the gate
// cannot be forgotten when that surface lights up.
if (hasDriver || hasAdmin)
{
builder.Services.AddGrpc(o =>
@@ -555,9 +572,13 @@ if (hasDriver || hasAdmin)
{
o.Interceptors.Add<LocalDbSyncAuthInterceptor>();
o.Interceptors.Add<TelemetryStreamAuthInterceptor>();
o.Interceptors.Add<MeshCommandAuthInterceptor>();
}
if (hasAdmin)
{
o.Interceptors.Add<ConfigServeAuthInterceptor>();
o.Interceptors.Add<CentralCommandAuthInterceptor>();
}
});
}
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
@@ -10,10 +11,35 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// trace. Absences are the hardest class of fault to attribute in the field, so the whole point
/// of these tests is that the host refuses to start instead.
/// </summary>
/// <remarks>
/// Role-dependent facts run in <see cref="RoleEnvironmentCollection"/> because the validator
/// resolves roles through <c>RoleParser.ResolveEffectiveRoles</c>, which reads
/// <c>OTOPCUA_ROLES</c> when <c>Cluster:Roles</c> is empty — a process-wide env var, so the
/// whole class is serialized against the other role-reading suites.
/// </remarks>
[Collection(RoleEnvironmentCollection.Name)]
public class MeshTransportOptionsValidatorTests
{
private static ValidateOptionsResult Validate(MeshTransportOptions o) =>
new MeshTransportOptionsValidator().Validate(MeshTransportOptions.SectionName, o);
private static ValidateOptionsResult Validate(MeshTransportOptions o, params string[] roles)
{
var pairs = new Dictionary<string, string?>();
for (var i = 0; i < roles.Length; i++)
{
pairs[$"Cluster:Roles:{i}"] = roles[i];
}
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
return new MeshTransportOptionsValidator(configuration)
.Validate(MeshTransportOptions.SectionName, o);
}
private static MeshTransportOptions WellFormedGrpc() => new()
{
Mode = MeshTransportOptions.ModeGrpc,
GrpcPsk = "not-a-real-key",
CentralGrpcEndpoints = ["http://central-1:4055", "https://central-2:4055"],
};
[Fact]
public void Default_options_are_valid()
@@ -26,10 +52,23 @@ public class MeshTransportOptionsValidatorTests
[Fact]
public void Unknown_mode_fails()
{
var result = Validate(new MeshTransportOptions { Mode = "grpc" });
var result = Validate(new MeshTransportOptions { Mode = "receptionist" });
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("grpc");
result.FailureMessage.ShouldContain("receptionist");
}
[Fact]
public void Unknown_mode_message_names_all_three_accepted_values()
{
// An operator who mistypes the mode gets the full menu, not a partial one — Grpc became a
// legal value in Phase 0 and an error that omitted it would send them back to ClusterClient.
var result = Validate(new MeshTransportOptions { Mode = "receptionist" });
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeDps);
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeClusterClient);
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
}
[Fact]
@@ -135,4 +174,210 @@ public class MeshTransportOptionsValidatorTests
],
}).Succeeded.ShouldBeTrue();
}
// ---- Grpc mode (ClusterClient→gRPC migration, Phase 0) --------------------------------------
[Fact]
public void Grpc_is_an_accepted_mode()
{
Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
}
[Fact]
public void Grpc_mode_matching_is_case_insensitive()
{
var options = WellFormedGrpc();
options.Mode = "grpc";
Validate(options, "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
}
[Fact]
public void Grpc_driver_node_without_a_psk_fails()
{
var options = WellFormedGrpc();
options.GrpcPsk = null;
var result = Validate(options, "driver", "cluster-SITE-A");
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("MeshTransport:GrpcPsk");
result.FailureMessage.ShouldContain("MeshTransport__GrpcPsk");
}
[Fact]
public void Grpc_driver_node_with_an_empty_psk_fails()
{
// Empty is the shape an unset environment variable actually produces, and it is exactly as
// fatal as null — a blank bearer authenticates nothing.
var options = WellFormedGrpc();
options.GrpcPsk = " ";
Validate(options, "driver", "cluster-SITE-A").Failed.ShouldBeTrue();
}
[Fact]
public void The_psk_value_is_never_echoed_in_a_failure_message()
{
// The whole point of a validator that reads a secret is that its diagnostics are safe to
// paste into a ticket. Naming the key is the contract; printing it is a leak.
var options = WellFormedGrpc();
options.GrpcPsk = "super-secret-mesh-psk";
options.CentralGrpcEndpoints = ["central-1:4055"];
var result = Validate(options, "driver", "cluster-SITE-A");
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotContain("super-secret-mesh-psk");
}
[Fact]
public void Grpc_driver_node_without_central_endpoints_fails()
{
var options = WellFormedGrpc();
options.CentralGrpcEndpoints = [];
var result = Validate(options, "driver", "cluster-SITE-A");
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("MeshTransport:CentralGrpcEndpoints");
}
[Fact]
public void A_central_endpoint_that_is_not_an_http_url_fails()
{
var options = WellFormedGrpc();
options.CentralGrpcEndpoints = ["central-1:4055"];
var result = Validate(options, "driver", "cluster-SITE-A");
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("central-1:4055");
result.FailureMessage.ShouldContain("http://");
}
[Fact]
public void One_bad_central_endpoint_among_good_ones_still_fails()
{
var options = WellFormedGrpc();
options.CentralGrpcEndpoints = ["http://central-1:4055", "akka.tcp://otopcua@central-2:4053"];
var result = Validate(options, "driver", "cluster-SITE-A");
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("akka.tcp://otopcua@central-2:4053");
}
[Fact]
public void Grpc_driver_node_with_a_non_positive_dispatch_timeout_fails()
{
var options = WellFormedGrpc();
options.DispatchTimeoutSeconds = 0;
var result = Validate(options, "driver", "cluster-SITE-A");
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("MeshTransport:DispatchTimeoutSeconds");
}
[Fact]
public void Grpc_driver_node_with_a_non_positive_ack_timeout_fails()
{
var options = WellFormedGrpc();
options.AckTimeoutSeconds = -1;
var result = Validate(options, "driver", "cluster-SITE-A");
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("MeshTransport:AckTimeoutSeconds");
}
[Fact]
public void Grpc_admin_only_node_needs_none_of_the_node_side_keys()
{
// The node-side surface is what a DRIVER uses to dial central and post its ack. An admin-only
// central node dials nodes instead and resolves per-cluster keys at runtime, so requiring
// these here would make an admin node carry a PSK and an endpoint list it never reads.
Validate(
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
"admin", "cluster-MAIN").Succeeded.ShouldBeTrue();
}
[Fact]
public void Grpc_role_less_node_needs_none_of_the_node_side_keys()
{
// Every in-process test host and every single-mesh node has no declared role. Requiring the
// Grpc surface of them would turn a dark-switch value into a tree-wide config obligation.
Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }).Succeeded.ShouldBeTrue();
}
[Fact]
public void Roles_come_from_OTOPCUA_ROLES_when_Cluster_Roles_is_empty()
{
// Roles-source divergence (Gitea #532): a node configured only through the env var is still a
// driver node in Akka. A validator reading Cluster:Roles alone would exempt the exact node
// that has to dial central, and its acks would be rejected unauthenticated at runtime.
using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A"))
{
var options = WellFormedGrpc();
options.GrpcPsk = null;
Validate(options).Failed.ShouldBeTrue();
}
}
[Fact]
public void Dps_mode_requires_none_of_the_new_Grpc_keys()
{
// The no-new-obligations invariant: adding a third mode must not make any existing
// deployment's configuration invalid. A Dps driver node that sets nothing new still passes.
Validate(
new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps },
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
}
[Fact]
public void ClusterClient_mode_requires_none_of_the_new_Grpc_keys()
{
Validate(
new MeshTransportOptions
{
Mode = MeshTransportOptions.ModeClusterClient,
CentralContactPoints = ["akka.tcp://otopcua@central-1:4053"],
},
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
}
[Fact]
public void ClusterPsks_defaults_to_an_empty_case_insensitive_map()
{
// Production leaves the map empty and the runtime provider falls back to the Secrets store;
// the rig populates it. Case-insensitivity matters because a ClusterId reaches this lookup
// from three sources — a role suffix, a DB column and an env key — that do not agree on case.
var options = new MeshTransportOptions();
options.ClusterPsks.ShouldBeEmpty();
options.ClusterPsks["MAIN"] = "rig-key";
options.ClusterPsks.ContainsKey("main").ShouldBeTrue();
}
[Fact]
public void ClusterPsks_stays_case_insensitive_after_binding_configuration()
{
// Asserting the comparer on a fresh instance proves nothing about the shape that reaches
// production: the options binder could construct its own Dictionary and drop the comparer
// silently, and the symptom would be a rig lookup that misses on a ClusterId spelled the way
// the DB spells it. Bind for real and check the comparer survived.
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeshTransport:ClusterPsks:SITE-A"] = "rig-key",
})
.Build();
var options = new MeshTransportOptions();
configuration.GetSection(MeshTransportOptions.SectionName).Bind(options);
options.ClusterPsks["site-a"].ShouldBe("rig-key");
}
}
@@ -52,9 +52,58 @@ public class SplitTopologyTransportValidatorTests
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("MeshTransport:Mode");
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeClusterClient);
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
result.FailureMessage.ShouldContain("SITE-A");
}
[Fact]
public void Cluster_role_driver_node_on_Grpc_mesh_transport_passes()
{
// The migration window: Grpc is the transport this rule exists to arrive at, so a rig node
// flipped to it must boot. Without this relaxation the very first Grpc rollout step — setting
// the mode on a split-topology node — is a host-start failure.
Validate(
meshMode: MeshTransportOptions.ModeGrpc,
telemetryMode: TelemetryOptions.ModeGrpc,
telemetryDialMode: null,
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
}
[Fact]
public void Cluster_role_admin_node_on_Grpc_mesh_transport_passes()
{
Validate(
meshMode: MeshTransportOptions.ModeGrpc,
telemetryMode: null,
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
"admin", "cluster-SITE-A").Succeeded.ShouldBeTrue();
}
[Fact]
public void Cluster_role_node_on_Grpc_mesh_transport_is_matched_case_insensitively()
{
Validate(
meshMode: "grpc",
telemetryMode: TelemetryOptions.ModeGrpc,
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
"admin", "driver", "cluster-MAIN").Succeeded.ShouldBeTrue();
}
[Fact]
public void Cluster_role_node_on_an_unrecognised_mesh_transport_still_fails()
{
// Widening the rule from one accepted value to two must not widen it to "anything that is not
// Dps" — a typo'd mode is still a mode that delivers nothing across the boundary.
var result = Validate(
meshMode: "receptionist",
telemetryMode: TelemetryOptions.ModeGrpc,
telemetryDialMode: null,
"driver", "cluster-SITE-A");
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldContain("MeshTransport:Mode");
}
[Fact]
public void Cluster_role_driver_node_fully_configured_passes()
{
@@ -0,0 +1,451 @@
using Google.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Protos;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// Round-trip goldens for <see cref="MeshTransportDtoMapper"/> — the ClusterClient→gRPC command
/// plane's only data contract. Every golden goes domain → proto → <b>serialized bytes</b> → proto →
/// domain, because the failure class this suite exists to catch is not a mis-assigned property but a
/// member that survives an in-memory copy and vanishes on the wire: proto3 explicit presence is a
/// serialization-level property, so a null-vs-<c>""</c> or absent-vs-epoch mistake is invisible
/// unless the bytes are actually round-tripped.
/// </summary>
/// <remarks>
/// Each record gets one all-members-non-default golden plus a null-variant golden for every
/// nullable member, since a nullable member is exactly where a silent drop hides — the record still
/// compares equal to a fresh instance built with the default, so nothing else in the system notices.
/// </remarks>
public class MeshTransportDtoMapperTests
{
// Deliberately non-default in every position: distinct GUIDs (so a swapped assignment cannot pass),
// a real 64-char lowercase SHA-256 hex, and strings that are neither empty nor equal to each other.
private static readonly Guid DeploymentGuid = Guid.ParseExact("11111111111111111111111111111111", "N");
private static readonly Guid CorrelationGuid = Guid.ParseExact("22222222222222222222222222222222", "N");
private static readonly Guid EnvelopeGuid = Guid.ParseExact("33333333333333333333333333333333", "N");
private const string Sha256Hex = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
/// <summary>Serializes and re-parses <paramref name="message"/> so presence is proven on the wire.</summary>
private static T Wire<T>(T message)
where T : IMessage<T>, new()
=> new MessageParser<T>(() => new T()).ParseFrom(message.ToByteArray());
// ---------------------------------------------------------------- DispatchDeployment
[Fact]
public void DispatchDeployment_RoundTrips_WithEveryMemberSet()
{
var domain = new DispatchDeployment(
new DeploymentId(DeploymentGuid),
new RevisionHash(Sha256Hex),
new CorrelationId(CorrelationGuid));
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
}
[Fact]
public void DispatchDeployment_EncodesIdsInTheirHexNForm()
{
var proto = MeshTransportDtoMapper.ToProto(new DispatchDeployment(
new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid)));
proto.DeploymentId.ShouldBe(DeploymentGuid.ToString("N"));
proto.RevisionHash.ShouldBe(Sha256Hex);
proto.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
}
// ---------------------------------------------------------------- RestartDriver / ReconnectDriver
[Fact]
public void RestartDriver_RoundTrips_WithEveryMemberSet()
{
var domain = new RestartDriver("cluster-alpha", "drv-modbus-7", "jdoe@zb.local", CorrelationGuid);
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
}
[Fact]
public void ReconnectDriver_RoundTrips_WithEveryMemberSet()
{
var domain = new ReconnectDriver("cluster-beta", "drv-s7-3", "aoperator@zb.local", CorrelationGuid);
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
}
// ---------------------------------------------------------------- AlarmCommand
[Fact]
public void AlarmCommand_RoundTrips_WithEveryMemberSet()
{
var domain = new AlarmCommand(
"alarm-42",
"TimedShelve",
"jdoe@zb.local",
"shelving for maintenance",
new DateTime(2026, 8, 6, 13, 45, 12, 345, DateTimeKind.Utc).AddTicks(6789));
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
}
[Fact]
public void AlarmCommand_RoundTrips_WithNullComment()
{
var domain = new AlarmCommand("alarm-42", "Acknowledge", "jdoe", null, null);
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
back.ShouldBe(domain);
back.Comment.ShouldBeNull();
back.UnshelveAtUtc.ShouldBeNull();
}
[Fact]
public void AlarmCommand_EmptyComment_StaysEmpty_AndIsNotNull()
{
// The trap: proto3 without explicit presence decodes an absent string as "", collapsing
// null and "" onto one value. `optional string comment` keeps them distinct — an operator who
// acknowledged with a deliberately blank comment must not be reported as having supplied none.
var domain = new AlarmCommand("alarm-42", "AddComment", "jdoe", "", null);
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
back.ShouldBe(domain);
back.Comment.ShouldNotBeNull();
back.Comment.ShouldBeEmpty();
}
[Fact]
public void AlarmCommand_UnshelveAt_RoundTripsAsUtc()
{
// DateTime equality compares ticks and ignores Kind, so record equality alone would pass on a
// Local-Kind result whose instant is hours off once anything re-interprets it. Assert Kind.
var when = new DateTime(2026, 8, 6, 13, 45, 12, DateTimeKind.Utc).AddTicks(1234567);
var domain = new AlarmCommand("alarm-42", "TimedShelve", "jdoe", null, when);
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
back.UnshelveAtUtc.ShouldNotBeNull();
back.UnshelveAtUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
back.UnshelveAtUtc.Value.Ticks.ShouldBe(when.Ticks);
}
[Fact]
public void AlarmCommand_UnrecognizedOperation_RoundTripsVerbatim()
{
// Controller decision: `operation` stays a proto string precisely so an operation this build
// does not know travels intact rather than decoding to an enum's UNSPECIFIED and being dropped.
var domain = new AlarmCommand("alarm-42", "SomeFutureOperation", "jdoe", null, null);
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
back.ShouldBe(domain);
back.Operation.ShouldBe("SomeFutureOperation");
}
// ---------------------------------------------------------------- ApplyAck
[Theory]
[InlineData(ApplyAckOutcome.Applied)]
[InlineData(ApplyAckOutcome.Failed)]
public void ApplyAck_RoundTrips_WithEveryMemberSet(ApplyAckOutcome outcome)
{
var domain = new ApplyAck(
new DeploymentId(DeploymentGuid),
new NodeId("site-a-1"),
outcome,
"artifact fetch failed: sha mismatch",
new CorrelationId(CorrelationGuid));
MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain))).ShouldBe(domain);
}
[Fact]
public void ApplyAck_RoundTrips_WithNullFailureReason()
{
var domain = new ApplyAck(
new DeploymentId(DeploymentGuid),
new NodeId("site-a-1"),
ApplyAckOutcome.Applied,
null,
new CorrelationId(CorrelationGuid));
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
back.ShouldBe(domain);
back.FailureReason.ShouldBeNull();
}
[Fact]
public void ApplyAck_EmptyFailureReason_StaysEmpty_AndIsNotNull()
{
var domain = new ApplyAck(
new DeploymentId(DeploymentGuid),
new NodeId("site-a-1"),
ApplyAckOutcome.Failed,
"",
new CorrelationId(CorrelationGuid));
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
back.ShouldBe(domain);
back.FailureReason.ShouldNotBeNull();
back.FailureReason.ShouldBeEmpty();
}
[Fact]
public void ApplyAck_UnspecifiedOutcome_Throws_RatherThanDecodingToAMeaningfulValue()
{
// The single highest-stakes decode in this contract. proto3 hands back 0 for an absent enum
// field, and the deploy path seals on Applied — so 0 must never resolve to Applied (or to
// Failed, which would fail a deployment that actually succeeded). ApplyAckOutcome has no
// Unknown member to land on, so refusal is the only honest answer.
var proto = new ApplyAckMessage
{
DeploymentId = DeploymentGuid.ToString("N"),
NodeId = "site-a-1",
Outcome = ApplyOutcome.Unspecified,
CorrelationId = CorrelationGuid.ToString("N"),
};
var ex = Should.Throw<InvalidOperationException>(() => MeshTransportDtoMapper.FromProto(proto));
ex.Message.ShouldContain(nameof(ApplyOutcome.Unspecified));
}
[Fact]
public void ApplyAck_UnknownOutcomeValue_Throws()
{
// A peer built against a newer contract with a third outcome. Same rule: refuse, do not guess.
var proto = new ApplyAckMessage
{
DeploymentId = DeploymentGuid.ToString("N"),
NodeId = "site-a-1",
Outcome = (ApplyOutcome)99,
CorrelationId = CorrelationGuid.ToString("N"),
};
Should.Throw<InvalidOperationException>(() => MeshTransportDtoMapper.FromProto(proto))
.Message.ShouldContain("99");
}
[Fact]
public void ApplyAck_UnknownClrOutcome_ThrowsOnEncode()
{
var domain = new ApplyAck(
new DeploymentId(DeploymentGuid),
new NodeId("site-a-1"),
(ApplyAckOutcome)99,
null,
new CorrelationId(CorrelationGuid));
Should.Throw<ArgumentOutOfRangeException>(() => MeshTransportDtoMapper.ToProto(domain));
}
// ---------------------------------------------------------------- Envelope
[Fact]
public void Envelope_RoundTrips_DispatchDeployment()
{
var domain = new DispatchDeployment(
new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid));
var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain));
envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.DispatchDeployment);
MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
}
[Fact]
public void Envelope_RoundTrips_RestartDriver()
{
var domain = new RestartDriver("cluster-alpha", "drv-modbus-7", "jdoe", CorrelationGuid);
var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain));
envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.RestartDriver);
MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
}
[Fact]
public void Envelope_RoundTrips_ReconnectDriver()
{
var domain = new ReconnectDriver("cluster-beta", "drv-s7-3", "jdoe", CorrelationGuid);
var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain));
envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.ReconnectDriver);
MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
}
[Fact]
public void Envelope_RoundTrips_AlarmCommand()
{
var domain = new AlarmCommand("alarm-42", "Acknowledge", "jdoe", "ack'd", null);
var envelope = Wire(MeshTransportDtoMapper.ToEnvelope(domain, new CorrelationId(EnvelopeGuid)));
envelope.CommandCase.ShouldBe(CommandEnvelope.CommandOneofCase.AlarmCommand);
envelope.CorrelationId.ShouldBe(EnvelopeGuid.ToString("N"));
MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBe(domain);
}
[Fact]
public void Envelope_SetsBothCorrelationIds_ForTheThreeSelfCorrelatingCommands()
{
// Encode writes envelope and inner correlation from the record's own member, so the two can
// only diverge if a peer produced them apart — see the decode-precedence golden below.
var dispatch = MeshTransportDtoMapper.ToEnvelope(new DispatchDeployment(
new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex), new CorrelationId(CorrelationGuid)));
dispatch.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
dispatch.DispatchDeployment.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
var restart = MeshTransportDtoMapper.ToEnvelope(
new RestartDriver("c", "d", "u", CorrelationGuid));
restart.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
restart.RestartDriver.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
var reconnect = MeshTransportDtoMapper.ToEnvelope(
new ReconnectDriver("c", "d", "u", CorrelationGuid));
reconnect.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
reconnect.ReconnectDriver.CorrelationId.ShouldBe(CorrelationGuid.ToString("N"));
}
[Fact]
public void Envelope_InnerCorrelationWins_OnDecode()
{
// Controller decision: the inner field is the domain member, the envelope's is transport
// metadata. If a peer ever writes them apart, the record must carry what the record declared.
var envelope = Wire(new CommandEnvelope
{
CorrelationId = EnvelopeGuid.ToString("N"),
DispatchDeployment = new DispatchDeploymentCommand
{
DeploymentId = DeploymentGuid.ToString("N"),
RevisionHash = Sha256Hex,
CorrelationId = CorrelationGuid.ToString("N"),
},
});
var decoded = MeshTransportDtoMapper.FromEnvelope(envelope).ShouldBeOfType<DispatchDeployment>();
decoded.CorrelationId.Value.ShouldBe(CorrelationGuid);
decoded.CorrelationId.Value.ShouldNotBe(EnvelopeGuid);
}
[Fact]
public void Envelope_InnerCorrelationWins_OnDecode_ForDriverCommands()
{
var restart = Wire(new CommandEnvelope
{
CorrelationId = EnvelopeGuid.ToString("N"),
RestartDriver = new RestartDriverCommand
{
ClusterId = "c",
DriverInstanceId = "d",
ActorByUserName = "u",
CorrelationId = CorrelationGuid.ToString("N"),
},
});
MeshTransportDtoMapper.FromEnvelope(restart).ShouldBeOfType<RestartDriver>()
.CorrelationId.ShouldBe(CorrelationGuid);
var reconnect = Wire(new CommandEnvelope
{
CorrelationId = EnvelopeGuid.ToString("N"),
ReconnectDriver = new ReconnectDriverCommand
{
ClusterId = "c",
DriverInstanceId = "d",
ActorByUserName = "u",
CorrelationId = CorrelationGuid.ToString("N"),
},
});
MeshTransportDtoMapper.FromEnvelope(reconnect).ShouldBeOfType<ReconnectDriver>()
.CorrelationId.ShouldBe(CorrelationGuid);
}
[Fact]
public void FromEnvelope_UnsetOneof_Throws()
{
// Unreachable while MeshTransportProtoContractTests holds the oneof locked, which is exactly
// why it must throw rather than return null: the alternative is a command that crosses the
// mesh, decodes to nothing and is dropped without a trace.
var ex = Should.Throw<InvalidOperationException>(
() => MeshTransportDtoMapper.FromEnvelope(new CommandEnvelope { CorrelationId = "x" }));
ex.Message.ShouldContain(nameof(CommandEnvelope.CommandOneofCase.None));
}
[Fact]
public void ToEnvelope_UnknownMessageType_Throws()
{
Should.Throw<ArgumentOutOfRangeException>(
() => MeshTransportDtoMapper.ToEnvelope("not a command", new CorrelationId(EnvelopeGuid)));
}
[Fact]
public void EveryHandledOneofCase_DecodesToADomainRecord()
{
// Ties the mapper to the contract-lock list: a fifth oneof case added to the proto and to
// HandledCases without a mapper arm fails here rather than at runtime on the far side.
var samples = new Dictionary<CommandEnvelope.CommandOneofCase, CommandEnvelope>
{
[CommandEnvelope.CommandOneofCase.DispatchDeployment] = MeshTransportDtoMapper.ToEnvelope(
new DispatchDeployment(new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex),
new CorrelationId(CorrelationGuid))),
[CommandEnvelope.CommandOneofCase.RestartDriver] = MeshTransportDtoMapper.ToEnvelope(
new RestartDriver("c", "d", "u", CorrelationGuid)),
[CommandEnvelope.CommandOneofCase.ReconnectDriver] = MeshTransportDtoMapper.ToEnvelope(
new ReconnectDriver("c", "d", "u", CorrelationGuid)),
[CommandEnvelope.CommandOneofCase.AlarmCommand] = MeshTransportDtoMapper.ToEnvelope(
new AlarmCommand("a", "Acknowledge", "u", null, null), new CorrelationId(EnvelopeGuid)),
};
samples.Keys.ShouldBe(MeshTransportProtoContract.HandledCases, ignoreOrder: true);
foreach (var (expectedCase, envelope) in samples)
{
envelope.CommandCase.ShouldBe(expectedCase);
MeshTransportDtoMapper.FromEnvelope(Wire(envelope)).ShouldNotBeNull();
}
}
// ---------------------------------------------------------------- ToEnvelope(object …) dispatch
[Fact]
public void ToEnvelope_ObjectDispatch_MatchesTheTypedOverloads()
{
// The router holds its command as MeshCommand.Message (an object), so the untyped entry point
// must produce byte-identical envelopes to the typed ones it delegates to.
var alarmCorrelation = new CorrelationId(EnvelopeGuid);
object[] commands =
[
new DispatchDeployment(new DeploymentId(DeploymentGuid), new RevisionHash(Sha256Hex),
new CorrelationId(CorrelationGuid)),
new RestartDriver("c", "d", "u", CorrelationGuid),
new ReconnectDriver("c", "d", "u", CorrelationGuid),
new AlarmCommand("a", "Acknowledge", "u", "note", null),
];
CommandEnvelope[] typed =
[
MeshTransportDtoMapper.ToEnvelope((DispatchDeployment)commands[0]),
MeshTransportDtoMapper.ToEnvelope((RestartDriver)commands[1]),
MeshTransportDtoMapper.ToEnvelope((ReconnectDriver)commands[2]),
MeshTransportDtoMapper.ToEnvelope((AlarmCommand)commands[3], alarmCorrelation),
];
for (var i = 0; i < commands.Length; i++)
{
MeshTransportDtoMapper.ToEnvelope(commands[i], alarmCorrelation).ShouldBe(typed[i]);
}
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Protos;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// Contract-lock for the ClusterClient→gRPC command envelope's oneof. If a fifth command is added
/// to <c>mesh_transport.proto</c> without adding a matching entry to
/// <see cref="MeshTransportProtoContract.HandledCases"/>, this test goes red — the single guard
/// that keeps the wire contract and the handled-case set from silently drifting apart. A drift
/// here is exactly the failure the whole transport exists to avoid: a command that crosses the
/// mesh boundary, decodes to a case nobody handles, and is dropped without a trace.
/// </summary>
public class MeshTransportProtoContractTests
{
[Fact]
public void EveryOneofVariant_IsAccountedFor()
{
var variants = System.Enum.GetValues<CommandEnvelope.CommandOneofCase>()
.Where(c => c != CommandEnvelope.CommandOneofCase.None).ToArray();
variants.ShouldBe(MeshTransportProtoContract.HandledCases, ignoreOrder: true);
}
[Fact]
public void The_service_base_and_client_types_generate()
{
// Referencing these types is the whole assertion — GrpcServices="Both" must emit both halves
// of both services: central hosts CentralCommandService and dials NodeCommandService, and a
// driver node does the mirror image, so neither service can ship client-only or server-only.
typeof(NodeCommandService.NodeCommandServiceBase).ShouldNotBeNull();
typeof(NodeCommandService.NodeCommandServiceClient).ShouldNotBeNull();
typeof(CentralCommandService.CentralCommandServiceBase).ShouldNotBeNull();
typeof(CentralCommandService.CentralCommandServiceClient).ShouldNotBeNull();
}
[Fact]
public void The_apply_outcome_enum_reserves_zero_as_unspecified()
{
// proto3 decodes an absent enum field as 0. If 0 meant "Applied", a message that lost the
// field in transit — or was written by a peer that predates it — would report a deployment
// as successfully applied when nothing was said at all. 0 must stay meaningless.
((int)ApplyOutcome.Unspecified).ShouldBe(0);
((int)ApplyOutcome.Applied).ShouldNotBe(0);
((int)ApplyOutcome.Failed).ShouldNotBe(0);
}
}
@@ -0,0 +1,436 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Communication;
/// <summary>
/// ClusterClient→gRPC Phase 0 — central's per-cluster PSK lookup. Central talks to N site
/// clusters, each with its own key, so every outbound dial and every inbound ack verification
/// goes through here.
/// </summary>
/// <remarks>
/// The tests that matter are the adversarial ones: the store being down, the key being absent
/// or blank, and the cache handing back a value the store no longer agrees with. Every one of
/// those must end in "no key" — the caller then denies or skips, never proceeds unauthenticated.
/// </remarks>
public sealed class MeshPskProviderTests
{
private static MeshPskProvider CreateProvider(
ISecretResolver resolver,
Dictionary<string, string>? clusterPsks = null,
ILogger<MeshPskProvider>? logger = null)
{
var options = new MeshTransportOptions();
if (clusterPsks is not null)
{
foreach (var (key, value) in clusterPsks)
options.ClusterPsks[key] = value;
}
return new MeshPskProvider(
Options.Create(options), resolver, logger ?? NullLogger<MeshPskProvider>.Instance);
}
[Fact]
public void ConfigMapEntry_IsReturned_WithoutConsultingTheSecretStore()
{
var resolver = new FakeSecretResolver();
var provider = CreateProvider(
resolver, new Dictionary<string, string> { ["SITE-A"] = "rig-psk-site-a" });
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("rig-psk-site-a");
resolver.CallCount.ShouldBe(0);
}
[Fact]
public void ConfigMapEntry_WinsOverTheSecretStore()
{
// The map exists so a rig can supply literals without standing up a KEK. If the store also
// holds a key for that cluster the map must still win, or a stale store row would silently
// beat the value the operator just set.
var resolver = new FakeSecretResolver { Value = "stored-psk" };
var provider = CreateProvider(
resolver, new Dictionary<string, string> { ["SITE-A"] = "rig-psk-site-a" });
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("rig-psk-site-a");
resolver.CallCount.ShouldBe(0);
}
[Fact]
public void ConfigMapLookup_IsCaseInsensitive()
{
// MeshTransport__ClusterPsks__site-a and __SITE-A are the same cluster. The map's comparer
// (set on MeshTransportOptions) is what makes that true; this pins the pairing.
var resolver = new FakeSecretResolver();
var provider = CreateProvider(
resolver, new Dictionary<string, string> { ["SITE-A"] = "rig-psk-site-a" });
provider.TryGetPsk("site-a", out var psk).ShouldBeTrue();
psk.ShouldBe("rig-psk-site-a");
}
[Fact]
public void BlankConfigMapEntry_FallsThroughToTheSecretStore()
{
// A blank map entry is an empty environment variable, not a decision. Treat it as absent
// rather than as an empty key that an empty bearer would satisfy.
var resolver = new FakeSecretResolver { Value = "stored-psk" };
var provider = CreateProvider(
resolver, new Dictionary<string, string> { ["SITE-A"] = " " });
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("stored-psk");
resolver.CallCount.ShouldBe(1);
}
[Fact]
public void SecretStoreValue_IsReturned_UnderTheConventionalName()
{
var resolver = new FakeSecretResolver { Value = "stored-psk" };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("stored-psk");
// SecretName normalizes to lowercase — the same normalization ${secret:OT-MESH-PSK-SITE-A}
// goes through in the pre-host expander, so both paths address one row.
resolver.LastName.ShouldBe("ot-mesh-psk-site-a");
}
[Fact]
public void UnknownCluster_FailsClosed()
{
// Absent secret + no map entry. The caller denies; it must never fall back to "no auth".
var resolver = new FakeSecretResolver { Value = null };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE-Z", out var psk).ShouldBeFalse();
psk.ShouldBeNull();
}
[Fact]
public void BlankSecretValue_FailsClosed()
{
// A row that decrypts to whitespace is a broken seed, not a key. Accepting it would make
// "Bearer " a valid credential for the whole cluster.
var resolver = new FakeSecretResolver { Value = " " };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE-A", out var psk).ShouldBeFalse();
psk.ShouldBeNull();
}
[Fact]
public void ResolverThrow_FailsClosed()
{
// The store is a local SQLite file with an env KEK; a missing KEK throws rather than
// returning null. Any failure is "no key", never an exception escaping into the gRPC gate.
var resolver = new FakeSecretResolver { Throw = new InvalidOperationException("KEK unavailable") };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE-A", out var psk).ShouldBeFalse();
psk.ShouldBeNull();
}
[Fact]
public void ResolverThrow_IsNotCached_SoRecoveryNeedsNoInvalidate()
{
// Caching a negative would turn a transient store fault into a permanent outage for that
// cluster, recoverable only by a restart or a hook nothing calls yet.
var resolver = new FakeSecretResolver { Throw = new InvalidOperationException("KEK unavailable") };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE-A", out _).ShouldBeFalse();
resolver.Throw = null;
resolver.Value = "stored-psk";
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("stored-psk");
}
[Fact]
public void ResolvedValue_IsCached_SoTheStoreIsHitOnce()
{
var resolver = new FakeSecretResolver { Value = "stored-psk" };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE-A", out _).ShouldBeTrue();
provider.TryGetPsk("SITE-A", out _).ShouldBeTrue();
provider.TryGetPsk("site-a", out _).ShouldBeTrue();
resolver.CallCount.ShouldBe(1);
}
[Fact]
public void Cache_ServesTheStaleValue_UntilInvalidated()
{
// Both directions matter: stale-until-invalidated is the whole reason Invalidate exists, and
// an invalidate that did not actually re-read would be indistinguishable from a no-op.
var resolver = new FakeSecretResolver { Value = "first-psk" };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE-A", out var first).ShouldBeTrue();
first.ShouldBe("first-psk");
resolver.Value = "rotated-psk";
provider.TryGetPsk("SITE-A", out var stale).ShouldBeTrue();
stale.ShouldBe("first-psk");
provider.Invalidate("SITE-A");
provider.TryGetPsk("SITE-A", out var fresh).ShouldBeTrue();
fresh.ShouldBe("rotated-psk");
}
[Fact]
public void Invalidate_IsCaseInsensitive_AndScopedToOneCluster()
{
var resolver = new FakeSecretResolver { Value = "first-psk" };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE-A", out _).ShouldBeTrue();
provider.TryGetPsk("SITE-B", out _).ShouldBeTrue();
resolver.Value = "rotated-psk";
provider.Invalidate("site-a");
provider.TryGetPsk("SITE-A", out var a).ShouldBeTrue();
a.ShouldBe("rotated-psk");
provider.TryGetPsk("SITE-B", out var b).ShouldBeTrue();
b.ShouldBe("first-psk");
}
[Fact]
public void Invalidate_OnACacheMiss_IsANoOp()
{
var resolver = new FakeSecretResolver { Value = "stored-psk" };
var provider = CreateProvider(resolver);
Should.NotThrow(() => provider.Invalidate("never-seen"));
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("stored-psk");
}
[Fact]
public void InvalidateAll_DropsEveryCluster()
{
var resolver = new FakeSecretResolver { Value = "first-psk" };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE-A", out _).ShouldBeTrue();
provider.TryGetPsk("SITE-B", out _).ShouldBeTrue();
resolver.Value = "rotated-psk";
provider.InvalidateAll();
provider.TryGetPsk("SITE-A", out var a).ShouldBeTrue();
provider.TryGetPsk("SITE-B", out var b).ShouldBeTrue();
a.ShouldBe("rotated-psk");
b.ShouldBe("rotated-psk");
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void BlankClusterId_FailsClosed(string clusterId)
{
var resolver = new FakeSecretResolver { Value = "stored-psk" };
var provider = CreateProvider(resolver);
provider.TryGetPsk(clusterId, out var psk).ShouldBeFalse();
psk.ShouldBeNull();
resolver.CallCount.ShouldBe(0);
}
[Fact]
public void ClusterIdThatCannotFormASecretName_FailsClosed()
{
// Cluster ids are operator-authored; SecretName's allow-list is [a-z0-9._/-]. A '$' throws
// out of the SecretName ctor, and that throw must become "no key", not a 500 in the gate.
var resolver = new FakeSecretResolver { Value = "stored-psk" };
var provider = CreateProvider(resolver);
provider.TryGetPsk("SITE$A", out var psk).ShouldBeFalse();
psk.ShouldBeNull();
resolver.CallCount.ShouldBe(0);
}
[Fact(Timeout = 30_000)]
public async Task ConcurrentCallers_ResolveOnce_AndAllSeeTheSameValue()
{
// The gRPC gate calls this from every inbound ack, on arbitrary thread-pool threads. A
// non-atomic fill would stampede the store and could hand two callers different keys.
//
// The shape matters more than the assertion. Callers park BEFORE TryGetPsk and are released
// together, so the race is real rather than accidentally serialized by task scheduling —
// parking them INSIDE the resolver could never work, because the whole point of the Lazy is
// that only one caller ever gets in there. And the resolver's delay RETURNS rather than
// waiting on a gate: a regression that lets a second caller reach the store must drive
// CallCount to 2 (red), never sit on an exhausted semaphore (hang, which reads as a flake).
const int Callers = 16;
using var arrived = new CountdownEvent(Callers);
using var start = new ManualResetEventSlim(false);
var resolver = new FakeSecretResolver { Value = "stored-psk", ResolveDelay = TimeSpan.FromMilliseconds(50) };
var provider = CreateProvider(resolver);
// LongRunning gives each caller a dedicated thread: reaching the arrival gate must not
// depend on thread-pool injection, which is rate-limited and made this flake on a
// contended box (the gate timed out before all 16 pool threads existed).
var callers = Enumerable.Range(0, Callers).Select(_ => Task.Factory.StartNew(() =>
{
arrived.Signal();
start.Wait();
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
return psk;
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)).ToArray();
arrived.Wait(TimeSpan.FromSeconds(10)).ShouldBeTrue("all callers should reach the start gate");
start.Set();
var results = await Task.WhenAll(callers);
results.ShouldAllBe(r => r == "stored-psk");
resolver.CallCount.ShouldBe(1);
}
[Fact]
public void APoisonedCacheEntry_IsEvicted_RatherThanDenyingTheClusterForever()
{
// ResolveFromStore swallows what the store throws, but it LOGS while doing so — and a logger
// provider that throws escapes the factory. Lazy under ExecutionAndPublication caches that
// exception and rethrows it on every later read, so an unguarded entry.Value would deny this
// cluster for the life of the process; nothing calls Invalidate until Phase 1A.
var logger = new ThrowingLogger { ThrowOnLog = true };
var resolver = new FakeSecretResolver { Throw = new InvalidOperationException("KEK unavailable") };
var provider = CreateProvider(resolver, logger: logger);
// The logger throwing must not escape the gate either — a denial, not a 500.
provider.TryGetPsk("SITE-A", out var first).ShouldBeFalse();
first.ShouldBeNull();
resolver.CallCount.ShouldBe(1);
logger.ThrowOnLog = false;
resolver.Throw = null;
resolver.Value = "stored-psk";
provider.TryGetPsk("SITE-A", out var second).ShouldBeTrue();
second.ShouldBe("stored-psk");
resolver.CallCount.ShouldBe(2); // reached the store again — the entry was not poisoned
}
[Fact]
public void ConfigMapLookup_StaysCaseInsensitive_ThroughRealConfigurationBinding()
{
// Writing into the options object pins only the field initializer's comparer. What reaches
// production is a BOUND instance, and the binder is free to construct its own Dictionary and
// drop the comparer silently — the symptom being a rig lookup that misses on a ClusterId
// spelled the way the DB spells it. Bind for real, then go through the PROVIDER.
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeshTransport:ClusterPsks:SITE-A"] = "rig-psk-site-a",
})
.Build();
var options = new MeshTransportOptions();
configuration.GetSection(MeshTransportOptions.SectionName).Bind(options);
var resolver = new FakeSecretResolver();
var provider = new MeshPskProvider(
Options.Create(options), resolver, NullLogger<MeshPskProvider>.Instance);
provider.TryGetPsk("site-a", out var psk).ShouldBeTrue();
psk.ShouldBe("rig-psk-site-a");
resolver.CallCount.ShouldBe(0);
}
/// <summary>
/// An <see cref="ISecretResolver"/> whose answer the test controls between calls, so cache
/// staleness and invalidation are observable without a real store.
/// </summary>
private sealed class FakeSecretResolver : ISecretResolver
{
private int _callCount;
/// <summary>What <see cref="GetAsync"/> returns when <see cref="Throw"/> is null.</summary>
public string? Value { get; set; }
/// <summary>When set, <see cref="GetAsync"/> throws it instead of returning.</summary>
public Exception? Throw { get; set; }
/// <summary>
/// How long each call takes before answering. Widens the window a concurrent stampede
/// would have to exploit — and it RETURNS, so a second entrant shows up as a second call
/// rather than as a hang.
/// </summary>
public TimeSpan ResolveDelay { get; set; } = TimeSpan.Zero;
/// <summary>How many times the store was consulted.</summary>
public int CallCount => Volatile.Read(ref _callCount);
/// <summary>The name of the most recently requested secret.</summary>
public string? LastName { get; private set; }
public async Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
LastName = name.Value;
Interlocked.Increment(ref _callCount);
if (ResolveDelay > TimeSpan.Zero)
await Task.Delay(ResolveDelay, ct).ConfigureAwait(false);
if (Throw is not null)
throw Throw;
return Value;
}
}
/// <summary>
/// An <see cref="ILogger{T}"/> that can be made to throw, standing in for a logger provider
/// that faults — the only way the provider's own catch blocks can throw.
/// </summary>
private sealed class ThrowingLogger : ILogger<MeshPskProvider>
{
/// <summary>When true, every <see cref="Log"/> call throws.</summary>
public bool ThrowOnLog { get; set; }
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (ThrowOnLog)
throw new InvalidOperationException("logger provider faulted");
}
}
}
@@ -0,0 +1,287 @@
using System.Diagnostics.CodeAnalysis;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// ClusterClient→gRPC Phase 0 — the central-side ack endpoint's inbound gate. Mapping
/// <c>CentralCommandService</c> onto the pipeline is a Phase 1 task; this test covers only the
/// interceptor's own authorization behavior.
/// </summary>
/// <remarks>
/// Differs from <see cref="MeshCommandAuthInterceptor"/> in the one way central differs from a
/// node: there is no single expected key. The caller names its cluster in
/// <c>x-otopcua-cluster</c>, the bearer is verified against <i>that</i> cluster's PSK, and a
/// cluster with no resolvable key is denied — so a caller cannot pick a cluster to be graded
/// against, only claim one it must then prove.
/// </remarks>
public sealed class CentralCommandAuthInterceptorTests
{
private const string ReportApplyMethod = "/mesh_transport.v1.CentralCommandService/ReportApply";
private const string OtherMethod = "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
private static CentralCommandAuthInterceptor CreateInterceptor(params (string Cluster, string Psk)[] keys)
=> new(new FakeMeshPskProvider(keys), NullLogger<CentralCommandAuthInterceptor>.Instance);
private static ServerCallContext CreateContext(
string method, string? authorizationHeader, string? clusterHeader)
=> CreateContext(
method,
authorizationHeader is null ? [] : [authorizationHeader],
clusterHeader);
private static ServerCallContext CreateContext(
string method, string[] authorizationHeaders, string? clusterHeader)
{
var headers = new Metadata();
foreach (var value in authorizationHeaders)
headers.Add("authorization", value);
if (clusterHeader is not null)
headers.Add("x-otopcua-cluster", clusterHeader);
return new FakeServerCallContext(method, headers);
}
/// <summary>Invokes the interceptor's unary path (how <c>ReportApply</c> actually runs).</summary>
private static Task Invoke(CentralCommandAuthInterceptor interceptor, ServerCallContext context)
=> interceptor.UnaryServerHandler<string, string>(
"request", context, (_, _) => Task.FromResult("ok"));
[Fact]
public async Task NonCentralCommandMethod_PassesThrough_EvenWithNoKeysAtAll()
{
// The interceptor shares the AddGrpc pipeline with config-serve (and, on a fused node, sync
// and telemetry), so it sees every call. It must be scoped strictly to its own service.
var interceptor = CreateInterceptor();
var context = CreateContext(OtherMethod, authorizationHeader: null, clusterHeader: null);
await Invoke(interceptor, context); // no throw
}
[Fact]
public async Task ReportApply_WithCorrectBearerForItsCluster_PassesThrough()
{
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-A");
await Invoke(interceptor, context); // no throw
}
[Fact]
public async Task ReportApply_WithNoClusterHeader_IsDenied()
{
// Without the cluster there is no key to grade against. Falling back to "try them all" would
// make one compromised site's key valid for forging every other site's acks.
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-a", clusterHeader: null);
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ReportApply_WithBlankClusterHeader_IsDenied()
{
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-a", " ");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ReportApply_ForAClusterWithNoResolvableKey_IsDenied()
{
// Fail-closed on the provider's "no key" — an unseeded or unreachable key must cost the ack,
// never authentication.
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, "Bearer anything-at-all", "SITE-Z");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ReportApply_WithAnotherClustersBearer_IsDenied()
{
// The isolation proof: SITE-B's key does not authenticate a call claiming to be SITE-A.
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"), ("SITE-B", "psk-site-b"));
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-b", "SITE-A");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ReportApply_WithNoBearerToken_IsDenied()
{
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, authorizationHeader: null, clusterHeader: "SITE-A");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ReportApply_WithMalformedAuthorizationHeader_IsDenied()
{
// No "Bearer " prefix at all — must be treated the same as a missing header.
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, "psk-site-a", "SITE-A");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ReportApply_TokenComparison_IsNotAPrefixMatch()
{
// A prefix/StartsWith comparison would accept a truncated key and make the secret
// recoverable one character at a time.
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, "Bearer psk-site-", "SITE-A");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ReportApply_WithAJunkAuthorizationHeaderBeforeTheRealOne_IsDenied()
{
// First entry wins, and it must parse. Scanning past a non-Bearer header for a later one
// would let a caller smuggle its credential behind a header an intermediary may have
// inspected or rewritten, and would make WHICH credential is checked depend on ordering
// nothing guarantees. Pinned identically on MeshCommandAuthInterceptor.
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(
ReportApplyMethod, ["junk", "Bearer psk-site-a"], "SITE-A");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task EveryDenialArm_ReturnsAnIdenticalDetail()
{
// The anti-enumeration mechanism IS this identity: the caller supplies the cluster id, so a
// reply distinguishing "no key for that cluster" from "wrong key" would let an
// unauthenticated caller read central's cluster set off the wire. Asserting only StatusCode
// would let a well-meaning "more helpful message" edit break it silently.
// (Content-level only — the class remark records the timing oracle that survives until the
// Phase 1A known-cluster-set gate.)
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var noHeader = await Should.ThrowAsync<RpcException>(() => Invoke(
interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", clusterHeader: null)));
var unknownCluster = await Should.ThrowAsync<RpcException>(() => Invoke(
interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-Z")));
var wrongToken = await Should.ThrowAsync<RpcException>(() => Invoke(
interceptor, CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A")));
var noToken = await Should.ThrowAsync<RpcException>(() => Invoke(
interceptor, CreateContext(ReportApplyMethod, authorizationHeader: null, clusterHeader: "SITE-A")));
unknownCluster.Status.Detail.ShouldBe(noHeader.Status.Detail);
wrongToken.Status.Detail.ShouldBe(noHeader.Status.Detail);
noToken.Status.Detail.ShouldBe(noHeader.Status.Detail);
noHeader.Status.Detail.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public async Task ServerStreamingPath_IsAlsoGated()
{
// ReportApply is unary today, but gating only that path would leave any future streaming
// method on the same service open. Gate every handler shape.
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A");
var ex = await Should.ThrowAsync<RpcException>(() =>
interceptor.ServerStreamingServerHandler<string, string>(
"request", responseStream: null!, context, (_, _, _) => Task.CompletedTask));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ClientStreamingPath_IsAlsoGated()
{
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A");
var ex = await Should.ThrowAsync<RpcException>(() =>
interceptor.ClientStreamingServerHandler<string, string>(
requestStream: null!, context, (_, _) => Task.FromResult("ok")));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task DuplexStreamingPath_IsAlsoGated()
{
var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"));
var context = CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A");
var ex = await Should.ThrowAsync<RpcException>(() =>
interceptor.DuplexStreamingServerHandler<string, string>(
requestStream: null!, responseStream: null!, context, (_, _, _) => Task.CompletedTask));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public void HasExactlyOnePublicConstructor()
{
// Grpc.AspNetCore silently stops invoking an interceptor with more than one public ctor —
// this pins the shape so a well-meaning overload doesn't quietly disable auth in production.
var publicCtors = typeof(CentralCommandAuthInterceptor).GetConstructors();
publicCtors.Length.ShouldBe(1);
}
/// <summary>An <see cref="IMeshPskProvider"/> over a fixed key set; unknown clusters fail closed.</summary>
private sealed class FakeMeshPskProvider(params (string Cluster, string Psk)[] keys) : IMeshPskProvider
{
private readonly Dictionary<string, string> _keys =
keys.ToDictionary(k => k.Cluster, k => k.Psk, StringComparer.OrdinalIgnoreCase);
public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk)
=> _keys.TryGetValue(clusterId, out psk);
public void Invalidate(string clusterId) { }
public void InvalidateAll() { }
}
/// <summary>
/// Minimal <see cref="ServerCallContext"/> carrying just a method name and request headers —
/// the only two things the interceptor reads. Hand-rolled because
/// <c>Grpc.Core.Testing.TestServerCallContext</c> ships only in the retired native package.
/// </summary>
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
: ServerCallContext
{
protected override string MethodCore => method;
protected override string HostCore => "localhost";
protected override string PeerCore => "ipv4:127.0.0.1:12345";
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
protected override Metadata RequestHeadersCore => requestHeaders;
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
protected override Metadata ResponseTrailersCore { get; } = [];
protected override Status StatusCore { get; set; }
protected override WriteOptions? WriteOptionsCore { get; set; }
protected override AuthContext AuthContextCore { get; } =
new(null, new Dictionary<string, List<AuthProperty>>());
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
=> throw new NotSupportedException();
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
=> Task.CompletedTask;
}
}
@@ -0,0 +1,244 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// ClusterClient→gRPC Phase 0 — the node-side command endpoint's inbound gate. Mapping
/// <c>NodeCommandService</c> onto the pipeline is a Phase 1 task; this test covers only the
/// interceptor's own authorization behavior.
/// </summary>
/// <remarks>
/// Same contract as <see cref="TelemetryStreamAuthInterceptor"/>, scoped to
/// <c>/mesh_transport.v1.NodeCommandService/</c> — fail-closed on an unconfigured key,
/// constant-time comparison, gated on every handler shape so scoping holds regardless of RPC
/// shape. Deliberately NO <c>x-otopcua-cluster</c> requirement: that header is a central-side
/// routing hint, and requiring it here would gate on a value the caller chooses.
/// </remarks>
public sealed class MeshCommandAuthInterceptorTests
{
private const string ExecuteMethod = "/mesh_transport.v1.NodeCommandService/Execute";
private const string OtherMethod = "/telemetry.v1.TelemetryStreamService/Subscribe";
private static MeshCommandAuthInterceptor CreateInterceptor(string? psk)
=> new(
Options.Create(new MeshTransportOptions { GrpcPsk = psk }),
NullLogger<MeshCommandAuthInterceptor>.Instance);
private static ServerCallContext CreateContext(string method, string? authorizationHeader)
=> CreateContext(method, authorizationHeader is null ? [] : [authorizationHeader]);
private static ServerCallContext CreateContext(string method, string[] authorizationHeaders)
{
var headers = new Metadata();
foreach (var value in authorizationHeaders)
headers.Add("authorization", value);
return new FakeServerCallContext(method, headers);
}
/// <summary>Invokes the interceptor's unary path (how <c>Execute</c> actually runs).</summary>
private static Task Invoke(MeshCommandAuthInterceptor interceptor, ServerCallContext context)
=> interceptor.UnaryServerHandler<string, string>(
"request", context, (_, _) => Task.FromResult("ok"));
[Fact]
public async Task NonMeshCommandMethod_PassesThrough_EvenWithNoPskConfigured()
{
// The interceptor shares the AddGrpc pipeline with the sync / config-serve / telemetry
// services, so it sees every call. It must be scoped strictly to the node command service.
var interceptor = CreateInterceptor(psk: null);
var context = CreateContext(OtherMethod, authorizationHeader: null);
await Invoke(interceptor, context); // no throw
}
[Fact]
public async Task ExecuteMethod_WithNoPskConfigured_IsDenied_EvenWithABearerToken()
{
// Fail-closed. "No PSK configured" is the DEFAULT — treating it as "no auth required" would
// let anything that can reach the port drive RestartDriver / AlarmCommand / deployment
// dispatch on this node. A token must not help.
var interceptor = CreateInterceptor(psk: null);
var context = CreateContext(ExecuteMethod, "Bearer anything-at-all");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithBlankPskConfigured_IsDenied()
{
// Blank is distinct from unset in the options (GrpcPsk is nullable on purpose) but must be
// treated identically here: an empty expected token would be satisfied by an empty bearer.
var interceptor = CreateInterceptor(" ");
var context = CreateContext(ExecuteMethod, "Bearer ");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithNoBearerToken_IsDenied()
{
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, authorizationHeader: null);
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithMalformedAuthorizationHeader_IsDenied()
{
// No "Bearer " prefix at all — must be treated the same as a missing header.
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "the-cluster-psk");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithWrongBearerToken_IsDenied()
{
// This is also the isolation proof: a node holding SITE-A's PSK rejects a caller presenting
// SITE-B's. Nothing about the cluster id is consulted — only whether the key matches.
var interceptor = CreateInterceptor("dev-mesh-psk-SITE-A");
var context = CreateContext(ExecuteMethod, "Bearer dev-mesh-psk-SITE-B");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithCorrectBearerToken_PassesThrough()
{
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-cluster-psk");
await Invoke(interceptor, context); // no throw
}
[Fact]
public async Task ExecuteMethod_WithCorrectBearerToken_AndNoClusterHeader_PassesThrough()
{
// x-otopcua-cluster is a central-side routing hint. Requiring it here would gate on a value
// the caller supplies about itself, which proves nothing the PSK has not already proven.
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-cluster-psk");
await Invoke(interceptor, context); // no throw
}
[Fact]
public async Task ExecuteMethod_WithAJunkAuthorizationHeaderBeforeTheRealOne_IsDenied()
{
// First entry wins, and it must parse. This interceptor originally scanned past a non-Bearer
// header for a later Bearer one while its central-side clone took the first entry, so the
// two clones gave OPPOSITE answers to this exact request. Both now share
// GrpcRequestHeaders and both deny it; pinned identically on CentralCommandAuthInterceptor.
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, ["junk", "Bearer the-cluster-psk"]);
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_TokenComparison_IsNotAPrefixMatch()
{
// A prefix/StartsWith comparison would accept a truncated key and make the secret
// recoverable one character at a time.
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-cluster-ps");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ServerStreamingPath_IsAlsoGated()
{
// Execute is unary today, but gating only that path would leave any future streaming method
// on the same service open. Gate every handler shape.
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-wrong-psk");
var ex = await Should.ThrowAsync<RpcException>(() =>
interceptor.ServerStreamingServerHandler<string, string>(
"request", responseStream: null!, context, (_, _, _) => Task.CompletedTask));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ClientStreamingPath_IsAlsoGated()
{
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-wrong-psk");
var ex = await Should.ThrowAsync<RpcException>(() =>
interceptor.ClientStreamingServerHandler<string, string>(
requestStream: null!, context, (_, _) => Task.FromResult("ok")));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task DuplexStreamingPath_IsAlsoGated()
{
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-wrong-psk");
var ex = await Should.ThrowAsync<RpcException>(() =>
interceptor.DuplexStreamingServerHandler<string, string>(
requestStream: null!, responseStream: null!, context, (_, _, _) => Task.CompletedTask));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public void HasExactlyOnePublicConstructor()
{
// Grpc.AspNetCore silently stops invoking an interceptor with more than one public ctor —
// this pins the shape so a well-meaning overload doesn't quietly disable auth in production.
var publicCtors = typeof(MeshCommandAuthInterceptor).GetConstructors();
publicCtors.Length.ShouldBe(1);
}
/// <summary>
/// Minimal <see cref="ServerCallContext"/> carrying just a method name and request headers —
/// the only two things the interceptor reads. Hand-rolled because
/// <c>Grpc.Core.Testing.TestServerCallContext</c> ships only in the retired native package.
/// </summary>
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
: ServerCallContext
{
protected override string MethodCore => method;
protected override string HostCore => "localhost";
protected override string PeerCore => "ipv4:127.0.0.1:12345";
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
protected override Metadata RequestHeadersCore => requestHeaders;
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
protected override Metadata ResponseTrailersCore { get; } = [];
protected override Status StatusCore { get; set; }
protected override WriteOptions? WriteOptionsCore { get; set; }
protected override AuthContext AuthContextCore { get; } =
new(null, new Dictionary<string, List<AuthProperty>>());
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
=> throw new NotSupportedException();
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
=> Task.CompletedTask;
}
}