Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa955556d | |||
| 6ef8c7d70a | |||
| 228ff8b428 | |||
| 2ee84af1c0 | |||
| f1ad967083 | |||
| 654df8abc2 | |||
| c8e2f4da02 |
@@ -138,7 +138,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
||||
- **All clusters share ONE ActorSystem name**, `"scadabridge"` — hardcoded at `AkkaHostedService.cs:191`. Central and each site are separate clusters *only* by seed-node partitioning. This is required, not incidental: Akka.Remote address matching means a ClusterClient could not reach a differently-named system.
|
||||
- **`ActiveNodeEvaluator.SelfIsOldestUp` is THE single definition of "active node"** (`Communication/ClusterState/ActiveNodeEvaluator.cs`) — the **oldest Up member** in a role scope, and explicitly **never `cluster.State.Leader`**: leadership (lowest address) is an Akka-internal concept that diverges from singleton placement permanently once the original first node restarts and rejoins, and both sides claim it during a partition. The equivalence *oldest-Up == where `ClusterSingletonManager` places singletons* **is** the design. `ClusterActivityEvaluator.SelfIsOldest`, the S&F delivery gate, `/health/active` and the heartbeat `IsActive` stamp all delegate here.
|
||||
- Site nodes carry **two Akka roles**: the base `Site` plus a site-specific `site-{SiteId}` (`BuildRoles`, `AkkaHostedService.cs:386`). Singletons scope to the **site-specific** role.
|
||||
- **Neither inter-cluster transport is authenticated or encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths`; the gRPC listener is **h2c** and `LocalDbSyncAuthInterceptor` gates *only* `/localdb_sync.v1.LocalDbSync/`, so the whole `SiteStreamService` surface — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — is callable by anyone who can reach :8083. The boundary assumes a trusted network. (The HTTP fetch token and the LocalDb sync interceptor are the only authenticated pieces.)
|
||||
- **The gRPC boundary is authenticated (PSK) as of 2026-07-22; Akka remoting still is not, and nothing is encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths` — so the ClusterClient command/control path remains open to anyone who can reach the remoting port, and the boundary still assumes a trusted network. The gRPC listener stays **h2c**, but `SiteStreamService` is no longer open: `ControlPlaneAuthInterceptor` (`Host/ControlPlaneAuthInterceptor.cs`) gates `/sitestream.SiteStreamService/` — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — against a **per-site preshared key**, fail-closed, constant-time compared, alongside the separate `LocalDbSyncAuthInterceptor` on `/localdb_sync.v1.LocalDbSync/` with its own separate key. **Site side:** `ScadaBridge:Communication:GrpcPsk`, in production `${secret:SB-GRPC-PSK-<siteId>}`, and **`StartupValidator` refuses to boot a site node without it** (an unset key would leave the node healthy-looking but serving nothing). **Central side:** `SitePskProvider` resolves `SB-GRPC-PSK-{siteId}` from the secrets store at channel-build time (sites are added at runtime, so no boot-time expansion is possible), with `ScadaBridge:Communication:SitePsks:{siteId}` as an override for hosts running without a master key — the docker rig uses the latter. One key per site, never fleet-wide. A bearer token over h2c is readable and replayable on-path; TLS is the follow-on hardening and needs no change to this design. Introduced by Phase 0 of the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`).
|
||||
- **Akka frame size is the default 128 KB with `log-frame-size-exceeding` off**, and no custom serializer is configured — so payload carried over ClusterClient is JSON-escaped a second time by the default Newtonsoft serializer, roughly doubling it. Over the limit the transport drops **that one message** without tearing down the association (heartbeats keep flowing, the site still reports healthy) and the central Ask simply times out. See `docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`; `DeployArtifactsCommand` still carries payload and remains exposed.
|
||||
- gRPC streaming channel — **note the direction is inverted from the data flow**: data moves site→central, but each **site node hosts the gRPC server** (`SiteStreamGrpcServer`, Kestrel h2c, port 8083, mapped **only in the Site branch** of `Program.cs`) and **central is the client**, dialling in. There is **no gRPC server on central at all**, which is why the two `Ingest*` unary RPCs — documented as a "central-side ingest surface" — are dead in practice (acknowledged in `AkkaHostedService.cs:510-519`); sites reach central over ClusterClient instead. Central creates per-site `SiteStreamGrpcClient` via `SiteStreamGrpcClientFactory`, keyed **`(siteId, endpoint)`** — the key was widened from site-only to fix an arch-review High where one session's NodeA→NodeB flip disposed a channel another session was still using. Site entity has GrpcNodeAAddress/GrpcNodeBAddress fields. Proto: `sitestream.proto`, **6 RPCs** (2 server-streaming: `SubscribeInstance`, `SubscribeSite` (site-wide, alarm-only); 4 unary: `IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents`, `PullSiteCalls`), `SiteStreamEvent` (oneof: AttributeValueUpdate, AlarmStateUpdate). Field numbers are never reused; evolution is additive only (`AlarmStateUpdate` grew 7→23 fields for the native-alarm mirror). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle. DebugStreamEvent message removed (no longer flows through ClusterClient).
|
||||
- Native alarms: a read-only mirror of native alarms from OPC UA Alarms & Conditions servers and the MxAccess Gateway, unified onto an A&C-style condition model (`AlarmConditionState`: orthogonal Active/Acked/Confirmed/Shelved/Suppressed + 0–1000 severity) plus an `AlarmKind` discriminator (Computed/NativeOpcUa/NativeMxAccess). New DCL capability seam `IAlarmSubscribableConnection` (implemented by the OPC UA and MxGateway adapters); the `DataConnectionActor` opens ONE alarm feed per connection and routes transitions to instances by source-object reference. A `NativeAlarmActor` (peer to the computed `AlarmActor` under `InstanceActor`) mirrors one source binding: snapshot atomic-swap on (re)subscribe, retention (drops once inactive+acked), per-source cap, and site SQLite persistence (`native_alarm_state`, survives failover, cleared on redeploy/undeploy — mirrors static overrides). State streams to central over the additively-enriched gRPC `AlarmStateUpdate` (the existing computed `AlarmStateChanged` was enriched additively) and seeds via the DebugView snapshot. Authoring: `TemplateNativeAlarmSource` / `InstanceNativeAlarmSourceOverride` entities flatten to `ResolvedNativeAlarmSource` (inherit/compose/override); management commands + ManagementActor handlers + CLI (`template/instance native-alarm-source`) + Central UI (template editor tab + instance override panel) + enriched DebugView alarm table. Read-only — no ack-back; no central tables.
|
||||
|
||||
@@ -16,6 +16,10 @@ services:
|
||||
# pepper per the "different per environment" guidance; real deployments inject a
|
||||
# true secret out-of-band, never from source control. Both Central nodes share it.
|
||||
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-env2-cluster-0001"
|
||||
# DEV-ONLY gRPC control-plane preshared key for site-x — NOT a real secret.
|
||||
# Must match ScadaBridge:Communication:GrpcPsk in site-x-node-*/appsettings.Site.json.
|
||||
# Production seeds SB-GRPC-PSK-<siteId> into the secret store instead.
|
||||
ScadaBridge__Communication__SitePsks__site-x: "dev-grpc-psk-docker-env2-site-x"
|
||||
ports:
|
||||
- "9101:5000" # Web UI + Inbound API
|
||||
- "9111:8081" # Akka remoting
|
||||
@@ -43,6 +47,10 @@ services:
|
||||
# pepper per the "different per environment" guidance; real deployments inject a
|
||||
# true secret out-of-band, never from source control. Both Central nodes share it.
|
||||
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-env2-cluster-0001"
|
||||
# DEV-ONLY gRPC control-plane preshared key for site-x — NOT a real secret.
|
||||
# Must match ScadaBridge:Communication:GrpcPsk in site-x-node-*/appsettings.Site.json.
|
||||
# Production seeds SB-GRPC-PSK-<siteId> into the secret store instead.
|
||||
ScadaBridge__Communication__SitePsks__site-x: "dev-grpc-psk-docker-env2-site-x"
|
||||
ports:
|
||||
- "9102:5000" # Web UI + Inbound API
|
||||
- "9112:8081" # Akka remoting
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
||||
},
|
||||
"Communication": {
|
||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||
// ScadaBridge__Communication__SitePsks__<siteId> (docker-compose.yml).
|
||||
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. Without it the
|
||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||
// refuse every SiteStream call while the node still looked healthy.
|
||||
"GrpcPsk": "dev-grpc-psk-docker-env2-site-x",
|
||||
"CentralContactPoints": [
|
||||
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081",
|
||||
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081"
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
||||
},
|
||||
"Communication": {
|
||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||
// ScadaBridge__Communication__SitePsks__<siteId> (docker-compose.yml).
|
||||
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. Without it the
|
||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||
// refuse every SiteStream call while the node still looked healthy.
|
||||
"GrpcPsk": "dev-grpc-psk-docker-env2-site-x",
|
||||
"CentralContactPoints": [
|
||||
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081",
|
||||
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081"
|
||||
|
||||
@@ -120,6 +120,31 @@ docker/
|
||||
└── logs/
|
||||
```
|
||||
|
||||
## gRPC control-plane keys (dev)
|
||||
|
||||
The site gRPC service (`SiteStreamService` on 8083 — live subscriptions, audit pull,
|
||||
cached-telemetry ingest) is gated by a preshared key, and the gate is **fail-closed**: a site node
|
||||
with no key refuses every call, and `StartupValidator` refuses to boot it at all. So the rig
|
||||
carries dev keys, one per site:
|
||||
|
||||
| Where | Setting | Value |
|
||||
|---|---|---|
|
||||
| `site-{a,b,c}-node-*/appsettings.Site.json` | `ScadaBridge:Communication:GrpcPsk` | `dev-grpc-psk-docker-site-{a,b,c}` |
|
||||
| `docker-compose.yml`, both central nodes | `ScadaBridge__Communication__SitePsks__site-{a,b,c}` | same value |
|
||||
|
||||
Both nodes of a pair carry the same key; each site's key is different from the others'. The
|
||||
central half lives in compose env rather than the mounted `appsettings.Central.json`, which by
|
||||
convention holds no plaintext credentials. Production uses `${secret:SB-GRPC-PSK-<siteId>}` on
|
||||
the site and the matching secret in central's store — see
|
||||
[`docs/deployment/topology-guide.md`](../docs/deployment/topology-guide.md).
|
||||
|
||||
**These are not real secrets and are committed deliberately**, exactly like the LocalDb sync key
|
||||
(`dev-site-a-localdb-sync-key`) beside them. The two are separate keys on purpose: the LocalDb one
|
||||
authenticates the *pair partner* for database replication, not central.
|
||||
|
||||
If you add a site to the rig, add its key in both places or its streams will fail with
|
||||
`PermissionDenied`.
|
||||
|
||||
## Commands
|
||||
|
||||
### Initial Setup
|
||||
@@ -351,3 +376,10 @@ docker start scadabridge-site-a-a
|
||||
Same pattern applies for site-b (`scadabridge-site-b-a`/`scadabridge-site-b-b`) and site-c (`scadabridge-site-c-a`/`scadabridge-site-c-b`).
|
||||
|
||||
Failover takes approximately 25 seconds (2s heartbeat + 10s detection threshold + 15s stable-after for split-brain resolver).
|
||||
|
||||
**Manual site failover from the UI (admin-only).** Each site card on `/monitoring/health` carries the same **Trigger failover** button as the central card. Central and each site are separate Akka clusters, so this is a *request* relayed over the ClusterClient command/control channel — the site's own communication actor performs the graceful `Leave` against its `site-{SiteId}` role and acks the result.
|
||||
|
||||
- Unlike central failover, this does **not** disconnect your page — a site is a different cluster.
|
||||
- A refusal from the site (no standby, or a command addressed to a different site) reads differently from an unreachable site (Ask timeout); the UI shows the site's own reason. Only the timeout leaves any doubt about whether the failover took effect.
|
||||
- A site running an older binary has no handler for the command, so it dead-letters and you see "site did not respond".
|
||||
- Each invocation writes a `Cluster` / `ManualFailover` audit row stamped with the site id.
|
||||
|
||||
@@ -27,6 +27,15 @@ services:
|
||||
ScadaBridge__Database__MachineDataDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
ScadaBridge__Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
ScadaBridge__Security__JwtSigningKey: "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long"
|
||||
# DEV-ONLY gRPC control-plane preshared keys, one per site — NOT real secrets.
|
||||
# Central verifies/presents these; each site node carries the same value as
|
||||
# ScadaBridge:Communication:GrpcPsk in its mounted appsettings.Site.json. Kept as
|
||||
# env overrides (not in the mounted central appsettings) so that file stays free of
|
||||
# plaintext credentials. Production instead seeds SB-GRPC-PSK-<siteId> into the
|
||||
# secret store, which is also the only source that can serve a site added at runtime.
|
||||
ScadaBridge__Communication__SitePsks__site-a: "dev-grpc-psk-docker-site-a"
|
||||
ScadaBridge__Communication__SitePsks__site-b: "dev-grpc-psk-docker-site-b"
|
||||
ScadaBridge__Communication__SitePsks__site-c: "dev-grpc-psk-docker-site-c"
|
||||
ports:
|
||||
- "9001:5000" # Web UI + Inbound API
|
||||
- "9011:8081" # Akka remoting (host access for CLI/debugging)
|
||||
@@ -65,6 +74,15 @@ services:
|
||||
ScadaBridge__Database__MachineDataDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||
ScadaBridge__Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
ScadaBridge__Security__JwtSigningKey: "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long"
|
||||
# DEV-ONLY gRPC control-plane preshared keys, one per site — NOT real secrets.
|
||||
# Central verifies/presents these; each site node carries the same value as
|
||||
# ScadaBridge:Communication:GrpcPsk in its mounted appsettings.Site.json. Kept as
|
||||
# env overrides (not in the mounted central appsettings) so that file stays free of
|
||||
# plaintext credentials. Production instead seeds SB-GRPC-PSK-<siteId> into the
|
||||
# secret store, which is also the only source that can serve a site added at runtime.
|
||||
ScadaBridge__Communication__SitePsks__site-a: "dev-grpc-psk-docker-site-a"
|
||||
ScadaBridge__Communication__SitePsks__site-b: "dev-grpc-psk-docker-site-b"
|
||||
ScadaBridge__Communication__SitePsks__site-c: "dev-grpc-psk-docker-site-c"
|
||||
ports:
|
||||
- "9002:5000" # Web UI + Inbound API
|
||||
- "9012:8081" # Akka remoting
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
||||
},
|
||||
"Communication": {
|
||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||
// ScadaBridge__Communication__SitePsks__<siteId> (docker-compose.yml).
|
||||
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. Without it the
|
||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||
// refuse every SiteStream call while the node still looked healthy.
|
||||
"GrpcPsk": "dev-grpc-psk-docker-site-a",
|
||||
"CentralContactPoints": [
|
||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
||||
},
|
||||
"Communication": {
|
||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||
// ScadaBridge__Communication__SitePsks__<siteId> (docker-compose.yml).
|
||||
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. Without it the
|
||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||
// refuse every SiteStream call while the node still looked healthy.
|
||||
"GrpcPsk": "dev-grpc-psk-docker-site-a",
|
||||
"CentralContactPoints": [
|
||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
||||
},
|
||||
"Communication": {
|
||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||
// ScadaBridge__Communication__SitePsks__<siteId> (docker-compose.yml).
|
||||
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. Without it the
|
||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||
// refuse every SiteStream call while the node still looked healthy.
|
||||
"GrpcPsk": "dev-grpc-psk-docker-site-b",
|
||||
"CentralContactPoints": [
|
||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
||||
},
|
||||
"Communication": {
|
||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||
// ScadaBridge__Communication__SitePsks__<siteId> (docker-compose.yml).
|
||||
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. Without it the
|
||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||
// refuse every SiteStream call while the node still looked healthy.
|
||||
"GrpcPsk": "dev-grpc-psk-docker-site-b",
|
||||
"CentralContactPoints": [
|
||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
||||
},
|
||||
"Communication": {
|
||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||
// ScadaBridge__Communication__SitePsks__<siteId> (docker-compose.yml).
|
||||
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. Without it the
|
||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||
// refuse every SiteStream call while the node still looked healthy.
|
||||
"GrpcPsk": "dev-grpc-psk-docker-site-c",
|
||||
"CentralContactPoints": [
|
||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
||||
},
|
||||
"Communication": {
|
||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||
// ScadaBridge__Communication__SitePsks__<siteId> (docker-compose.yml).
|
||||
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. Without it the
|
||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||
// refuse every SiteStream call while the node still looked healthy.
|
||||
"GrpcPsk": "dev-grpc-psk-docker-site-c",
|
||||
"CentralContactPoints": [
|
||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
||||
|
||||
@@ -30,7 +30,13 @@ The transports are independent. A gRPC stream interruption does not affect in-fl
|
||||
|
||||
**The gRPC dial direction is inverted from its data direction.** Values flow site → central, but each **site node hosts the gRPC server** and **central is the client**. `MapGrpcService<SiteStreamGrpcServer>()` appears exactly once in the tree, inside the Site branch of `Program.cs` (`Host/Program.cs:542`); there is **no gRPC server on a central node at all**. That is why the two `Ingest*` unary RPCs — nominally a central-side ingest surface — are dead in the shipped topology (acknowledged in `AkkaHostedService.cs:505-508`: "when the gRPC server is not registered (current central topology)"); sites push audit telemetry to central over `ClusterClient` instead, and central pulls with `PullAuditEvents` / `PullSiteCalls` by dialling the site.
|
||||
|
||||
**None of the three transports carries a mutual-auth or transport-encryption story.** `BuildHocon` emits no Akka `enable-ssl`, no secure cookie and no `trusted-selection-paths`, so remoting and `ClusterClient` are plaintext and unauthenticated. The site gRPC listener is **h2c** — `ListenAnyIP(grpcPort, o => o.Protocols = HttpProtocols.Http2)` with no `UseHttps` (`Host/Program.cs:501-506`) — and `SiteStreamGrpcClient` opens a bare `GrpcChannel.ForAddress(endpoint, …)` with no credentials. `LocalDbSyncAuthInterceptor` shares that listener but gates **only** methods under `/localdb_sync.v1.LocalDbSync/`, explicitly letting every `SiteStreamService` call through untouched, so the whole surface — including the `PullAuditEvents` / `PullSiteCalls` RPCs that return audit rows — is callable by anything that can reach the site's gRPC port. The one authenticated piece is the HTTP config fetch, and its per-deployment token is the *entire* security boundary (the endpoint is `AllowAnonymous`). The design assumes a trusted network between central and sites.
|
||||
**No transport carries transport encryption; two of the three now carry authentication.**
|
||||
|
||||
- **Akka remoting / `ClusterClient` — unauthenticated.** `BuildHocon` emits no `enable-ssl`, no secure cookie and no `trusted-selection-paths`, so the command/control path is plaintext and open to anything that can reach the remoting port.
|
||||
- **gRPC — authenticated by preshared key since 2026-07-22.** The listener is still **h2c** — `ListenAnyIP(grpcPort, o => o.Protocols = HttpProtocols.Http2)` with no `UseHttps` — but `ControlPlaneAuthInterceptor` gates every method under `/sitestream.SiteStreamService/`, including the `PullAuditEvents` / `PullSiteCalls` RPCs that return audit rows. It is fail-closed (no key ⇒ everything refused, and `StartupValidator` will not boot a site node in that state), compares with `CryptographicOperations.FixedTimeEquals`, and rejects with `PermissionDenied`. Central attaches the key via `ControlPlaneCredentials`, which binds `CallCredentials` to each channel so unary and streaming calls are covered uniformly — `SiteStreamGrpcClient`, `GrpcPullAuditEventsInvoker` and `GrpcPullSiteCallsInvoker` all build their channels through it. Keys are **per site** (`SB-GRPC-PSK-<siteId>`), so a compromised site yields only its own. `LocalDbSyncAuthInterceptor` shares the listener and keeps its own separate key on `/localdb_sync.v1.LocalDbSync/` — the two authenticate different peers (central vs. the pair partner) and are never shared.
|
||||
- **HTTP config fetch — token-authenticated.** Its per-deployment token is the *entire* security boundary (the endpoint is `AllowAnonymous`).
|
||||
|
||||
A bearer PSK over plaintext h2c is readable and replayable by anyone on the path, so the design still assumes a trusted network between central and sites — but the bar is now "read the traffic" rather than "reach the port". TLS on these listeners is the follow-on hardening and would not change the key design. Operational detail: [`docs/deployment/topology-guide.md`](../deployment/topology-guide.md).
|
||||
|
||||
### Notify-and-fetch: the deployment-config HTTP path
|
||||
|
||||
@@ -262,7 +268,7 @@ Three layers of dead-client detection protect the gRPC stream path:
|
||||
- [Notification Outbox (#21)](./NotificationOutbox.md) — `CentralCommunicationActor` routes `NotificationSubmit` / `NotificationStatusQuery` messages from sites to the `NotificationOutboxActor` proxy. `CommunicationService` Asks the proxy directly for central-UI outbox management calls.
|
||||
- [Site Call Audit (#22)](./SiteCallAudit.md) — `CommunicationService` Asks the `SiteCallAuditActor` proxy directly for query and relay operations. `SiteCallAuditActor` issues `RetryParkedOperation` / `DiscardParkedOperation` relay commands to sites via `SiteEnvelope`; `SiteCommunicationActor` dispatches them to `_parkedMessageHandler`.
|
||||
- [Store-and-Forward Engine (#6)](./StoreAndForward.md) — the site S&F Engine drives `NotificationSubmit` forwarding and cached-call telemetry emission through `SiteCommunicationActor`. Parked-message queries and retry/discard relay commands flow back the other way.
|
||||
- [Management Service (#18)](./ManagementService.md) — `ManagementActor` is registered with `ClusterClientReceptionist` at `/user/management` on central; the CLI connects via its own separate `ClusterClient`. This is a distinct `ClusterClient` usage from the inter-cluster hub-and-spoke connections managed by this component. Management Service also hosts `DeploymentConfigEndpoints` — the `GET /api/internal/deployments/{id}/config` route that terminates the third (HTTP) transport, mapped in the central-role block alongside `/api/audit/*` and `/management`.
|
||||
- [Management Service (#18)](./ManagementService.md) — `ManagementActor` runs at `/user/management` on central and is reached **in-process** through `ManagementActorHolder`; the CLI connects over HTTP, not `ClusterClient`. (It was `ClusterClientReceptionist`-registered until 2026-07-22, for a CLI that was never built that way.) So this component's `ClusterClient` usage is exclusively the inter-cluster hub-and-spoke connections. Management Service also hosts `DeploymentConfigEndpoints` — the `GET /api/internal/deployments/{id}/config` route that terminates the third (HTTP) transport, mapped in the central-role block alongside `/api/audit/*` and `/management`.
|
||||
- Design spec: [Component-Communication.md](../requirements/Component-Communication.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -56,17 +56,18 @@ Mutating handlers that call repositories directly invoke `AuditAsync` (backed by
|
||||
|
||||
### Actor lifecycle and registration
|
||||
|
||||
`AkkaHostedService` (in the Host) creates the `ManagementActor` under the path `/user/management` and registers it with `ClusterClientReceptionist`:
|
||||
`AkkaHostedService` (in the Host) creates the `ManagementActor` under the path `/user/management` and publishes it to `ManagementActorHolder`, which is the only way anything reaches it:
|
||||
|
||||
```csharp
|
||||
var mgmtActor = _actorSystem!.ActorOf(
|
||||
Props.Create(() => new ManagementActor(_serviceProvider, mgmtLogger)),
|
||||
"management");
|
||||
ClusterClientReceptionist.Get(_actorSystem).RegisterService(mgmtActor);
|
||||
var mgmtHolder = _serviceProvider.GetRequiredService<ManagementActorHolder>();
|
||||
mgmtHolder.ActorRef = mgmtActor;
|
||||
```
|
||||
|
||||
A `ClusterClientReceptionist.Get(_actorSystem).RegisterService(mgmtActor)` call sat between those two statements until 2026-07-22. It was deleted because nothing ever sent to it: the CLI it was built for uses HTTP, not ClusterClient.
|
||||
|
||||
`ClusterClientReceptionist` advertises the actor to `ClusterClient` senders without requiring them to join the Akka cluster. The `ManagementActorHolder.ActorRef` property is then the bridge from the HTTP endpoint (which runs in ASP.NET Core middleware) into the Akka actor world.
|
||||
|
||||
The actor declares an explicit supervisor strategy — one-for-one with Resume and no retry limit — to match the coordinator-actor convention and remain correct if child actors are added later.
|
||||
@@ -154,9 +155,11 @@ Content-Type: application/json
|
||||
|
||||
A successful response is HTTP 200 with the JSON result. An authorization failure is HTTP 403 with `{ "error": "...", "code": "UNAUTHORIZED" }`.
|
||||
|
||||
### Sending a command via ClusterClient
|
||||
### Sending a command in-process
|
||||
|
||||
The `ManagementActor` is also reachable from any `ClusterClient` that has a contact point into the central cluster. The actor is registered under `/system/receptionist` with the path `/user/management`. Callers construct and `Tell` a `ManagementEnvelope` and expect one of `ManagementSuccess`, `ManagementError`, or `ManagementUnauthorized` in reply.
|
||||
`ManagementEnvelope` is also the in-process contract: a caller holding `ManagementActorHolder.ActorRef` asks the actor directly and expects one of `ManagementSuccess`, `ManagementError`, or `ManagementUnauthorized` in reply. `ManagementEndpoints` is that caller.
|
||||
|
||||
There is **no** out-of-process actor path. The actor was advertised via `ClusterClientReceptionist` until 2026-07-22, so a `ClusterClient` with a contact point into the central cluster could `Tell` it a `ManagementEnvelope`; no caller ever did, and the registration is gone. The HTTP endpoints above are the only remote management surface.
|
||||
|
||||
## Command Groups
|
||||
|
||||
|
||||
@@ -178,9 +178,45 @@ database from its peer rather than letting it rejoin.
|
||||
|
||||
### Central-Site Communication
|
||||
|
||||
- Sites connect to central via Akka.NET remoting.
|
||||
- The `Communication:CentralSeedNode` setting in the site config points to one of the central nodes.
|
||||
- If that central node is down, the site's communication actor will retry until it connects to the active central node.
|
||||
Three transports cross the boundary, not one:
|
||||
|
||||
- **Akka ClusterClient** — command/control. Sites list every central node in
|
||||
`ScadaBridge:Communication:CentralContactPoints`; contact rotation reaches whichever node
|
||||
answers, so no "active central" needs to be identified. (There is no `Communication:CentralSeedNode`
|
||||
setting — earlier revisions of this guide named one that never existed in the code.)
|
||||
- **gRPC** — real-time data and audit pull. Note the direction is inverted from the data flow:
|
||||
each **site node hosts the gRPC server** on `GrpcPort` (default 8083, h2c) and central dials in.
|
||||
- **Plain HTTP** — the deploy config itself, fetched by the site with a per-deployment token.
|
||||
|
||||
#### gRPC control-plane preshared key (required)
|
||||
|
||||
Every site node must set `ScadaBridge:Communication:GrpcPsk`, and central must hold the same
|
||||
value for that site. **`StartupValidator` refuses to boot a site node without it**, deliberately:
|
||||
the gate is fail-closed, so an unset key would leave the node joined, healthy-looking and
|
||||
answering heartbeats while refusing every gRPC call — no live subscriptions, no audit pull, no
|
||||
cached-telemetry ingest.
|
||||
|
||||
| Side | Where the key lives |
|
||||
|---|---|
|
||||
| Site node (both nodes of the pair, identical) | `ScadaBridge:Communication:GrpcPsk`, in production `${secret:SB-GRPC-PSK-<siteId>}` |
|
||||
| Central | secret `SB-GRPC-PSK-<siteId>` in its store — **or** `ScadaBridge:Communication:SitePsks:<siteId>` |
|
||||
|
||||
The store is the source that matters in production, because sites are added at runtime and their
|
||||
keys cannot be enumerated in configuration at boot; `SitePsks` covers a host running without a
|
||||
master key (the docker rig) and one-off pins.
|
||||
|
||||
One key **per site**, never one for the fleet: a compromised site must not yield another site's
|
||||
key. And never share it with `LocalDb:Replication:ApiKey` — that authenticates the *pair partner*
|
||||
for database replication, a different trust relationship on the same listener.
|
||||
|
||||
**Rotation:** set the new value on both sides, then restart the pair (pairs restart together
|
||||
anyway — see above). **Upgrading to a build that has this gate requires seeding the key first**,
|
||||
including in the on-host `deploy/` overlays.
|
||||
|
||||
The key is a bearer token over plaintext h2c, so it is readable and replayable by anyone on the
|
||||
path. That is the accepted posture today — the same trusted-network assumption the boundary
|
||||
already made, now with authentication rather than none. TLS on these listeners is follow-on
|
||||
hardening and needs no change to the key design.
|
||||
|
||||
## Scaling Guidelines
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Integration call routing (`IntegrationCallRequest`) is dead on both ends
|
||||
|
||||
**Date:** 2026-07-22 · **Status:** OPEN (decision needed: wire or delete) · **Severity:** Low (no
|
||||
runtime impact — the path cannot be reached) · **Area:** Central–Site Communication
|
||||
|
||||
## What
|
||||
|
||||
"Pattern 4: Integration Routing" — `CommunicationService.RouteIntegrationCallAsync` →
|
||||
`SiteEnvelope(IntegrationCallRequest)` → `SiteCommunicationActor` → an integration handler — is
|
||||
plumbed end to end but connected at neither end.
|
||||
|
||||
- **No producer.** `RouteIntegrationCallAsync` (`CommunicationService.cs`, "Pattern 4") has **zero
|
||||
callers** in `src/` or `tests/`. It is the only one of `CommunicationService`'s command methods
|
||||
with none.
|
||||
- **No handler.** `SiteCommunicationActor` forwards to `_integrationHandler` when one is
|
||||
registered, but `RegisterLocalHandler(LocalHandlerType.Integration, …)` appears **only** in
|
||||
`SiteCommunicationActorTests.cs`. `AkkaHostedService` registers the other three handler types
|
||||
(`Artifacts`, `EventLog`, `ParkedMessages`) and never this one.
|
||||
|
||||
So if anything ever did call it, the site would answer
|
||||
`IntegrationCallResponse(Success: false, Error: "Integration handler not available")`
|
||||
(`SiteCommunicationActor.cs`, Pattern 4) — and the two tests that exercise the path both register
|
||||
the handler themselves first, which is why the suite has never noticed.
|
||||
|
||||
Do not confuse this with the **Inbound API**'s routed-site-script path, which is live, tested, and
|
||||
uses different messages entirely. This is a separate, unused routing pattern that predates it.
|
||||
|
||||
## Why it is recorded rather than fixed
|
||||
|
||||
Found during the recon for the ClusterClient→gRPC transport migration
|
||||
([`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`](../plans/2026-07-22-clusterclient-to-grpc-plan.md),
|
||||
T0.2), which had to enumerate every command crossing the site↔central boundary. Of the **29**
|
||||
command types, this is the one that is excluded: **28 migrate to the gRPC contract.**
|
||||
|
||||
Porting it would mean designing a proto contract, a `oneof` slot and round-trip mapper tests for a
|
||||
verb no caller can invoke and no site can service — and enshrining it on a wire format whose
|
||||
evolution rules are additive-only, so an unused RPC slot is permanent. Deleting it during a
|
||||
transport migration would mix an unrelated behavioural change into a change whose whole value is
|
||||
that behaviour is identical. Hence: excluded from the contract, behaviour untouched, decision
|
||||
deferred to its own change.
|
||||
|
||||
## Decision needed
|
||||
|
||||
Either:
|
||||
|
||||
1. **Delete** — remove `RouteIntegrationCallAsync`, the `IntegrationCallRequest`/`Response`
|
||||
messages, the `SiteCommunicationActor` receive block, `LocalHandlerType.Integration`, and the
|
||||
three tests that cover them. This is the default if no consumer is planned.
|
||||
2. **Wire** — register a real integration handler on site nodes and give the method a caller. This
|
||||
only makes sense if there is a requirement it serves; none is recorded in
|
||||
`docs/requirements/`.
|
||||
|
||||
Whichever is chosen, do it **before Phase 4** of the migration, since Phase 4 deletes the Akka
|
||||
transport underneath this path. If it is still dead at that point, option 1 is forced.
|
||||
|
||||
## Filing
|
||||
|
||||
To be filed as a Gitea issue on `dohertj2/scadabridge` by the repo owner — this note is the
|
||||
in-repo record of the finding and of the migration exclusion it justifies.
|
||||
@@ -0,0 +1,143 @@
|
||||
# ClusterClient → gRPC migration — live gate results
|
||||
|
||||
Rig: `docker/` (2 central + 3×2 site + traefik), rebuilt from the branch under test via
|
||||
`bash docker/deploy.sh`. Recorded check-by-check in the family's live-gate format. Phase 5's
|
||||
full eight-check gate is recorded further down as those phases land; this file starts with
|
||||
Phase 0, whose DoD has its own smaller gate.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — PSK auth + dead-code removal — **PASS** (2026-07-22)
|
||||
|
||||
Branch `feat/grpc-phase0-psk` @ `228ff8b4`. Image rebuilt, all 9 containers recreated.
|
||||
|
||||
### Baseline (pre-change build, same rig)
|
||||
|
||||
An unauthenticated call from the host to a site's audit-pull RPC was **accepted**:
|
||||
|
||||
```
|
||||
$ grpcurl -plaintext -d '{"batch_size":1}' localhost:9023 sitestream.SiteStreamService/PullAuditEvents
|
||||
{}
|
||||
```
|
||||
|
||||
That is the gap Phase 0 closes, reproduced rather than assumed.
|
||||
|
||||
### Checks
|
||||
|
||||
| # | Check | Result |
|
||||
|---|---|---|
|
||||
| 1 | All 8 nodes boot with keys configured (new `StartupValidator` rule) | **PASS** — all recreated and reached ready |
|
||||
| 2 | Unauthenticated `PullAuditEvents` ⇒ `PermissionDenied`, all 3 sites | **PASS** |
|
||||
| 3 | Wrong key (site-b's key presented to site-a) ⇒ `PermissionDenied` | **PASS** — per-site scoping is real, not decorative |
|
||||
| 4 | Correct key ⇒ success, all 3 sites | **PASS** |
|
||||
| 5 | Central's own authenticated paths still work | **PASS** — 14 successful `PullAuditEvents` from central to site-a; **0** auth failures in either central's log |
|
||||
| 6 | LocalDb sync unaffected by the new interceptor | **PASS** — **0** control-plane rejections and **0** sync auth failures on the passive peer; session connected after one boot-order retry |
|
||||
| 7 | No interceptor-activation errors | **PASS** — 0 (see the defect below) |
|
||||
|
||||
Evidence for 2–4:
|
||||
|
||||
```
|
||||
=== NO CREDENTIALS ===
|
||||
:9023 -> ERROR: Code: PermissionDenied Message: Control plane authentication failed.
|
||||
:9033 -> ERROR: Code: PermissionDenied Message: Control plane authentication failed.
|
||||
:9043 -> ERROR: Code: PermissionDenied Message: Control plane authentication failed.
|
||||
|
||||
=== WRONG KEY (site-b's key against site-a) ===
|
||||
:9023 -> ERROR: Code: PermissionDenied Message: Control plane authentication failed.
|
||||
|
||||
=== CORRECT KEY ===
|
||||
site-a :9023 -> {}
|
||||
site-b :9033 -> {}
|
||||
site-c :9043 -> {}
|
||||
```
|
||||
|
||||
Site-a rejected exactly **2** calls — the two deliberate probes above — and nothing else.
|
||||
|
||||
### Defect the gate caught that the test suite did not
|
||||
|
||||
**First run of this gate FAILED**, and is worth recording because the failure mode is
|
||||
deceptive.
|
||||
|
||||
`Grpc.AspNetCore` activates a type-registered interceptor through
|
||||
`InterceptorRegistration.GetFactory()`, which throws when more than one public constructor is
|
||||
applicable. `ControlPlaneAuthInterceptor` shipped with two — the DI one and a prefix-set
|
||||
overload intended for later phases.
|
||||
|
||||
The throw happens **inside the pipeline, per call**, so:
|
||||
|
||||
- nothing failed at startup; the node booted, joined its pair and reported healthy;
|
||||
- every gated call died with `Unknown / "Exception was thrown by handler"`, which reads as a
|
||||
handler bug rather than an auth bug;
|
||||
- **correct key, wrong key and no key produced identical errors** — the tell. A gate that
|
||||
cannot distinguish those is not authenticating anything.
|
||||
|
||||
Site-a's log at the time: three `PullAuditEvents` calls, three identical
|
||||
`System.InvalidOperationException: Multiple constructors accepting all given argument types
|
||||
have been found in type 'ControlPlaneAuthInterceptor'`.
|
||||
|
||||
The full suite was green when this shipped — **29 suites, 6,872 tests, 0 failures**. The
|
||||
in-process end-to-end test missed it because it registered the interceptor with
|
||||
`AddSingleton` alongside `AddGrpc`, so DI returned the instance and gRPC's activation path
|
||||
never ran.
|
||||
|
||||
Fixed in `228ff8b4`: the prefix-set constructor is `internal`; the end-to-end harness now
|
||||
registers exactly as `Program.cs` does (by type, not in DI); and a reflection assertion pins
|
||||
"exactly one public constructor", since that is the actual invariant.
|
||||
|
||||
**Lesson for phases 1A/1B, which both add services to this interceptor:** extend
|
||||
`DefaultGatedPrefixes`; do not add a second public constructor. And any in-process harness for
|
||||
a DI-activated component must mirror the production registration shape or it proves less than
|
||||
it appears to.
|
||||
|
||||
### Test suite alongside the gate
|
||||
|
||||
Non-Playwright: **29 suites, 6,872 tests, 0 failures**.
|
||||
|
||||
Playwright (against this rig): **170 passed, 2 failed, 1 skipped** of 173. Both failures were
|
||||
run down to root cause and **both are pre-existing on `main`, unrelated to Phase 0** — this
|
||||
branch touches no EF, CentralUI, Transport or ManagementService file (`git diff --stat
|
||||
main...HEAD -- src/` is 15 files, all Communication/Host/AuditLog gRPC plumbing).
|
||||
|
||||
An earlier run of this suite reported 44 failures. That run is **void**: a `docker/deploy.sh`
|
||||
was recreating the cluster underneath it, so the fast `LoginTests`/`NavigationTests` failures
|
||||
were "app unreachable", not defects.
|
||||
|
||||
**1. `TransportImportTests.ImportSyntheticBundle_AppliesAndShowsAuditDrillIn` — a real
|
||||
production bug, not a test defect.** Central's log during the failure:
|
||||
|
||||
```
|
||||
[ERR] An exception occurred while iterating over the results of a query ...
|
||||
System.InvalidOperationException: The configured execution strategy
|
||||
'SqlServerRetryingExecutionStrategy' does not support user-initiated transactions.
|
||||
at Microsoft.EntityFrameworkCore.Query.Internal.SplitQueryingEnumerable`1.AsyncEnumerator.MoveNextAsync()
|
||||
```
|
||||
|
||||
`BundleImporter.cs:1298` opens a user-initiated transaction; the central context is configured
|
||||
with `EnableRetryOnFailure` (`ConfigurationDatabase/ServiceCollectionExtensions.cs:33`). SQL
|
||||
Server's retrying strategy refuses to run a split query inside a caller's transaction, so
|
||||
**bundle import fails against real MS SQL**. The fix is the one the exception names: wrap the
|
||||
transaction in `Database.CreateExecutionStrategy().ExecuteAsync(...)`.
|
||||
|
||||
Why the whole unit/integration suite is green on it: those tests use the in-memory EF provider,
|
||||
which has no retrying execution strategy — and `BeginTransactionAsync` is a no-op there. The
|
||||
comment directly above line 1298 documents that divergence without drawing the conclusion. Only
|
||||
a rig-backed test can see this.
|
||||
|
||||
**2. `SmsNotificationE2ETests.SmsConfigPage_CreateOrRender_NeverLeaksAuthToken` — a stale test
|
||||
fixture.** No server-side error at all: the page renders 200, and no `INSERT INTO
|
||||
SmsConfigurations` is ever issued. The test's fixture SID is `ACtest123` (`d6ead8ae`,
|
||||
2026-06-19). `SmsConfiguration.razor:231` rejects anything not matching `^AC[0-9a-fA-F]{32}$`,
|
||||
added by `40088a21` (2026-07-10) to close an un-escaped URI-interpolation hole. `Save()` sets
|
||||
`_formError` and returns — no toast, exactly as observed. The fixture was never updated.
|
||||
|
||||
This has been failing since 2026-07-10, and it matters more than a red line: everything after
|
||||
the toast assertion — including **the secret-non-leak assertion that the Auth Token value never
|
||||
reaches the page HTML** — has not executed since. Fix is a valid 32-hex SID in the fixture.
|
||||
|
||||
### Not covered by this gate
|
||||
|
||||
- Streaming subscriptions were exercised in-process (TestServer), not over the rig. The
|
||||
interceptor is path-scoped, not method-scoped, so the rig's `PullAuditEvents` evidence covers
|
||||
the same code path — but a live `SubscribeInstance` under load is untested here.
|
||||
- Key **rotation** on a live pair.
|
||||
- `docker-env2` was updated with its own key but not redeployed or gated.
|
||||
@@ -0,0 +1,355 @@
|
||||
# ClusterClient → gRPC-only cross-cluster transport — implementation plan
|
||||
|
||||
**Date:** 2026-07-22. **Design:** `~/Desktop/scadaproj/scadabridge_clusterclient_to_grpc.md`
|
||||
(read it first, §7 especially — it contains the deep-dive corrections this plan builds on).
|
||||
**Goal:** all site↔central traffic rides gRPC with PSK auth from `ZB.MOM.WW.Secrets`;
|
||||
`ClusterClient`/`ClusterClientReceptionist` are deleted; Akka remoting never crosses the
|
||||
site↔central boundary.
|
||||
|
||||
Everything below was code-verified 2026-07-22. If a cited line has drifted, re-locate by the
|
||||
quoted identifier, never by line number.
|
||||
|
||||
## How to execute this plan (read before starting)
|
||||
|
||||
- **Phase order:** 0 → (1A ∥ 1B) → (2 ∥ 3) → 4 → 5. Phases marked ∥ are parallel-safe **only in
|
||||
separate git worktrees** (`git worktree add ../ScadaBridge-1B feat/grpc-sitecommand`) — never
|
||||
run two agents against one working tree (destructive git races are a known family incident).
|
||||
Designated merge order when tracks meet: 1A lands first, 1B rebases (expected conflicts are
|
||||
confined to `ZB.MOM.WW.ScadaBridge.Communication.csproj` proto ItemGroups and `Program.cs`
|
||||
service/Map blocks — resolve as union).
|
||||
- **Branches:** `feat/grpc-phase0-psk`, `feat/grpc-central-control` (1A),
|
||||
`feat/grpc-sitecommand` (1B), then per-phase branches; PR each phase to `main` with its DoD met.
|
||||
- **Build/test:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` (0 warnings — TreatWarningsAsErrors),
|
||||
`dotnet test ZB.MOM.WW.ScadaBridge.slnx`. Tests are xunit **v2** (2.9.3) + `Akka.TestKit.Xunit2`
|
||||
1.5.62 + NSubstitute. TestKit tests inherit `TestKit` — never hand-roll
|
||||
`ActorSystem.Create`+join.
|
||||
- **Proto codegen is CHECKED-IN, not build-time** (protoc segfaults in the linux_arm64 image).
|
||||
For every new/changed proto follow the sitestream recipe documented in
|
||||
`src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj`:
|
||||
temporarily uncomment/add the `<Protobuf Include=... GrpcServices="Both" />` item, delete stale
|
||||
generated files, `dotnet build` on macOS, copy `obj/**/Protos/*.cs` into a committed folder
|
||||
(mirror `SiteStreamGrpc/`), re-comment the item. `docker/regen-proto.sh` exists.
|
||||
- **Rig:** `docker/deploy.sh` rebuilds the 2-central + 3×2-site cluster (+ traefik). Central UI
|
||||
`9001/9002:5000`, site gRPC `90x3/90x4:8083`. **External sites↔central rules:** never
|
||||
host-`sqlite3` a live WAL DB; `aspnet:10.0` has no `curl`; seeding via `docker/seed-sites.sh`.
|
||||
- **Coexistence rule:** every migrated path sits behind a config flag with the Akka
|
||||
implementation as default until Phase 4. Rollback at any point = flip the flag.
|
||||
|
||||
## The two choke points (where all transport code changes)
|
||||
|
||||
- **Site→central:** `SiteCommunicationActor`
|
||||
(`src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs`) — 7 message types,
|
||||
all `ClusterClient.Send` to `/user/central-communication` (Ask, except `HeartbeatMessage` Tell).
|
||||
- **Central→site:** `CentralCommunicationActor` (same dir) — the `SiteEnvelope` handler
|
||||
(`:452-467`) unwraps and routes via per-site ClusterClient. ALL producers
|
||||
(`CommunicationService`'s 27 + `SiteCallAuditActor`'s 2 relays) go through it.
|
||||
**`CommunicationService` gets NO interface extraction; its ~20 consumers are untouched.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — PSK auth + dead-code removal (standalone hardening; parallel-safe with 1A/1B prep)
|
||||
|
||||
### T0.1 Delete the vestigial `/user/management` receptionist registration
|
||||
`AkkaHostedService.cs:459` (`RegisterService` of `ManagementActor`) — delete the registration
|
||||
only (the actor stays; `ManagementEndpoints.cs:117` asks it in-process via
|
||||
`ManagementActorHolder`). Grep-verify nothing sends to `/user/management`. Update
|
||||
`docs/requirements/Component-Host.md` REQ-HOST-6a to record the removal.
|
||||
|
||||
### T0.2 File the dead-integration issue
|
||||
`IntegrationCallRequest` (`CommunicationService.cs:238`) is unwired in production —
|
||||
`RegisterLocalHandler(Integration, …)` exists only in `SiteCommunicationActorTests.cs:102`, so
|
||||
production always replies "Integration handler not available" (`SiteCommunicationActor.cs:134-136`).
|
||||
File a Gitea issue (decide: wire or delete); **exclude it from the gRPC contract** (28 of 29
|
||||
commands migrate). Do not change its behavior in this program.
|
||||
|
||||
### T0.3 PSK interceptor + options
|
||||
New `src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs`, copied from
|
||||
`LocalDbSyncAuthInterceptor.cs` (same file layout: seal, 4 server handlers → shared
|
||||
`Authorize`, `authorization: Bearer` metadata extraction, `CryptographicOperations.FixedTimeEquals`
|
||||
over UTF-8, **fail-closed when the expected key is unset**, reject with
|
||||
`StatusCode.PermissionDenied`). Differences from the template:
|
||||
|
||||
- **Path scope:** gates a *set* of service prefixes (constructor-provided), initially the
|
||||
sitestream service prefix (read the real package/service name from `sitestream.proto` —
|
||||
`/…SiteStreamService/`); later phases add the new services. LocalDb sync keeps its own
|
||||
interceptor + key untouched.
|
||||
- **Key source, site side (server on `:8083`):** new option
|
||||
`CommunicationOptions.GrpcPsk` (`ScadaBridge:Communication:GrpcPsk`), production value
|
||||
`${secret:SB-GRPC-PSK-<siteId>}` resolved by the existing pre-host `SecretReferenceExpander`
|
||||
(`Program.cs:56-65`) — zero new resolution code.
|
||||
- **Key source, central side (clients today; server in 1A):** central's site set is dynamic, so
|
||||
central resolves secret **`SB-GRPC-PSK-{siteId}`** at channel-build time via the runtime
|
||||
`ISecretResolver` (copy the fail-closed lazy pattern from
|
||||
`DataConnectionLayer/Adapters/MxGatewayDataConnection.cs:82-98`), cached per site, cache
|
||||
invalidated on site remove. New helper `SitePskProvider` in Communication (interface) +
|
||||
Host (implementation over `ISecretResolver`).
|
||||
|
||||
Wire: add the interceptor to the site's existing `AddGrpc` (`Program.cs:526-527`, alongside the
|
||||
LocalDb one). Attach the PSK on central's existing site-dialing clients as call-level
|
||||
`Metadata` `Authorization: Bearer <psk>` (the LocalDb sync client pattern,
|
||||
`SyncBackgroundService.cs:82-86`): `SiteStreamGrpcClient` (subscribe calls),
|
||||
`GrpcPullAuditEventsClient`, `GrpcPullSiteCallsClient` — each already flows through a channel/
|
||||
invoker creation point where the site id is known.
|
||||
|
||||
### T0.4 Rig + tests
|
||||
- Rig: mirror the LocalDb dev-key pattern — literal `ScadaBridge:Communication:GrpcPsk:
|
||||
"dev-grpc-psk-site-a"` in BOTH `docker/site-a-node-*/appsettings.Site.json` (and site-b/c with
|
||||
their own keys, since unlike LocalDb this is not optional), plus central-side dev secrets: seed
|
||||
`SB-GRPC-PSK-site-{a,b,c}` into both centrals' secret stores (secret CLI seed flow, or dev-KEK
|
||||
env as the rig's Secrets setup already does).
|
||||
- Tests: interceptor unit tests (wrong key / missing header / unset expected key ⇒
|
||||
`PermissionDenied`; non-gated service path passes; constant-time compare exercised);
|
||||
`SitePskProvider` fail-closed test; one Host wiring test asserting the interceptor is
|
||||
registered.
|
||||
|
||||
**Phase 0 DoD:** suite green; on the rig, an unauthenticated `grpcurl`/test client gets
|
||||
`PermissionDenied` on SiteStream, authenticated streaming + pull still work end-to-end. PR merged.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1A — Central control plane (site→central) — worktree A
|
||||
|
||||
### T1A.1 `central_control.proto`
|
||||
New `Protos/central_control.proto` in the Communication project (checked-in codegen per recipe),
|
||||
package `scadabridge.centralcontrol.v1`, service `CentralControlService`:
|
||||
|
||||
| RPC | Wraps DTO (source file under `Commons/Messages/`) | Notes |
|
||||
|---|---|---|
|
||||
| `SubmitNotification` | `NotificationSubmit`/`NotificationSubmitAck` (`Notification/NotificationMessages.cs:30,47`) | 11 fields incl. `Guid?` execution ids → string |
|
||||
| `QueryNotificationStatus` | `NotificationStatusQuery`/`Response` (`:55,62`) | |
|
||||
| `IngestAuditEvents` | reuse existing `AuditEventBatch`/`IngestAck` from sitestream.proto | import, don't duplicate; `ForwardState`/`IngestedAtUtc` stay off-wire (`AuditEventDtoMapper.cs:23-27`) |
|
||||
| `IngestCachedTelemetry` | reuse `CachedTelemetryBatch`/`IngestAck` | same |
|
||||
| `ReconcileSite` | `ReconcileSiteRequest`/`Response` (`Deployment/ReconcileSiteRequest.cs`, `ReconcileSiteResponse.cs` incl. `ReconcileGapItem`) | map<string,string> for name→hash |
|
||||
| `ReportSiteHealth` | `SiteHealthReport`/`SiteHealthReportAck` (`Health/SiteHealthReport.cs`) | the big one: ~30 fields incl. maps of `ConnectionHealth` enum, `TagResolutionStatus`, `TagQualityCounts`, `NodeStatus` list, `SiteAuditBacklogSnapshot`; model nullable ints/doubles with wrappers; keep `SequenceNumber` |
|
||||
| `Heartbeat` | `HeartbeatMessage` (`Health/HeartbeatMessage.cs`) → `google.protobuf.Empty` reply | fire-and-forget semantics preserved client-side (don't await failure into caller) |
|
||||
|
||||
Mappers in `Communication/Grpc/CentralControlDtoMapper.cs` with **round-trip golden tests**
|
||||
(construct DTO → proto → DTO, assert deep-equal; include null-optional cases).
|
||||
|
||||
### T1A.2 Central hosting
|
||||
Central branch of `Program.cs` (~`:262` services, `:449-469` Map block):
|
||||
`builder.Services.AddGrpc(o => o.Interceptors.Add<ControlPlaneAuthInterceptor>())` — central's
|
||||
interceptor variant verifies `Bearer` against the per-site PSK looked up by the **required
|
||||
`x-scadabridge-site` metadata header** via `SitePskProvider` (fail-closed: missing header ⇒
|
||||
`PermissionDenied`). Add an **explicit Kestrel listener** for h2c gRPC: new option
|
||||
`ScadaBridge:Node:CentralGrpcPort` (default **8083**, symmetric with sites), configured like the
|
||||
site branch does (`Program.cs:507-512`, `HttpProtocols.Http2`); central's `:5000` stays as-is
|
||||
(Traefik is HTTP/1 — gRPC does NOT go through traefik). `MapGrpcService<CentralControlGrpcService>()`.
|
||||
|
||||
`CentralControlGrpcService` (Host or Communication): decode proto → the SAME message types →
|
||||
`Ask` the existing `CentralCommunicationActor` handlers (they already handle all 7 — zero
|
||||
handler logic changes) → encode reply. Reuse the readiness convention: reject `Unavailable`
|
||||
until the central actor system is up (mirror `SiteStreamGrpcServer.SetReady`).
|
||||
|
||||
### T1A.3 Site-side client + transport seam
|
||||
- New `ICentralTransport` (Communication): one method per the 7 sends. Implementations:
|
||||
`AkkaCentralTransport` (extracted verbatim from today's `SiteCommunicationActor` send blocks)
|
||||
and `GrpcCentralTransport` (new).
|
||||
- `GrpcCentralTransport`: **channel pair with sticky failover + failback** per design §3.5 — new
|
||||
shared `CentralChannelProvider`: endpoints from new option
|
||||
`ScadaBridge:Communication:CentralGrpcEndpoints` (List<string>, e.g.
|
||||
`["http://scadabridge-central-a:8083","http://scadabridge-central-b:8083"]`; validator: required
|
||||
when transport=Grpc); sticky-until-failure; flip on connect-fail/`Unavailable`; background
|
||||
failback probe every 30–60 s (gRPC health or a `Heartbeat` ping); reconnect backoff copied from
|
||||
`SyncBackgroundService.cs:151` (1 s doubling, cap 60 s). Attach PSK (`GrpcPsk` option) +
|
||||
`x-scadabridge-site` on every call; per-call deadlines from the matching `CommunicationOptions`
|
||||
timeout (`NotificationForwardTimeout`, `HealthReportTimeout`, etc. — today's Ask timeouts,
|
||||
unchanged values). **Cross-node auto-retry only on connect-fail/`Unavailable`** — never on
|
||||
`DeadlineExceeded`.
|
||||
- `SiteCommunicationActor` selects the implementation from new option
|
||||
`ScadaBridge:Communication:CentralTransport` (`Akka` | `Grpc`, **default `Akka`**). The 7
|
||||
handler bodies delegate to the injected transport; reply/fault semantics identical (timeout or
|
||||
non-OK status ⇒ same `Status.Failure` the S&F/audit layers already treat as transient).
|
||||
|
||||
### T1A.4 Tests
|
||||
Extend `Communication.Tests` (TestKit): `SiteCommunicationActor` with an NSubstitute
|
||||
`ICentralTransport` — all 7 paths, fault propagation (transport throw ⇒ same failure the S&F
|
||||
tests expect). `GrpcCentralTransport` unit tests with an in-process `TestServer` gRPC host:
|
||||
failover flip, sticky behavior, failback probe, PSK attached, deadline set, no-retry-on-deadline.
|
||||
Reuse/extend `DirectActorSiteStreamAuditClient` for the ingest integration harness.
|
||||
`NotificationForwarderTests`/`SiteAuditTelemetryActorTests`/`HealthReportSenderTests` must pass
|
||||
unmodified (they sit above the seam — if they need edits, the seam is wrong).
|
||||
|
||||
**1A DoD:** suite green; on the rig with site-a flipped to `CentralTransport=Grpc`
|
||||
(central gRPC port published, e.g. `9013/9014:8083`): notification e2e, audit rows land, health
|
||||
page live, heartbeat drives active flag, reconcile works after site restart — while site-b/c
|
||||
still run Akka (coexistence proven).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1B — Site command plane (central→site) — worktree B
|
||||
|
||||
### T1B.1 `site_command.proto`
|
||||
Package `scadabridge.sitecommand.v1`, service `SiteCommandService` — **28 commands** (29 minus
|
||||
dead `IntegrationCallRequest`), grouped into domain RPCs with `oneof` request/response
|
||||
envelopes (full command list + reply types + `CommunicationService.cs` line refs in the design
|
||||
doc §7 / recon inventory):
|
||||
|
||||
| RPC | Commands (count) | Deadline source |
|
||||
|---|---|---|
|
||||
| `ExecuteLifecycle` | RefreshDeployment, Enable/Disable/DeleteInstance, DeploymentStateQuery, DeployArtifacts (6) | `DeploymentTimeout`/`LifecycleTimeout`/`ArtifactDeploymentTimeout` |
|
||||
| `ExecuteOpcUa` | BrowseNode, SearchAddressSpace, ReadTagValues, VerifyEndpoint, Trust/List/RemoveServerCert, WriteTag (8) | `QueryTimeout` (browse/search per existing Ask usage) |
|
||||
| `ExecuteQuery` | EventLogQuery, DebugSnapshot, Subscribe/UnsubscribeDebugView (4) | `QueryTimeout`/`DebugViewTimeout` |
|
||||
| `ExecuteParked` | ParkedMessageQuery/Retry/Discard, RetryParkedOperation, DiscardParkedOperation (5) | `QueryTimeout`; relay callers keep `RelayTimeout`(10s) < `QueryTimeout`(30s) ordering |
|
||||
| `ExecuteRoute` | RouteToCall/GetAttributes/SetAttributes/WaitForAttribute (4) | `IntegrationTimeout`; WaitForAttribute uses its dynamic timeout |
|
||||
| `TriggerFailover` | TriggerSiteFailover (1) | `LifecycleTimeout` |
|
||||
|
||||
Mappers `SiteCommandDtoMapper.cs` + round-trip golden tests for every command/reply (the bulk of
|
||||
this track — budget accordingly; enums, `TrackedOperationId` struct → string guid, nullable
|
||||
wrappers).
|
||||
|
||||
### T1B.2 Site server: shared dispatcher
|
||||
Refactor `SiteCommunicationActor`'s receive table into `SiteCommandDispatcher` (pure routing:
|
||||
message → `_deploymentManagerProxy` / `_artifactHandler` / `_eventLogHandler` /
|
||||
`_parkedMessageHandler` / failover handler — preserving EXACTLY today's targets, including the
|
||||
node-local parked-message handler; see design §7.3, the replicated-store semantics are
|
||||
deliberate). The actor and a new `SiteCommandGrpcService` (mapped in the site branch next to
|
||||
`SiteStreamGrpcServer`, gated by `ControlPlaneAuthInterceptor` + the readiness flag) both call
|
||||
the dispatcher — one routing truth, both transports.
|
||||
|
||||
### T1B.3 Central client + transport seam
|
||||
`ISiteCommandTransport` (send `SiteEnvelope`-equivalent, Ask or Tell) injected into
|
||||
**`CentralCommunicationActor`**; implementations `AkkaSiteTransport` (today's per-site
|
||||
ClusterClient path, extracted) and `GrpcSiteTransport`. `GrpcSiteTransport` uses a new shared
|
||||
**`SitePairChannelProvider`**: addresses from the `Site` entity's existing
|
||||
`GrpcNodeAAddress`/`GrpcNodeBAddress` (the streaming path's columns — do NOT invent
|
||||
`LoadSiteAddressesFromDb`, it doesn't exist; reuse the `ISiteRepository` reads +
|
||||
`CentralCommunicationActor`'s existing DB-driven cache-refresh loop `:532-598` to build/refresh
|
||||
channels instead of ClusterClients), sticky failover/failback per §3.5, PSK from
|
||||
`SitePskProvider` + deadlines per the table above. Config flag
|
||||
`ScadaBridge:Communication:SiteTransport` (`Akka` | `Grpc`, default `Akka`) selected inside
|
||||
`CentralCommunicationActor` — `CommunicationService` and `SiteCallAuditActor` unchanged.
|
||||
|
||||
### T1B.4 Tests
|
||||
TestKit: `CentralCommunicationActor` with substitute `ISiteCommandTransport` (envelope routing,
|
||||
Ask-sender reply plumbing, per-site transport lifecycle on site add/remove/change);
|
||||
`SiteCommandDispatcher` unit tests (every command → correct target, incl. parked→local handler
|
||||
and failover→local); `SiteCommandGrpcService` via TestServer (auth, readiness, one command per
|
||||
oneof group); existing `CommunicationServiceTests`/`CentralCommunicationActor*Tests` pass with
|
||||
the Akka implementation as default.
|
||||
|
||||
**1B DoD:** suite green; rig central flipped to `SiteTransport=Grpc` for site-a only: from
|
||||
CentralUI — deploy refresh, enable/disable instance, browse, read tag, write tag, event-log
|
||||
query, parked query/retry/discard (run retry against the STANDBY site node explicitly —
|
||||
replicated-store semantics), `TriggerSiteFailover` — all work; site-b/c untouched on Akka.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 (after 1A) ∥ Phase 3 (after 1B) — full cutover on the rig + hardening
|
||||
|
||||
- **Phase 2:** flip all sites to `CentralTransport=Grpc`. Soak: S&F drain under central-a kill
|
||||
(rows stay Pending, resume without loss or duplicates — sequence/dedup layers unchanged),
|
||||
failback observed when central-a returns, health/heartbeat cadence unchanged in
|
||||
`CentralHealthAggregator` (no sequence regressions logged).
|
||||
- **Phase 3:** flip central to `SiteTransport=Grpc` for all sites. Soak: full CentralUI command
|
||||
matrix against each site; site-node kill mid-command returns a clean error (no hang beyond
|
||||
deadline); site pair failover mid-stream of commands.
|
||||
- Both phases: watch for `PermissionDenied` noise (would indicate PSK drift), and confirm zero
|
||||
ClusterClient log activity on flipped paths.
|
||||
|
||||
## Phase 4 — deletion + config cutover (sequential, after 2+3)
|
||||
|
||||
1. Flip both flag defaults to `Grpc`; rig + docs updated; one soak cycle.
|
||||
2. Delete: `AkkaCentralTransport`/`AkkaSiteTransport`, ClusterClient creation
|
||||
(`AkkaHostedService.cs:942-953`), `DefaultSiteClientFactory` (+ its tests), per-site
|
||||
ClusterClient cache in `CentralCommunicationActor` (keep the DB refresh loop — it now feeds
|
||||
`SitePairChannelProvider`), receptionist registrations `:436` and `:935` (T0.1 already removed
|
||||
`:459`), `CommunicationOptions.CentralContactPoints` (+ validator + rig configs), then the
|
||||
flags themselves.
|
||||
3. Grep-gates: `rg -i "clusterclient|receptionist" src tests docker docs` → only historical docs;
|
||||
`rg "CentralContactPoints"` → empty.
|
||||
4. Docs: update `grpc_streams.md` (its "ClusterClient keeps command/control" split is
|
||||
superseded; also fix its `LoadSiteAddressesFromDb` doc-vs-code gap), `Component-Host.md`,
|
||||
`Component-StoreAndForward.md:137`, and add a `docs/known-issues` cross-ref note that the
|
||||
frame-size class is retired. Keep `Akka.Cluster.Tools` (ClusterSingleton still used).
|
||||
|
||||
## Phase 5 — live gate (rig; sequential; every check PASS required)
|
||||
|
||||
1. **PSK negative:** no key / wrong key / missing `x-scadabridge-site` ⇒ `PermissionDenied`;
|
||||
unset server key ⇒ all rejected (fail-closed); LocalDb sync key unaffected.
|
||||
2. **Site→central matrix:** notification e2e (delivered + status query), audit + cached-telemetry
|
||||
rows in `dbo.AuditLog`/site-calls, `/monitoring/health` live per site, heartbeat → active
|
||||
flag, reconcile self-heal after site-node restart.
|
||||
3. **Central→site matrix:** all 6 RPC groups exercised from CentralUI, parked retry/discard on
|
||||
the standby node, failover command drains cleanly.
|
||||
4. **Failover/failback:** kill central-a → sites flip to central-b sticky (S&F uninterrupted);
|
||||
restart central-a → failback within probe cadence; same for a site node from central's side;
|
||||
booting node rejects `Unavailable` until ready and the client fails over.
|
||||
5. **Mid-drain kill:** kill central during an S&F drain burst — zero loss, zero duplicates.
|
||||
6. **Frame-class retirement:** issue a command/reply > 128 KB (large browse/event-log result) —
|
||||
succeeds over gRPC (impossible before).
|
||||
7. **Boundary check:** with everything on gRPC, verify NO Akka association exists between any
|
||||
site container and central (`netstat`/Akka logs) — remoting is pair-internal only.
|
||||
8. **Restart discipline:** full-rig restart (pairs together) comes up clean; no receptionist/
|
||||
ClusterClient log lines anywhere.
|
||||
|
||||
Record results in `docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md` (check-by-check,
|
||||
the family's live-gate format).
|
||||
|
||||
## Effort & parallelization summary
|
||||
|
||||
| Track | Est. | Parallel with |
|
||||
|---|---|---|
|
||||
| Phase 0 | 2–3 d | 1A/1B proto authoring |
|
||||
| 1A | 1.5–2 wk | 1B (separate worktrees; 1A merges first) |
|
||||
| 1B | 2–3 wk (mapper-heavy) | 1A |
|
||||
| 2, 3 | 2–4 d each | each other (independent flags/paths) |
|
||||
| 4 | 2–3 d | — |
|
||||
| 5 | 2–3 d | — |
|
||||
|
||||
Critical path ≈ 1B: **~4–6 weeks total**, matching the design estimate.
|
||||
|
||||
## Task checklist (tick as you go; IDs reference the sections above)
|
||||
|
||||
**Phase 0 — PSK + dead code** (branch `feat/grpc-phase0-psk`)
|
||||
- [x] T0.1 Delete `/user/management` receptionist registration (`AkkaHostedService.cs:459`) + Component-Host.md update
|
||||
- [x] T0.2 File dead-`IntegrationCallRequest` issue; record exclusion (28 of 29 migrate)
|
||||
- [x] T0.3 `ControlPlaneAuthInterceptor` + `CommunicationOptions.GrpcPsk` + `SitePskProvider`; gate SiteStream; PSK attached on central's streaming + pull clients
|
||||
- [x] T0.4 Rig dev keys (all 3 sites + central store seeds) + interceptor/provider/wiring tests
|
||||
- [ ] Phase 0 DoD: suite green; rig unauthenticated ⇒ `PermissionDenied`, authenticated paths work; PR merged
|
||||
|
||||
**Phase 1A — central control plane** (worktree, `feat/grpc-central-control`)
|
||||
- [ ] T1A.1 `central_control.proto` (7 RPCs; checked-in codegen) + `CentralControlDtoMapper` + round-trip golden tests
|
||||
- [ ] T1A.2 Central hosting: `AddGrpc` + per-site-PSK interceptor (`x-scadabridge-site`), `CentralGrpcPort` h2c listener (8083), `CentralControlGrpcService` (Ask existing handlers), readiness gate
|
||||
- [ ] T1A.3 `ICentralTransport` (Akka extract + Grpc impl), `CentralChannelProvider` (sticky failover/failback, backoff, deadlines, PSK), `CentralTransport` flag default `Akka`, `CentralGrpcEndpoints` option + validator
|
||||
- [ ] T1A.4 Tests: actor-with-fake-transport ×7, TestServer transport tests, S&F/audit/health suites pass unmodified
|
||||
- [ ] 1A DoD: rig site-a on `Grpc` proves all 5 site→central paths while site-b/c stay Akka; PR merged (before 1B)
|
||||
|
||||
**Phase 1B — site command plane** (worktree, `feat/grpc-sitecommand`)
|
||||
- [ ] T1B.1 `site_command.proto` (6 oneof RPCs / 28 commands) + `SiteCommandDtoMapper` + round-trip golden tests (all 28 + replies)
|
||||
- [ ] T1B.2 `SiteCommandDispatcher` refactor (actor + new `SiteCommandGrpcService` share it; parked stays node-local)
|
||||
- [ ] T1B.3 `ISiteCommandTransport` in `CentralCommunicationActor` (Akka extract + Grpc impl), `SitePairChannelProvider` (Site entity Grpc columns + DB refresh loop), `SiteTransport` flag default `Akka`
|
||||
- [ ] T1B.4 Tests: dispatcher routing ×28, actor envelope/reply plumbing, TestServer service tests, existing Communication suites green
|
||||
- [ ] 1B DoD: rig central on `Grpc` for site-a proves full command matrix incl. standby parked retry; rebased on 1A; PR merged
|
||||
|
||||
**Phase 2 ∥ 3 — cutover + soak**
|
||||
- [ ] P2 All sites `CentralTransport=Grpc`; central-kill S&F soak (no loss/dupes), failback observed, health sequences clean
|
||||
- [ ] P3 Central `SiteTransport=Grpc` all sites; full UI command matrix per site; site-kill mid-command clean; no PSK noise; zero ClusterClient log activity on flipped paths
|
||||
|
||||
**Phase 4 — deletion**
|
||||
- [ ] Defaults flip to `Grpc` + soak; then delete Akka transports, ClusterClient creation, `DefaultSiteClientFactory`, receptionist registrations, `CentralContactPoints`, then the flags
|
||||
- [ ] Grep-gates pass (`clusterclient|receptionist` → historical docs only; `CentralContactPoints` → empty)
|
||||
- [ ] Docs updated: `grpc_streams.md`, `Component-Host.md`, `Component-StoreAndForward.md:137`, known-issues cross-ref
|
||||
|
||||
**Phase 5 — live gate** (record in `2026-07-22-clusterclient-to-grpc-live-gate.md`)
|
||||
- [ ] 1 PSK negatives · [ ] 2 site→central matrix · [ ] 3 central→site matrix · [ ] 4 failover/failback both directions · [ ] 5 mid-drain kill · [ ] 6 >128 KB frame-class proof · [ ] 7 no cross-boundary Akka association · [ ] 8 full-rig restart clean
|
||||
|
||||
## Gotchas for the executor (will bite; read twice)
|
||||
|
||||
- Generated proto C# is committed; never add an active `<Protobuf>` item to the csproj in a
|
||||
final commit (linux_arm64 protoc segfault breaks the Docker build).
|
||||
- `HeartbeatMessage` must stay fire-and-forget end-to-end — don't let a gRPC failure surface as
|
||||
a fault to the heartbeat timer path.
|
||||
- Deadline ≠ retry: no automatic cross-node retry on `DeadlineExceeded` for WriteTag/Deploy/
|
||||
Failover; only on provably-unsent failures.
|
||||
- The parked-message handler is node-local **on purpose** (replicated store); do not "fix" it
|
||||
onto the singleton proxy.
|
||||
- Ack-before-Leave on `TriggerSiteFailover` (`SiteCommunicationActor.cs:569`): the gRPC reply
|
||||
must be written before the node leaves — verify the response completes under failover.
|
||||
- Inner-before-outer timeouts: `RelayTimeout`(10 s) < `QueryTimeout`(30 s) must survive the
|
||||
deadline mapping (`CommunicationService.cs:779-786` documents why).
|
||||
- `SiteStreamGrpcServer.AuditIngestAskTimeout` (30 s) is "one source of truth" shared with
|
||||
`CentralCommunicationActor` — keep the new central service on the same constant.
|
||||
- Rig sites reach central by container name (`scadabridge-central-{a,b}:8083`), NOT via traefik
|
||||
(HTTP/1 only).
|
||||
- xunit v2: use `Xunit.SkippableFact` for env-gated tests, not `Assert.Skip`.
|
||||
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"plan": "docs/plans/2026-07-22-clusterclient-to-grpc-plan.md",
|
||||
"design": "~/Desktop/scadaproj/scadabridge_clusterclient_to_grpc.md",
|
||||
"worktrees": {
|
||||
"feat/grpc-phase0-psk": "/Users/dohertj2/Desktop/ScadaBridge-phase0"
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T0.1",
|
||||
"phase": "0",
|
||||
"subject": "Delete the vestigial /user/management receptionist registration",
|
||||
"status": "completed",
|
||||
"activeForm": "Deleting the /user/management receptionist registration",
|
||||
"files": [
|
||||
"src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs",
|
||||
"docs/requirements/Component-Host.md",
|
||||
"docs/requirements/Component-ManagementService.md",
|
||||
"docs/requirements/Component-Communication.md",
|
||||
"docs/components/ManagementService.md",
|
||||
"docs/components/Communication.md",
|
||||
"src/ZB.MOM.WW.ScadaBridge.CLI/README.md"
|
||||
],
|
||||
"notes": "Actor stays; only the ClusterClientReceptionist.RegisterService call goes. Verified: CLI is Akka-free, so 6 docs claiming the CLI reaches ManagementActor over ClusterClient are stale and must be corrected in the same change."
|
||||
},
|
||||
{
|
||||
"id": "T0.2",
|
||||
"phase": "0",
|
||||
"subject": "Record the dead IntegrationCallRequest exclusion (28 of 29 commands migrate)",
|
||||
"status": "completed",
|
||||
"activeForm": "Recording the dead IntegrationCallRequest exclusion",
|
||||
"files": [
|
||||
"docs/known-issues/"
|
||||
],
|
||||
"notes": "Plan says 'file a Gitea issue' \u2014 outward-facing, handed to the user. In-repo half is a known-issues note + the exclusion record."
|
||||
},
|
||||
{
|
||||
"id": "T0.3",
|
||||
"phase": "0",
|
||||
"subject": "ControlPlaneAuthInterceptor + CommunicationOptions.GrpcPsk + SitePskProvider; gate SiteStream; attach PSK on central's clients",
|
||||
"status": "completed",
|
||||
"activeForm": "Building the control-plane PSK auth",
|
||||
"files": [
|
||||
"src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/ISitePskProvider.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Host/SitePskProvider.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClientFactory.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs",
|
||||
"src/ZB.MOM.WW.ScadaBridge.Host/Program.cs"
|
||||
],
|
||||
"notes": "Service prefix verified from sitestream.proto: /sitestream.SiteStreamService/. Grpc 2.76 -> CallCredentials.FromInterceptor + UnsafeUseInsecureChannelCallCredentials is the async-safe attach path on h2c."
|
||||
},
|
||||
{
|
||||
"id": "T0.4",
|
||||
"phase": "0",
|
||||
"subject": "Rig dev keys (3 sites + central secret seeds) + interceptor/provider/wiring tests",
|
||||
"status": "completed",
|
||||
"activeForm": "Seeding rig dev keys and writing the auth tests",
|
||||
"files": [
|
||||
"docker/site-a-node-a/appsettings.Site.json",
|
||||
"docker/site-a-node-b/appsettings.Site.json",
|
||||
"docker/site-b-node-a/appsettings.Site.json",
|
||||
"docker/site-b-node-b/appsettings.Site.json",
|
||||
"docker/site-c-node-a/appsettings.Site.json",
|
||||
"docker/site-c-node-b/appsettings.Site.json",
|
||||
"tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs"
|
||||
],
|
||||
"notes": "Fail-closed from day one \u2014 every environment (incl. docker-env2 and the gitignored deploy/wonder-app-vd03 overlay) needs its key before upgrade. Ops item for the user."
|
||||
},
|
||||
{
|
||||
"id": "P0.DoD",
|
||||
"phase": "0",
|
||||
"subject": "Phase 0 DoD: suite green; rig unauthenticated => PermissionDenied, authenticated paths work; PR merged",
|
||||
"status": "in_progress",
|
||||
"activeForm": "Verifying the Phase 0 DoD",
|
||||
"blockedBy": [
|
||||
"T0.1",
|
||||
"T0.2",
|
||||
"T0.3",
|
||||
"T0.4"
|
||||
],
|
||||
"notes": "Live gate PASS 2026-07-22 (all 7 checks, docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md). Suite: 29 non-Playwright suites / 6872 tests / 0 failures. Playwright 170 pass / 2 fail / 1 skip of 173 - BOTH failures root-caused and PRE-EXISTING on main, unrelated to Phase 0 (branch touches no EF/CentralUI/Transport/ManagementService file): (1) TransportImportTests - REAL production bug, BundleImporter.cs:1298 user-initiated transaction + EnableRetryOnFailure => import broken on real MS SQL, hidden by the in-memory EF provider; (2) SmsNotificationE2ETests - stale fixture SID 'ACtest123' vs the ^AC[0-9a-fA-F]{32}$ guard added 2026-07-10 (40088a21); failing since then, which also silences its secret-non-leak assertion. The earlier 44-failure run is VOID (concurrent rig rebuild). REMAINING: PR + merge, HELD for the user per the plan's stop-at-DoD rule."
|
||||
},
|
||||
{
|
||||
"id": "T1A.1",
|
||||
"phase": "1A",
|
||||
"subject": "central_control.proto (7 RPCs, checked-in codegen) + CentralControlDtoMapper + round-trip golden tests",
|
||||
"status": "pending",
|
||||
"activeForm": "Authoring central_control.proto and its mappers",
|
||||
"blockedBy": [
|
||||
"P0.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1A.2",
|
||||
"phase": "1A",
|
||||
"subject": "Central hosting: AddGrpc + per-site-PSK interceptor, CentralGrpcPort h2c listener, CentralControlGrpcService, readiness gate",
|
||||
"status": "pending",
|
||||
"activeForm": "Hosting CentralControlService on central",
|
||||
"blockedBy": [
|
||||
"T1A.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1A.3",
|
||||
"phase": "1A",
|
||||
"subject": "ICentralTransport (Akka extract + Grpc impl), CentralChannelProvider, CentralTransport flag, CentralGrpcEndpoints option",
|
||||
"status": "pending",
|
||||
"activeForm": "Building the site->central transport seam",
|
||||
"blockedBy": [
|
||||
"T1A.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1A.4",
|
||||
"phase": "1A",
|
||||
"subject": "Tests: actor-with-fake-transport x7, TestServer transport tests, S&F/audit/health suites pass unmodified",
|
||||
"status": "pending",
|
||||
"activeForm": "Testing the central control plane",
|
||||
"blockedBy": [
|
||||
"T1A.2",
|
||||
"T1A.3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P1A.DoD",
|
||||
"phase": "1A",
|
||||
"subject": "1A DoD: rig site-a on Grpc proves all 5 site->central paths while site-b/c stay Akka; PR merged before 1B",
|
||||
"status": "pending",
|
||||
"activeForm": "Verifying the 1A DoD",
|
||||
"blockedBy": [
|
||||
"T1A.4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1B.1",
|
||||
"phase": "1B",
|
||||
"subject": "site_command.proto (6 oneof RPCs / 28 commands) + SiteCommandDtoMapper + round-trip golden tests",
|
||||
"status": "pending",
|
||||
"activeForm": "Authoring site_command.proto and its mappers",
|
||||
"blockedBy": [
|
||||
"P0.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1B.2",
|
||||
"phase": "1B",
|
||||
"subject": "SiteCommandDispatcher refactor (actor + SiteCommandGrpcService share it; parked stays node-local)",
|
||||
"status": "pending",
|
||||
"activeForm": "Extracting the site command dispatcher",
|
||||
"blockedBy": [
|
||||
"T1B.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1B.3",
|
||||
"phase": "1B",
|
||||
"subject": "ISiteCommandTransport in CentralCommunicationActor (Akka extract + Grpc impl), SitePairChannelProvider, SiteTransport flag",
|
||||
"status": "pending",
|
||||
"activeForm": "Building the central->site transport seam",
|
||||
"blockedBy": [
|
||||
"T1B.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T1B.4",
|
||||
"phase": "1B",
|
||||
"subject": "Tests: dispatcher routing x28, actor envelope/reply plumbing, TestServer service tests, existing suites green",
|
||||
"status": "pending",
|
||||
"activeForm": "Testing the site command plane",
|
||||
"blockedBy": [
|
||||
"T1B.2",
|
||||
"T1B.3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P1B.DoD",
|
||||
"phase": "1B",
|
||||
"subject": "1B DoD: rig central on Grpc for site-a proves full command matrix incl. standby parked retry; rebased on 1A; PR merged",
|
||||
"status": "pending",
|
||||
"activeForm": "Verifying the 1B DoD",
|
||||
"blockedBy": [
|
||||
"T1B.4",
|
||||
"P1A.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P2",
|
||||
"phase": "2",
|
||||
"subject": "All sites CentralTransport=Grpc; central-kill S&F soak, failback observed, health sequences clean",
|
||||
"status": "pending",
|
||||
"activeForm": "Running the site->central cutover soak",
|
||||
"blockedBy": [
|
||||
"P1A.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P3",
|
||||
"phase": "3",
|
||||
"subject": "Central SiteTransport=Grpc all sites; full UI command matrix; site-kill mid-command clean; zero ClusterClient activity",
|
||||
"status": "pending",
|
||||
"activeForm": "Running the central->site cutover soak",
|
||||
"blockedBy": [
|
||||
"P1B.DoD"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P4.1",
|
||||
"phase": "4",
|
||||
"subject": "Flip both flag defaults to Grpc + soak; delete Akka transports, ClusterClient creation, DefaultSiteClientFactory, receptionist registrations, CentralContactPoints, then the flags",
|
||||
"status": "pending",
|
||||
"activeForm": "Deleting the ClusterClient transport",
|
||||
"blockedBy": [
|
||||
"P2",
|
||||
"P3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P4.2",
|
||||
"phase": "4",
|
||||
"subject": "Grep-gates pass + docs updated (grpc_streams.md, Component-Host.md, Component-StoreAndForward.md, known-issues cross-ref)",
|
||||
"status": "pending",
|
||||
"activeForm": "Running the deletion grep-gates and doc sweep",
|
||||
"blockedBy": [
|
||||
"P4.1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "P5",
|
||||
"phase": "5",
|
||||
"subject": "Live gate, 8 checks, recorded in docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md",
|
||||
"status": "pending",
|
||||
"activeForm": "Running the live gate",
|
||||
"blockedBy": [
|
||||
"P4.2"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -40,16 +40,16 @@
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 10 (OPTIONAL, ask user): site-pair failover via central-to-site transport",
|
||||
"status": "pending",
|
||||
"subject": "Task 10: site-pair failover via central\u2192site transport (BUILT \u2014 user approved 2026-07-22)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "FOLLOW-UP (owner deferred): correct the false InitJoin safety claim in scadaproj + OtOpcUa design docs",
|
||||
"status": "pending"
|
||||
"subject": "FOLLOW-UP: OtOpcUa converged on self-first seed ordering (2 local commits, unpushed); scadaproj CLAUDE.md index rows still owner-held",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
|
||||
@@ -152,6 +152,24 @@ Semantics:
|
||||
|
||||
After the Leave the node's `ActorSystem` terminates, the `WhenTerminated` watchdog exits the process, the service supervisor restarts it, and it rejoins as the youngest member (the new standby). Recovery is the normal restart contract above — no interaction with seed ordering, since the peer is alive on this path.
|
||||
|
||||
#### Site-pair failover
|
||||
|
||||
The same control appears on each **site** card. Central and each site are separate Akka clusters, so central cannot act on a site's membership — it *asks*, over the existing ClusterClient command/control channel:
|
||||
|
||||
1. `CommunicationService.TriggerSiteFailoverAsync` sends a `TriggerSiteFailover` inside a `SiteEnvelope`.
|
||||
2. The site's `SiteCommunicationActor` (registered per node, so contact rotation reaches whichever answers) resolves the target from cluster state and issues the graceful `Leave` locally.
|
||||
3. It replies `SiteFailoverAck` — sent **before** the Leave takes effect, so the ack still arrives when the acking node is the one leaving.
|
||||
|
||||
Differences from central failover, all deliberate:
|
||||
|
||||
- **Role scope is `site-{SiteId}`, not the base `Site` role.** Site singletons (the Deployment Manager) are placed on the site-specific role; using the base role would move the wrong node. Pinned by both a unit test asserting the role string and a real-cluster test.
|
||||
- **Misroute is refused.** A command whose `SiteId` does not match the receiving node's is rejected rather than acted on — a misroute must never fail over a site the operator did not select.
|
||||
- **A fault is acked, not thrown.** Reporting through the ack keeps the reason; letting it reach supervision would restart the communication actor and reduce central's Ask to a bare timeout.
|
||||
- **The operator's own session is unaffected** — a different cluster entirely. The confirmation dialog therefore does *not* carry the "this page will disconnect" warning that the central one does.
|
||||
- **Refusal vs unreachable are distinct.** A `false` ack is a definitive answer from the site (peer guard, misroute); a timeout means the site never answered. Both surface to the operator with their own wording, because only one of them means "nothing happened".
|
||||
|
||||
**Rolling upgrade.** A site running a binary older than this contract has no handler for `TriggerSiteFailover`; the message dead-letters and central's Ask times out, reported as "site did not respond". That is the correct user-facing outcome — an old site genuinely cannot honour the request. Message evolution stays additive-only.
|
||||
|
||||
## Single-Node Operation
|
||||
|
||||
`akka.cluster.min-nr-of-members` must be set to **1**. After failover, only one node is running. If set to 2, the surviving node waits for a second member before allowing the Cluster Singleton (Site Runtime Deployment Manager) to start — blocking all data collection and script execution indefinitely.
|
||||
|
||||
@@ -109,6 +109,31 @@ The streaming protocol is defined in `sitestream.proto` (`src/ZB.MOM.WW.ScadaBri
|
||||
- The `oneof event` pattern is extensible — future event types (health metrics, connection state changes) are added as new fields without breaking existing consumers.
|
||||
- Proto field numbers are never reused; new RPCs and message fields are appended additively. Old clients ignore unknown `oneof` variants.
|
||||
|
||||
##### Authentication (preshared key, 2026-07-22)
|
||||
|
||||
Every RPC on this service — streaming and unary alike — requires a preshared key, presented as
|
||||
`authorization: Bearer <key>` metadata and verified by `ControlPlaneAuthInterceptor` on the site
|
||||
node. The gate is **fail-closed**: with no key configured, every call is refused with
|
||||
`PermissionDenied`, and `StartupValidator` refuses to boot a site node in that state (an unset key
|
||||
would otherwise leave the node joined and reporting healthy while serving nothing).
|
||||
|
||||
Keys are scoped **one per site** — secret `SB-GRPC-PSK-<siteId>`, so a compromised site never
|
||||
yields another site's key. The site node reads its own key from
|
||||
`ScadaBridge:Communication:GrpcPsk` (in production a `${secret:}` reference expanded before the
|
||||
host is built); central resolves each site's key at channel-build time via `SitePskProvider`,
|
||||
because sites are created at runtime and cannot be enumerated in configuration at boot.
|
||||
`ControlPlaneCredentials` binds the key to the channel as `CallCredentials`, so no individual call
|
||||
site can omit it.
|
||||
|
||||
This is distinct from `LocalDb:Replication:ApiKey`, which gates
|
||||
`/localdb_sync.v1.LocalDbSync/` on the same listener via its own interceptor: that key
|
||||
authenticates the *pair partner* for database replication, a different trust relationship, and the
|
||||
two are never shared.
|
||||
|
||||
The transport remains h2c, so the key is readable and replayable by anyone on the path — the
|
||||
boundary still assumes a trusted network, with the bar raised from "can reach the port" to "can
|
||||
read the traffic". TLS is follow-on hardening and does not change this design.
|
||||
|
||||
#### Enriched AlarmStateUpdate (Native Alarm Mirror)
|
||||
|
||||
`AlarmStateUpdate` carries the read-only native alarm mirror (Computed, native OPC UA, and native MxAccess Gateway alarms) to central over the **existing gRPC real-time stream** — no new transport, no command/control round-trip. The message was extended **additively**: existing fields 1–7 are unchanged, and fields 8–23 carry the enriched native-alarm state. Old clients that only read fields 1–7 continue to work; new fields are populated only where the source provides them.
|
||||
@@ -306,7 +331,9 @@ Akka.NET guarantees message ordering between a specific sender/receiver actor pa
|
||||
|
||||
## ManagementActor and ClusterClient
|
||||
|
||||
The ManagementActor is registered at the well-known path `/user/management` on central nodes and advertised via **ClusterClientReceptionist**. External tools (primarily the CLI) connect using Akka.NET ClusterClient, which contacts the receptionist to discover the ManagementActor. This is a separate ClusterClient usage from the inter-cluster ClusterClient connections used for central-site messaging — the CLI does not participate in cluster membership or affect the hub-and-spoke topology.
|
||||
The ManagementActor runs at the well-known path `/user/management` on central nodes. It is **not** advertised via ClusterClientReceptionist, and no ClusterClient reaches it.
|
||||
|
||||
That registration existed until 2026-07-22 for an out-of-cluster CLI that was never built. The shipped CLI speaks **HTTP Basic to the central `/management` endpoints**; those endpoints ask the ManagementActor **in-process** through `ManagementActorHolder` (`ManagementEndpoints.cs`). Nothing in the repo ever sent to `/user/management` across the boundary, so the registration was deleted — leaving exactly one receptionist registration per cluster role (`CentralCommunicationActor` on central, `SiteCommunicationActor` on sites), both of which serve inter-cluster central↔site messaging and are themselves scheduled for removal by the gRPC transport migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`).
|
||||
|
||||
## Connection Failure Behavior
|
||||
|
||||
|
||||
@@ -125,7 +125,11 @@ The Host bootstraps the Akka.NET actor system from a **hand-assembled, injection
|
||||
|
||||
### REQ-HOST-6a: ClusterClientReceptionist (Central Only)
|
||||
|
||||
On central nodes, the Host must configure the Akka.NET **ClusterClientReceptionist** and register the ManagementActor with it. This allows external processes (e.g., the CLI) to discover and communicate with the ManagementActor via ClusterClient without joining the cluster as full members. The receptionist is started as part of the Akka.NET bootstrap (REQ-HOST-6) on central nodes only.
|
||||
On central nodes, the Host must configure the Akka.NET **ClusterClientReceptionist** and register the **CentralCommunicationActor** with it, so that site clusters' ClusterClients can reach the central command/control endpoint without joining the central cluster.
|
||||
|
||||
**The ManagementActor is NOT registered with the receptionist** (removed 2026-07-22). That registration was written for an out-of-cluster CLI that was never built: the shipped CLI speaks HTTP Basic to the central `/management` endpoints, which ask the ManagementActor **in-process** via `ManagementActorHolder` (`ManagementEndpoints.cs`). No sender to `/user/management` existed anywhere in the repo, so the registration only widened the cluster-client surface for nothing. The actor itself still runs at `/user/management`; only its cross-boundary advertisement is gone.
|
||||
|
||||
> **Migration note.** This receptionist registration — and the `CentralCommunicationActor` one that remains — are scheduled for deletion once the site↔central transport moves to gRPC. See `docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`.
|
||||
|
||||
### REQ-HOST-7: ASP.NET Web Endpoints
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Central cluster only. The ManagementActor runs as a plain actor on **every** cen
|
||||
|
||||
### ManagementActor
|
||||
|
||||
The central actor that receives and processes all management commands. Registered at a well-known actor path (`/user/management`) and with ClusterClientReceptionist.
|
||||
The central actor that receives and processes all management commands. Created at the well-known actor path (`/user/management`) and handed to `ManagementActorHolder`, which is how every in-process caller (notably `ManagementEndpoints`) reaches it. It is **not** advertised via ClusterClientReceptionist — see the note under "ManagementActor and ClusterClient" in Component-Communication.
|
||||
|
||||
### ManagementEndpoints
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed class GrpcPullAuditEventsClient : IPullAuditEventsClient
|
||||
{
|
||||
try
|
||||
{
|
||||
var reply = await _invoker.InvokeAsync(endpoint, request, ct).ConfigureAwait(false);
|
||||
var reply = await _invoker.InvokeAsync(siteId, endpoint, request, ct).ConfigureAwait(false);
|
||||
return (reply, false);
|
||||
}
|
||||
catch (RpcException ex) when (IsTolerable(ex.StatusCode))
|
||||
@@ -226,11 +226,17 @@ public sealed class GrpcPullAuditEventsClient : IPullAuditEventsClient
|
||||
/// May throw <see cref="RpcException"/> / <see cref="HttpRequestException"/>
|
||||
/// on transport faults — the caller classifies and swallows tolerable ones.
|
||||
/// </summary>
|
||||
/// <param name="siteId">
|
||||
/// The site being pulled from. Selects which preshared key the call presents —
|
||||
/// <c>PullAuditEvents</c> is gated by the site's <c>ControlPlaneAuthInterceptor</c>, and
|
||||
/// keys are per-site, so the endpoint alone is not enough to authenticate.
|
||||
/// </param>
|
||||
/// <param name="endpoint">The site gRPC authority (e.g. <c>http://site-a:8083</c>).</param>
|
||||
/// <param name="request">The wire-format pull request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The wire-format pull response.</returns>
|
||||
Task<ProtoPullResponse> InvokeAsync(string endpoint, ProtoPullRequest request, CancellationToken ct);
|
||||
Task<ProtoPullResponse> InvokeAsync(
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,8 +257,9 @@ public sealed class GrpcPullAuditEventsClient : IPullAuditEventsClient
|
||||
public sealed class GrpcPullAuditEventsInvoker
|
||||
: GrpcPullAuditEventsClient.IPullAuditEventsInvoker, IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, GrpcChannel> _channels = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<(string Site, string Endpoint), GrpcChannel> _channels = new();
|
||||
private readonly CommunicationOptions _options;
|
||||
private readonly ISitePskProvider? _pskProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the invoker using default <see cref="CommunicationOptions"/>.
|
||||
@@ -268,15 +275,27 @@ public sealed class GrpcPullAuditEventsInvoker
|
||||
/// </summary>
|
||||
/// <param name="options">Communication options supplying gRPC keepalive timings.</param>
|
||||
public GrpcPullAuditEventsInvoker(CommunicationOptions options)
|
||||
: this(options, pskProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the invoker with per-site call credentials, the production shape: the site's
|
||||
/// <c>ControlPlaneAuthInterceptor</c> refuses an unauthenticated <c>PullAuditEvents</c>.
|
||||
/// </summary>
|
||||
/// <param name="options">Communication options supplying gRPC keepalive timings.</param>
|
||||
/// <param name="pskProvider">Resolves each site's preshared key; null dials unauthenticated.</param>
|
||||
public GrpcPullAuditEventsInvoker(CommunicationOptions options, ISitePskProvider? pskProvider)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_pskProvider = pskProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProtoPullResponse> InvokeAsync(
|
||||
string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
var channel = GetOrCreateChannel(endpoint);
|
||||
var channel = GetOrCreateChannel(siteId, endpoint);
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
using var call = client.PullAuditEventsAsync(request, cancellationToken: ct);
|
||||
return await call.ResponseAsync.ConfigureAwait(false);
|
||||
@@ -288,12 +307,13 @@ public sealed class GrpcPullAuditEventsInvoker
|
||||
// pool) and the loser would leak. Create-then-GetOrAdd-then-dispose-if-lost
|
||||
// mirrors SiteStreamGrpcClientFactory: only the channel actually installed
|
||||
// survives; a channel that lost the race is disposed immediately.
|
||||
private GrpcChannel GetOrCreateChannel(string endpoint)
|
||||
private GrpcChannel GetOrCreateChannel(string siteId, string endpoint)
|
||||
{
|
||||
if (!_channels.TryGetValue(endpoint, out var channel))
|
||||
var key = (siteId, endpoint);
|
||||
if (!_channels.TryGetValue(key, out var channel))
|
||||
{
|
||||
var created = CreateChannel(endpoint);
|
||||
channel = _channels.GetOrAdd(endpoint, created);
|
||||
var created = CreateChannel(siteId, endpoint);
|
||||
channel = _channels.GetOrAdd(key, created);
|
||||
if (!ReferenceEquals(channel, created))
|
||||
{
|
||||
created.Dispose();
|
||||
@@ -302,7 +322,10 @@ public sealed class GrpcPullAuditEventsInvoker
|
||||
return channel;
|
||||
}
|
||||
|
||||
private GrpcChannel CreateChannel(string endpoint) =>
|
||||
// Keyed by (site, endpoint) rather than endpoint alone: the call credentials are bound to
|
||||
// the channel, and they are per-site, so two sites sharing an endpoint string would
|
||||
// otherwise share one channel carrying the first site's key.
|
||||
private GrpcChannel CreateChannel(string siteId, string endpoint) =>
|
||||
GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = new SocketsHttpHandler
|
||||
@@ -311,7 +334,7 @@ public sealed class GrpcPullAuditEventsInvoker
|
||||
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||
},
|
||||
});
|
||||
}.WithSiteCredentials(_pskProvider, siteId));
|
||||
|
||||
/// <summary>Disposes all cached channels.</summary>
|
||||
public void Dispose()
|
||||
|
||||
@@ -174,7 +174,7 @@ public sealed class GrpcPullSiteCallsClient : IPullSiteCallsClient
|
||||
{
|
||||
try
|
||||
{
|
||||
var reply = await _invoker.InvokeAsync(endpoint, request, ct).ConfigureAwait(false);
|
||||
var reply = await _invoker.InvokeAsync(siteId, endpoint, request, ct).ConfigureAwait(false);
|
||||
return (reply, false);
|
||||
}
|
||||
catch (RpcException ex) when (IsTolerable(ex.StatusCode))
|
||||
@@ -254,11 +254,17 @@ public sealed class GrpcPullSiteCallsClient : IPullSiteCallsClient
|
||||
/// May throw <see cref="RpcException"/> / <see cref="HttpRequestException"/>
|
||||
/// on transport faults — the caller classifies and swallows tolerable ones.
|
||||
/// </summary>
|
||||
/// <param name="siteId">
|
||||
/// The site being pulled from. Selects which preshared key the call presents —
|
||||
/// <c>PullSiteCalls</c> is gated by the site's <c>ControlPlaneAuthInterceptor</c>, and
|
||||
/// keys are per-site, so the endpoint alone is not enough to authenticate.
|
||||
/// </param>
|
||||
/// <param name="endpoint">The site gRPC authority (e.g. <c>http://site-a:8083</c>).</param>
|
||||
/// <param name="request">The wire-format pull request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The wire-format pull response.</returns>
|
||||
Task<ProtoPullResponse> InvokeAsync(string endpoint, ProtoPullRequest request, CancellationToken ct);
|
||||
Task<ProtoPullResponse> InvokeAsync(
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,8 +283,9 @@ public sealed class GrpcPullSiteCallsClient : IPullSiteCallsClient
|
||||
public sealed class GrpcPullSiteCallsInvoker
|
||||
: GrpcPullSiteCallsClient.IPullSiteCallsInvoker, IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, GrpcChannel> _channels = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<(string Site, string Endpoint), GrpcChannel> _channels = new();
|
||||
private readonly CommunicationOptions _options;
|
||||
private readonly ISitePskProvider? _pskProvider;
|
||||
|
||||
/// <summary>Creates the invoker using default <see cref="CommunicationOptions"/>.</summary>
|
||||
public GrpcPullSiteCallsInvoker()
|
||||
@@ -292,15 +299,27 @@ public sealed class GrpcPullSiteCallsInvoker
|
||||
/// </summary>
|
||||
/// <param name="options">Communication options supplying gRPC keepalive timings.</param>
|
||||
public GrpcPullSiteCallsInvoker(CommunicationOptions options)
|
||||
: this(options, pskProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the invoker with per-site call credentials, the production shape: the site's
|
||||
/// <c>ControlPlaneAuthInterceptor</c> refuses an unauthenticated <c>PullSiteCalls</c>.
|
||||
/// </summary>
|
||||
/// <param name="options">Communication options supplying gRPC keepalive timings.</param>
|
||||
/// <param name="pskProvider">Resolves each site's preshared key; null dials unauthenticated.</param>
|
||||
public GrpcPullSiteCallsInvoker(CommunicationOptions options, ISitePskProvider? pskProvider)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_pskProvider = pskProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProtoPullResponse> InvokeAsync(
|
||||
string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
var channel = GetOrCreateChannel(endpoint);
|
||||
var channel = GetOrCreateChannel(siteId, endpoint);
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
using var call = client.PullSiteCallsAsync(request, cancellationToken: ct);
|
||||
return await call.ResponseAsync.ConfigureAwait(false);
|
||||
@@ -310,12 +329,13 @@ public sealed class GrpcPullSiteCallsInvoker
|
||||
// concurrent first dials of the same endpoint can both build a GrpcChannel;
|
||||
// only the channel actually installed survives, the loser is disposed.
|
||||
// Mirrors SiteStreamGrpcClientFactory / GrpcPullAuditEventsInvoker.
|
||||
private GrpcChannel GetOrCreateChannel(string endpoint)
|
||||
private GrpcChannel GetOrCreateChannel(string siteId, string endpoint)
|
||||
{
|
||||
if (!_channels.TryGetValue(endpoint, out var channel))
|
||||
var key = (siteId, endpoint);
|
||||
if (!_channels.TryGetValue(key, out var channel))
|
||||
{
|
||||
var created = CreateChannel(endpoint);
|
||||
channel = _channels.GetOrAdd(endpoint, created);
|
||||
var created = CreateChannel(siteId, endpoint);
|
||||
channel = _channels.GetOrAdd(key, created);
|
||||
if (!ReferenceEquals(channel, created))
|
||||
{
|
||||
created.Dispose();
|
||||
@@ -324,7 +344,10 @@ public sealed class GrpcPullSiteCallsInvoker
|
||||
return channel;
|
||||
}
|
||||
|
||||
private GrpcChannel CreateChannel(string endpoint) =>
|
||||
// Keyed by (site, endpoint) rather than endpoint alone: the call credentials are bound to
|
||||
// the channel, and they are per-site, so two sites sharing an endpoint string would
|
||||
// otherwise share one channel carrying the first site's key.
|
||||
private GrpcChannel CreateChannel(string siteId, string endpoint) =>
|
||||
GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = new SocketsHttpHandler
|
||||
@@ -333,7 +356,7 @@ public sealed class GrpcPullSiteCallsInvoker
|
||||
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||
},
|
||||
});
|
||||
}.WithSiteCredentials(_pskProvider, siteId));
|
||||
|
||||
/// <summary>Disposes all cached channels.</summary>
|
||||
public void Dispose()
|
||||
|
||||
@@ -511,9 +511,14 @@ public static class ServiceCollectionExtensions
|
||||
var options = sp
|
||||
.GetService<Microsoft.Extensions.Options.IOptions<
|
||||
ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions>>();
|
||||
// The PSK provider is central-only and optional in DI, so GetService (not
|
||||
// GetRequiredService): a host without one dials unauthenticated and the site
|
||||
// refuses it, which is the fail-closed outcome we want rather than a
|
||||
// resolution crash at composition time.
|
||||
var psk = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.ISitePskProvider>();
|
||||
return options is null
|
||||
? new GrpcPullAuditEventsInvoker()
|
||||
: new GrpcPullAuditEventsInvoker(options.Value);
|
||||
? new GrpcPullAuditEventsInvoker(new ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions(), psk)
|
||||
: new GrpcPullAuditEventsInvoker(options.Value, psk);
|
||||
});
|
||||
services.TryAddSingleton<GrpcPullAuditEventsClient.IPullAuditEventsInvoker>(
|
||||
sp => sp.GetRequiredService<GrpcPullAuditEventsInvoker>());
|
||||
@@ -536,9 +541,14 @@ public static class ServiceCollectionExtensions
|
||||
var options = sp
|
||||
.GetService<Microsoft.Extensions.Options.IOptions<
|
||||
ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions>>();
|
||||
// The PSK provider is central-only and optional in DI, so GetService (not
|
||||
// GetRequiredService): a host without one dials unauthenticated and the site
|
||||
// refuses it, which is the fail-closed outcome we want rather than a
|
||||
// resolution crash at composition time.
|
||||
var psk = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.ISitePskProvider>();
|
||||
return options is null
|
||||
? new GrpcPullSiteCallsInvoker()
|
||||
: new GrpcPullSiteCallsInvoker(options.Value);
|
||||
? new GrpcPullSiteCallsInvoker(new ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions(), psk)
|
||||
: new GrpcPullSiteCallsInvoker(options.Value, psk);
|
||||
});
|
||||
services.TryAddSingleton<GrpcPullSiteCallsClient.IPullSiteCallsInvoker>(
|
||||
sp => sp.GetRequiredService<GrpcPullSiteCallsInvoker>());
|
||||
|
||||
@@ -1987,9 +1987,11 @@ scadabridge --url <url> cached-call discard --site-id <string> --tracked-operati
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
The CLI connects to the Central cluster using Akka.NET's `ClusterClient`. It does not join the cluster — it contacts the `ClusterClientReceptionist` on one of the configured Central nodes and sends commands to the `ManagementActor` at path `/user/management`.
|
||||
The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It carries HTTP **Basic** credentials from `--username`/`--password`. Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`.
|
||||
|
||||
The connection is established per-command invocation and torn down cleanly via `CoordinatedShutdown` when the command completes.
|
||||
There is no Akka dependency in the CLI at all: it does not join the cluster, does not use `ClusterClient`, and does not contact a `ClusterClientReceptionist`. (Earlier revisions of this document described a ClusterClient transport that was never built.)
|
||||
|
||||
An `HttpClient` is created per command invocation and disposed when the command completes.
|
||||
|
||||
Role enforcement is applied by the ManagementActor on the server side. The CLI authenticates against LDAP using `--username` / `--password`, resolves LDAP group memberships, then maps groups to ScadaBridge roles (Admin, Design, Deployment) via role mappings configured in the security settings. Operations require the appropriate role — for example, creating templates requires `Design`, deploying requires `Deployment`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles.
|
||||
|
||||
|
||||
+58
-17
@@ -13,7 +13,9 @@
|
||||
<button class="btn btn-outline-warning btn-sm"
|
||||
disabled="@(!HasPeer || _busy)"
|
||||
title="@(HasPeer
|
||||
? "Gracefully restart the active node; the standby takes over."
|
||||
? (IsSite
|
||||
? $"Gracefully restart site {SiteId}'s active node; its standby takes over."
|
||||
: "Gracefully restart the active node; the standby takes over.")
|
||||
: "No standby available — failing over a lone node would be an outage, not a failover.")"
|
||||
@onclick="TriggerAsync">
|
||||
@(_busy ? "Failing over…" : "Trigger failover")
|
||||
@@ -34,6 +36,17 @@
|
||||
[Parameter]
|
||||
public int OnlineCentralNodeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, this control fails over the named SITE pair instead of the central pair.
|
||||
/// Sites are separate Akka clusters reached over the command/control relay, so the site
|
||||
/// path cannot disturb the admin's own session — which is why the confirmation text
|
||||
/// differs. <c>null</c> (the default) means the central pair.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? SiteId { get; set; }
|
||||
|
||||
private bool IsSite => !string.IsNullOrWhiteSpace(SiteId);
|
||||
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthState { get; set; }
|
||||
|
||||
@@ -45,16 +58,26 @@
|
||||
|
||||
private async Task TriggerAsync()
|
||||
{
|
||||
// The admin is almost certainly connected THROUGH the node about to restart (Traefik
|
||||
// routes to the active node), so the dialog has to set that expectation or a working
|
||||
// failover reads as a crash they just caused.
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Trigger central failover?",
|
||||
"The active central node will leave the cluster and restart; the standby takes over "
|
||||
+ "and becomes active. Cluster singletons hand over gracefully, but in-flight work on "
|
||||
+ "the active node is interrupted. This page is served by the active node, so it will "
|
||||
+ "briefly disconnect and reconnect against the new active node.",
|
||||
danger: true);
|
||||
// Central: the admin is almost certainly connected THROUGH the node about to restart
|
||||
// (Traefik routes to the active node), so the dialog must set that expectation or a
|
||||
// working failover reads as a crash they caused.
|
||||
// Site: a different cluster entirely — this page is unaffected, and claiming otherwise
|
||||
// would train operators to ignore the warning that does matter.
|
||||
var confirmed = IsSite
|
||||
? await Dialog.ConfirmAsync(
|
||||
$"Trigger failover for site {SiteId}?",
|
||||
$"The active node of site {SiteId} will leave its cluster and restart; the site's "
|
||||
+ "standby takes over and its Deployment Manager singleton hands over gracefully. "
|
||||
+ "In-flight work on that site node is interrupted, and the site is briefly "
|
||||
+ "unavailable while the handover completes.",
|
||||
danger: true)
|
||||
: await Dialog.ConfirmAsync(
|
||||
"Trigger central failover?",
|
||||
"The active central node will leave the cluster and restart; the standby takes over "
|
||||
+ "and becomes active. Cluster singletons hand over gracefully, but in-flight work on "
|
||||
+ "the active node is interrupted. This page is served by the active node, so it will "
|
||||
+ "briefly disconnect and reconnect against the new active node.",
|
||||
danger: true);
|
||||
|
||||
if (!confirmed)
|
||||
{
|
||||
@@ -68,17 +91,35 @@
|
||||
try
|
||||
{
|
||||
var actor = await ResolveActorAsync();
|
||||
var target = await Failover.FailOverCentralAsync(actor);
|
||||
|
||||
if (target is null)
|
||||
if (IsSite)
|
||||
{
|
||||
// The server-side peer guard refused. Never report a failover that did not happen.
|
||||
_failed = true;
|
||||
_message = "Refused: no standby available to take over.";
|
||||
var outcome = await Failover.FailOverSiteAsync(SiteId!, actor);
|
||||
if (outcome.Accepted)
|
||||
{
|
||||
_message = $"Failover triggered — {outcome.TargetAddress} is leaving the site cluster.";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Carry the site's own words through: a peer-guard refusal and an
|
||||
// unreachable site are different situations for the operator.
|
||||
_failed = true;
|
||||
_message = outcome.ErrorMessage ?? "Refused by the site.";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_message = $"Failover triggered — {target} is leaving the cluster.";
|
||||
var target = await Failover.FailOverCentralAsync(actor);
|
||||
if (target is null)
|
||||
{
|
||||
// The server-side peer guard refused. Never report a failover that did not happen.
|
||||
_failed = true;
|
||||
_message = "Refused: no standby available to take over.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_message = $"Failover triggered — {target} is leaving the cluster.";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -219,12 +219,11 @@
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
@if (isCentral)
|
||||
{
|
||||
@* Admin-only; the control gates itself, and disables when the
|
||||
central pair has no online standby to take over. *@
|
||||
<CentralFailoverControl OnlineCentralNodeCount="@OnlineNodeCount(state)" />
|
||||
}
|
||||
@* Admin-only; the control gates itself, and disables when the pair has
|
||||
no online standby to take over. Central acts on the local cluster;
|
||||
a site is asked over the command/control relay. *@
|
||||
<CentralFailoverControl OnlineCentralNodeCount="@OnlineNodeCount(state)"
|
||||
SiteId="@(isCentral ? null : siteId)" />
|
||||
<small class="text-muted">
|
||||
Last report: <TimestampDisplay Value="@state.LastReportReceivedAt" Format="HH:mm:ss" NullText="awaiting first report" />
|
||||
| Last heartbeat: <TimestampDisplay Value="@state.LastHeartbeatAt" Format="HH:mm:ss" />
|
||||
|
||||
@@ -21,4 +21,28 @@ public interface IManualFailoverService
|
||||
/// <returns>The address acted on, or <c>null</c> when there is no peer to fail over to
|
||||
/// (failing over a lone node would be an outage, not a failover).</returns>
|
||||
Task<string?> FailOverCentralAsync(string actor);
|
||||
|
||||
/// <summary>
|
||||
/// Asks a SITE to gracefully fail over its own two-node pair. Central and each site are
|
||||
/// separate Akka clusters, so this is a request relayed over the command/control channel —
|
||||
/// the site performs the Leave itself and reports the outcome. Unlike central failover,
|
||||
/// this does NOT disturb the caller's own UI session.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The site whose pair should fail over.</param>
|
||||
/// <param name="actor">Authenticated user name, recorded on the audit row.</param>
|
||||
/// <returns>The site's outcome — accepted with a target, or refused with a reason.</returns>
|
||||
Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId, string actor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a site-pair failover request, in UI terms. Deliberately distinct from the wire
|
||||
/// ack so CentralUI stays free of the Akka message contract.
|
||||
/// </summary>
|
||||
/// <param name="Accepted"><c>true</c> when the site issued the graceful Leave.</param>
|
||||
/// <param name="TargetAddress">Address of the leaving site node; <c>null</c> when refused.</param>
|
||||
/// <param name="ErrorMessage">
|
||||
/// Why the request was refused or failed. A refusal (peer guard, misroute) is a definitive
|
||||
/// answer FROM the site; an unreachable site surfaces here as a timeout message. Both are
|
||||
/// reported to the operator rather than being flattened into a generic failure.
|
||||
/// </param>
|
||||
public sealed record SiteFailoverOutcome(bool Accepted, string? TargetAddress, string? ErrorMessage);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
|
||||
/// <summary>
|
||||
/// Central → site relay command: gracefully fail over the owning site's two-node cluster.
|
||||
/// Sent over the command/control channel when an Administrator clicks "Trigger failover" on a
|
||||
/// site card in the Central UI Health dashboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Central and each site are SEPARATE Akka clusters, so central cannot act on a site's
|
||||
/// membership directly — it can only ask. The site's own <c>SiteCommunicationActor</c> performs
|
||||
/// the <c>Cluster.Leave</c> against its site-specific <c>site-{SiteId}</c> role, which is the
|
||||
/// role its singletons (the Deployment Manager) are scoped to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Either site node may receive this: <c>SiteCommunicationActor</c> is registered per node
|
||||
/// (not as a singleton), and ClusterClient contact rotation reaches whichever answers. That is
|
||||
/// fine — <c>Cluster.Leave(address)</c> is valid from any member, and the target is resolved
|
||||
/// from cluster state rather than from who received the message.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Rolling upgrade:</b> a site running a binary older than this contract has no handler for
|
||||
/// this message; it becomes an unhandled message / dead letter and central's Ask times out.
|
||||
/// The timeout is reported to the operator as "site did not respond", which is the correct
|
||||
/// user-facing outcome — an old site genuinely cannot honour the request. Message evolution
|
||||
/// stays additive-only.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="CorrelationId">Correlation id echoed on the ack.</param>
|
||||
/// <param name="SiteId">The site whose pair should fail over. Carried explicitly so a
|
||||
/// misrouted command is detectable at the site rather than silently acted on.</param>
|
||||
public sealed record TriggerSiteFailover(
|
||||
string CorrelationId,
|
||||
string SiteId);
|
||||
|
||||
/// <summary>
|
||||
/// Site → central ack for a <see cref="TriggerSiteFailover"/> relay command.
|
||||
/// </summary>
|
||||
/// <param name="CorrelationId">Correlation id of the originating relay command.</param>
|
||||
/// <param name="Accepted">
|
||||
/// <c>true</c> when the site resolved a target and issued the graceful Leave.
|
||||
/// <c>false</c> is a definitive refusal from the site — most often the peer guard (fewer than
|
||||
/// two Up members in the site role, so a failover would be an outage) or a site-id mismatch.
|
||||
/// A <c>false</c> ack is NOT a transport failure and must be distinguished from an
|
||||
/// unreachable-site timeout.
|
||||
/// </param>
|
||||
/// <param name="TargetAddress">Address of the node that is leaving; <c>null</c> when refused.</param>
|
||||
/// <param name="ErrorMessage">Reason for a refusal, or a fault message; <c>null</c> on success.</param>
|
||||
public sealed record SiteFailoverAck(
|
||||
string CorrelationId,
|
||||
bool Accepted,
|
||||
string? TargetAddress,
|
||||
string? ErrorMessage);
|
||||
@@ -37,6 +37,7 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
/// do not need a real cluster.
|
||||
/// </summary>
|
||||
private readonly Func<bool> _isActiveCheck;
|
||||
private readonly Func<string, string?> _failOverRole;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the local Deployment Manager singleton proxy.
|
||||
@@ -76,12 +77,14 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
string siteId,
|
||||
CommunicationOptions options,
|
||||
IActorRef deploymentManagerProxy,
|
||||
Func<bool>? isActiveCheck = null)
|
||||
Func<bool>? isActiveCheck = null,
|
||||
Func<string, string?>? failOverRole = null)
|
||||
{
|
||||
_siteId = siteId;
|
||||
_options = options;
|
||||
_deploymentManagerProxy = deploymentManagerProxy;
|
||||
_isActiveCheck = isActiveCheck ?? DefaultIsActiveCheck;
|
||||
_failOverRole = failOverRole ?? DefaultFailOverRole;
|
||||
|
||||
// Registration
|
||||
Receive<RegisterCentralClient>(msg =>
|
||||
@@ -263,6 +266,14 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
});
|
||||
|
||||
// Central→site manual failover relay. Central and the site are separate clusters,
|
||||
// so central can only ask — this node performs the graceful Leave locally, scoped to
|
||||
// the SITE-SPECIFIC role, because that is what site singletons (the Deployment
|
||||
// Manager) are placed on. Either node may receive this (the actor is per-node, not a
|
||||
// singleton, and contact rotation picks whichever answers); the target is resolved
|
||||
// from cluster state, not from who received the message.
|
||||
Receive<TriggerSiteFailover>(HandleTriggerSiteFailover);
|
||||
|
||||
// Notification Outbox: forward a buffered notification submitted by the site
|
||||
// Store-and-Forward Engine to the central cluster. The original Sender (the
|
||||
// S&F forwarder's Ask) is forwarded as the ClusterClient.Send sender so the
|
||||
@@ -517,6 +528,69 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
private bool DefaultIsActiveCheck() =>
|
||||
ClusterState.ActiveNodeEvaluator.SelfIsOldestUp(Cluster.Get(Context.System));
|
||||
|
||||
/// <summary>
|
||||
/// Handles a central-initiated site failover. Refuses a command addressed to a different
|
||||
/// site (a misroute must never silently fail over a site the operator did not select) and
|
||||
/// refuses when the site pair has no peer to take over. The ack is sent BEFORE the Leave
|
||||
/// takes effect on the wire, so it still reaches central even when this node is the one
|
||||
/// leaving.
|
||||
/// </summary>
|
||||
private void HandleTriggerSiteFailover(TriggerSiteFailover msg)
|
||||
{
|
||||
if (!string.Equals(msg.SiteId, _siteId, StringComparison.Ordinal))
|
||||
{
|
||||
_log.Warning(
|
||||
"Refusing TriggerSiteFailover addressed to site {Requested}; this node serves {Actual}",
|
||||
msg.SiteId, _siteId);
|
||||
Sender.Tell(new SiteFailoverAck(
|
||||
msg.CorrelationId, Accepted: false, TargetAddress: null,
|
||||
ErrorMessage: $"Command addressed to site '{msg.SiteId}' but this node serves '{_siteId}'."));
|
||||
return;
|
||||
}
|
||||
|
||||
var role = $"site-{_siteId}";
|
||||
try
|
||||
{
|
||||
var target = _failOverRole(role);
|
||||
if (target is null)
|
||||
{
|
||||
_log.Warning(
|
||||
"Refusing TriggerSiteFailover for {SiteId}: fewer than 2 Up members in role {Role}, "
|
||||
+ "so there is no standby to take over", _siteId, role);
|
||||
Sender.Tell(new SiteFailoverAck(
|
||||
msg.CorrelationId, Accepted: false, TargetAddress: null,
|
||||
ErrorMessage: "No standby available — failing over a lone node would be an outage."));
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Warning(
|
||||
"Manual failover requested by central for site {SiteId}: {Target} is leaving the "
|
||||
+ "site cluster gracefully; its singletons hand over to the standby.", _siteId, target);
|
||||
Sender.Tell(new SiteFailoverAck(msg.CorrelationId, Accepted: true, target, ErrorMessage: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A fault here must be reported to the operator, not thrown into supervision —
|
||||
// restarting the communication actor would drop central's Ask into a timeout and
|
||||
// lose the reason.
|
||||
_log.Error(ex, "TriggerSiteFailover for {SiteId} faulted", _siteId);
|
||||
Sender.Tell(new SiteFailoverAck(
|
||||
msg.CorrelationId, Accepted: false, TargetAddress: null, ErrorMessage: ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production failover action: gracefully Leave the oldest Up member carrying
|
||||
/// <paramref name="role"/>, via the shared <see cref="ClusterState.ClusterFailoverCoordinator"/>
|
||||
/// so the central and site paths cannot drift. Injected in tests for the same reason
|
||||
/// <see cref="DefaultIsActiveCheck"/> is — a real Leave needs Akka.Cluster in the
|
||||
/// ActorSystem, which the TestKit system does not load.
|
||||
/// </summary>
|
||||
/// <param name="role">Site-specific role scope.</param>
|
||||
/// <returns>Address of the leaving node, or null when there is no peer.</returns>
|
||||
private string? DefaultFailOverRole(string role) =>
|
||||
ClusterState.ClusterFailoverCoordinator.FailOverOldest(Context.System, role)?.ToString();
|
||||
|
||||
// ── Internal messages ──
|
||||
|
||||
internal record SendHeartbeat;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
|
||||
|
||||
/// <summary>
|
||||
/// Deliberate, operator-initiated failover of a two-node cluster: the current active member
|
||||
/// leaves GRACEFULLY so <c>ClusterSingletonManager</c> hands its singletons to the survivor.
|
||||
///
|
||||
/// <para>Lives beside <see cref="ActiveNodeEvaluator"/> — the two share one definition of
|
||||
/// "active" (oldest Up member in a role scope), and they must not drift: the node asked to
|
||||
/// leave has to be exactly the node hosting the singletons. Placed in Communication rather
|
||||
/// than Host because BOTH sides need it — the central pair fails over via the Host's
|
||||
/// <c>AkkaManualFailoverService</c>, and a site pair fails over inside
|
||||
/// <c>SiteCommunicationActor</c>, which cannot reference Host.</para>
|
||||
///
|
||||
/// <para><b>Leave, never Down.</b> A Down skips singleton hand-off and hands the outcome to
|
||||
/// the downing strategy — the wrong tool for a planned role swap.</para>
|
||||
/// </summary>
|
||||
public static class ClusterFailoverCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// Asks the oldest Up member carrying <paramref name="role"/> to leave the cluster.
|
||||
/// Mirrors <see cref="ActiveNodeEvaluator.SelfIsOldestUp"/>'s rule, so the node acted on is
|
||||
/// the singleton host — never Akka's cluster <i>leader</i>, whose address-ordered definition
|
||||
/// diverges from singleton placement once the original first node restarts and rejoins.
|
||||
/// </summary>
|
||||
/// <param name="system">Actor system whose cluster is acted on.</param>
|
||||
/// <param name="role">Role scope. Central uses <c>Central</c>; a site pair uses its
|
||||
/// site-specific <c>site-{SiteId}</c> role, since site singletons are scoped to that role.</param>
|
||||
/// <param name="dryRun">Resolve and return the target WITHOUT issuing the Leave. Used to
|
||||
/// name the target in an audit row before acting, and to answer "would this work?".</param>
|
||||
/// <returns>The address that leaves (or would leave), or <c>null</c> when fewer than two Up
|
||||
/// members carry the role — failing over a lone node is an outage, not a failover.</returns>
|
||||
public static Address? FailOverOldest(ActorSystem system, string role, bool dryRun = false)
|
||||
{
|
||||
var cluster = Akka.Cluster.Cluster.Get(system);
|
||||
var withRole = cluster.State.Members
|
||||
.Where(m => m.Status == MemberStatus.Up && m.HasRole(role))
|
||||
.OrderBy(m => m, Member.AgeOrdering)
|
||||
.ToList();
|
||||
|
||||
if (withRole.Count < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var oldest = withRole[0];
|
||||
if (!dryRun)
|
||||
{
|
||||
cluster.Leave(oldest.Address);
|
||||
}
|
||||
|
||||
return oldest.Address;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,54 @@ public class CommunicationOptions
|
||||
/// </summary>
|
||||
public List<string> CentralContactPoints { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Preshared key authenticating this node's gRPC control plane — the site↔central
|
||||
/// boundary. On a site node this is the key its inbound gate
|
||||
/// (<c>ControlPlaneAuthInterceptor</c>) expects on every <c>SiteStreamService</c> call, and
|
||||
/// which central must present; central resolves the matching value per site from its own
|
||||
/// secret store under the name <c>SB-GRPC-PSK-{siteId}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// In production this is supplied as <c>${secret:SB-GRPC-PSK-<siteId>}</c> and expanded
|
||||
/// out of the secrets store before the host is built, so the plaintext never sits in
|
||||
/// appsettings. Development rigs set a literal, mirroring the LocalDb replication key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Empty means closed, not open.</b> With no key set the interceptor rejects every gated
|
||||
/// call. This is not optional configuration: a node that ships without a key serves no
|
||||
/// streams and no audit pulls.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Distinct from <c>LocalDb:Replication:ApiKey</c>, which authenticates the pair partner for
|
||||
/// database replication over the same listener. The two are never shared.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string GrpcPsk { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Central-side per-site gRPC preshared keys, keyed by site identifier — the mirror image
|
||||
/// of <see cref="GrpcPsk"/>, which is the single key a site node expects on its own inbound
|
||||
/// gate. An entry here takes precedence over the secret store for that site.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why both a config map and a secret store.</b> The store is the primary source and the
|
||||
/// only one that works for the real case: sites are added at runtime from the Central UI, so
|
||||
/// their keys cannot be enumerated in configuration at boot, and <c>SitePskProvider</c>
|
||||
/// resolves <c>SB-GRPC-PSK-{siteId}</c> on demand. This map covers the cases the store
|
||||
/// cannot or should not: a development rig that runs with no master key and injects every
|
||||
/// credential as an environment override, and an operator pinning one site's key without
|
||||
/// touching the store. Values may themselves be <c>${secret:…}</c> references, since a map
|
||||
/// declared in configuration IS enumerable at boot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Absence is not a fallback to "unauthenticated" in either source — a site with no key in
|
||||
/// the map and none in the store cannot be dialed at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Dictionary<string, string> SitePsks { get; set; } = new();
|
||||
|
||||
/// <summary>gRPC keepalive ping interval for streaming connections.</summary>
|
||||
public TimeSpan GrpcKeepAlivePingDelay { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
|
||||
@@ -809,6 +809,34 @@ public class CommunicationService
|
||||
return await GetSiteCallAudit().Ask<DiscardSiteCallResponse>(
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks a site to gracefully fail over its own two-node pair (Task 10). Central and each
|
||||
/// site are SEPARATE Akka clusters, so central cannot act on site membership directly —
|
||||
/// the site's own <c>SiteCommunicationActor</c> performs the <c>Cluster.Leave</c> against
|
||||
/// its site-specific role and acks the outcome.
|
||||
/// <para>
|
||||
/// A site running a binary older than the <see cref="TriggerSiteFailover"/> contract has no
|
||||
/// handler for it, so the message dead-letters and this Ask times out. That surfaces to the
|
||||
/// operator as "site did not respond", which is the honest outcome — an old site genuinely
|
||||
/// cannot honour the request.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="siteId">Target site.</param>
|
||||
/// <param name="correlationId">Correlation id echoed on the ack.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The site's ack.</returns>
|
||||
public async Task<SiteFailoverAck> TriggerSiteFailoverAsync(
|
||||
string siteId, string correlationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Relaying TriggerSiteFailover to site {SiteId}, correlationId={CorrelationId}",
|
||||
siteId, correlationId);
|
||||
|
||||
var envelope = new SiteEnvelope(siteId, new TriggerSiteFailover(correlationId, siteId));
|
||||
return await GetActor().Ask<SiteFailoverAck>(
|
||||
envelope, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the preshared key that authenticates gRPC control-plane traffic for one site
|
||||
/// relationship. Central holds one key per site; each site holds only its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why per-site rather than one fleet-wide key.</b> A compromised site yields only its own
|
||||
/// key, never another site's. A single shared key would be simpler to seed and strictly worse
|
||||
/// on blast radius, which is why it was rejected in the design.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fail-closed.</b> Implementations throw when the key cannot be resolved. They must never
|
||||
/// fall back to "no key means no authentication" — that is the failure mode this whole
|
||||
/// mechanism exists to remove, and it would silently disable auth on exactly the default
|
||||
/// configuration. A dial that cannot be authenticated does not happen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The interface lives in Communication (not Host) because both sides need it: central's
|
||||
/// site-dialing clients live here and in AuditLog, while the implementation over
|
||||
/// <c>ISecretResolver</c> lives in Host, which owns the secrets container.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ISitePskProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the preshared key for <paramref name="siteId"/>, caching the result.
|
||||
/// </summary>
|
||||
/// <param name="siteId">Site identifier, as used in the <c>Site.SiteIdentifier</c> column.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The preshared key. Never null or empty.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The key is not configured, not resolvable, or empty — the fail-closed path.
|
||||
/// </exception>
|
||||
ValueTask<string> GetAsync(string siteId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Drops any cached key for <paramref name="siteId"/>, so the next
|
||||
/// <see cref="GetAsync"/> re-reads the store. Called when a site is removed, and after a
|
||||
/// key rotation.
|
||||
/// </summary>
|
||||
/// <param name="siteId">Site identifier whose cached key should be discarded.</param>
|
||||
void Invalidate(string siteId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the call credentials that carry a site's preshared key (and the site's own identity)
|
||||
/// on every gRPC call central makes to that site — and, from Phase 1A, on the calls a site makes
|
||||
/// to central.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why <see cref="CallCredentials.FromInterceptor(AsyncAuthInterceptor)"/> rather than a
|
||||
/// client <c>Interceptor</c>.</b> The key is resolved asynchronously from the secrets store, and
|
||||
/// this is the one extension point in gRPC that is async by design. A client interceptor would
|
||||
/// have to block on the resolve inside a synchronous <c>AsyncServerStreamingCall</c> path.
|
||||
/// Credentials also apply uniformly to unary and streaming calls, so no call site can forget one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why <c>UnsafeUseInsecureChannelCallCredentials</c>.</b> gRPC refuses to attach call
|
||||
/// credentials to a plaintext channel by default, precisely because a bearer token on h2c is
|
||||
/// readable and replayable by anyone on the path. That is a real and accepted limitation here:
|
||||
/// these listeners are h2c today and the boundary assumes a trusted network. The PSK raises the
|
||||
/// bar from "anyone who can reach the port" to "anyone who can read the traffic"; TLS on these
|
||||
/// listeners is the follow-on hardening and requires no change to this code.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ControlPlaneCredentials
|
||||
{
|
||||
/// <summary>
|
||||
/// Metadata header naming the site a call belongs to. Central needs it to pick which
|
||||
/// per-site key to verify against; a site's own inbound gate ignores it (a site has exactly
|
||||
/// one key). Required by central's interceptor from Phase 1A.
|
||||
/// </summary>
|
||||
public const string SiteHeader = "x-scadabridge-site";
|
||||
|
||||
/// <summary>The bearer metadata header. Lowercase — gRPC lowercases header keys on the wire.</summary>
|
||||
public const string AuthorizationHeader = "authorization";
|
||||
|
||||
/// <summary>
|
||||
/// Creates call credentials that attach <c>authorization: Bearer <psk></c> and
|
||||
/// <c>x-scadabridge-site: <siteId></c> to every call.
|
||||
/// </summary>
|
||||
/// <param name="provider">Resolves the site's preshared key.</param>
|
||||
/// <param name="siteId">The site this channel talks to (or, site-side, this site's own id).</param>
|
||||
/// <returns>Call credentials for a channel bound to that site.</returns>
|
||||
public static CallCredentials ForSite(ISitePskProvider provider, string siteId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||
|
||||
return CallCredentials.FromInterceptor(async (context, metadata) =>
|
||||
{
|
||||
// A throw here fails the call, which is the point: an unauthenticated dial must
|
||||
// not happen. Callers classify the resulting fault the same way they classify any
|
||||
// other — the pull clients degrade to an empty batch and log, the streaming
|
||||
// subscribers retry.
|
||||
var psk = await provider.GetAsync(siteId, context.CancellationToken).ConfigureAwait(false);
|
||||
metadata.Add(AuthorizationHeader, $"Bearer {psk}");
|
||||
metadata.Add(SiteHeader, siteId);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies per-site call credentials to channel options, if a provider is available.
|
||||
/// A null provider leaves the options untouched — the shape used by test-only and
|
||||
/// default constructors that never dial a gated endpoint.
|
||||
/// </summary>
|
||||
/// <param name="options">Channel options being built.</param>
|
||||
/// <param name="provider">Key provider, or null to leave the channel unauthenticated.</param>
|
||||
/// <param name="siteId">The site this channel talks to.</param>
|
||||
/// <returns>The same options instance, for chaining.</returns>
|
||||
public static GrpcChannelOptions WithSiteCredentials(
|
||||
this GrpcChannelOptions options, ISitePskProvider? provider, string? siteId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (provider is null || string.IsNullOrWhiteSpace(siteId))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
options.Credentials = ChannelCredentials.Create(
|
||||
ChannelCredentials.Insecure, ForSite(provider, siteId));
|
||||
options.UnsafeUseInsecureChannelCallCredentials = true;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,27 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="logger">Logger for diagnostics and errors.</param>
|
||||
/// <param name="options">Communication options including keepalive settings.</param>
|
||||
public SiteStreamGrpcClient(string endpoint, ILogger logger, CommunicationOptions options)
|
||||
: this(endpoint, logger, options, pskProvider: null, siteIdentifier: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a client that authenticates every call with the site's preshared key.
|
||||
/// This is the production shape: <c>SiteStreamService</c> is gated by
|
||||
/// <c>ControlPlaneAuthInterceptor</c> on the site node, so a client without credentials
|
||||
/// gets <see cref="StatusCode.PermissionDenied"/> on every call.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The gRPC endpoint address for the site.</param>
|
||||
/// <param name="logger">Logger for diagnostics and errors.</param>
|
||||
/// <param name="options">Communication options including keepalive settings.</param>
|
||||
/// <param name="pskProvider">Resolves the site's preshared key; null leaves the channel unauthenticated.</param>
|
||||
/// <param name="siteIdentifier">Site this channel talks to; null leaves the channel unauthenticated.</param>
|
||||
public SiteStreamGrpcClient(
|
||||
string endpoint,
|
||||
ILogger logger,
|
||||
CommunicationOptions options,
|
||||
ISitePskProvider? pskProvider,
|
||||
string? siteIdentifier)
|
||||
{
|
||||
Endpoint = endpoint;
|
||||
KeepAlivePingDelay = options.GrpcKeepAlivePingDelay;
|
||||
@@ -72,7 +93,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout,
|
||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always
|
||||
}
|
||||
});
|
||||
}.WithSiteCredentials(pskProvider, siteIdentifier));
|
||||
_client = new SiteStreamService.SiteStreamServiceClient(_channel);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
|
||||
private readonly ConcurrentDictionary<(string Site, string Endpoint), SiteStreamGrpcClient> _clients = new();
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly CommunicationOptions _options;
|
||||
private readonly ISitePskProvider? _pskProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Test/default constructor — uses default <see cref="CommunicationOptions"/>.
|
||||
/// Test/default constructor — uses default <see cref="CommunicationOptions"/> and creates
|
||||
/// unauthenticated channels.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory passed to created clients.</param>
|
||||
public SiteStreamGrpcClientFactory(ILoggerFactory loggerFactory)
|
||||
@@ -37,16 +39,36 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DI constructor — flows <see cref="CommunicationOptions"/> into every created
|
||||
/// <see cref="SiteStreamGrpcClient"/> so the configured gRPC keepalive settings
|
||||
/// are applied rather than hard-coded defaults.
|
||||
/// Constructor without a key provider — creates unauthenticated channels, which a gated
|
||||
/// site will refuse. Retained for tests and for hosts that never dial a site.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory passed to created clients.</param>
|
||||
/// <param name="options">Communication options applied to each created client.</param>
|
||||
public SiteStreamGrpcClientFactory(ILoggerFactory loggerFactory, IOptions<CommunicationOptions> options)
|
||||
: this(loggerFactory, options, pskProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DI constructor — flows <see cref="CommunicationOptions"/> into every created
|
||||
/// <see cref="SiteStreamGrpcClient"/> so the configured gRPC keepalive settings are applied
|
||||
/// rather than hard-coded defaults, and attaches the per-site preshared key that the site's
|
||||
/// <c>ControlPlaneAuthInterceptor</c> requires.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">Logger factory passed to created clients.</param>
|
||||
/// <param name="options">Communication options applied to each created client.</param>
|
||||
/// <param name="pskProvider">
|
||||
/// Resolves each site's preshared key. Optional in DI so a host that registers no provider
|
||||
/// (a site node, which never dials another site) still resolves this factory.
|
||||
/// </param>
|
||||
public SiteStreamGrpcClientFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
IOptions<CommunicationOptions> options,
|
||||
ISitePskProvider? pskProvider)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_options = options.Value;
|
||||
_pskProvider = pskProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,7 +81,7 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
|
||||
/// <param name="grpcEndpoint">gRPC endpoint (second half of the cache key) the client is bound to.</param>
|
||||
/// <returns>The cached or newly-created client bound to <paramref name="grpcEndpoint"/>.</returns>
|
||||
public virtual SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) =>
|
||||
_clients.GetOrAdd((siteIdentifier, grpcEndpoint), _ => CreateClient(grpcEndpoint));
|
||||
_clients.GetOrAdd((siteIdentifier, grpcEndpoint), key => CreateClient(key.Site, key.Endpoint));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached client for <c>(site, endpoint)</c>, or <c>null</c> — never creates.
|
||||
@@ -77,12 +99,13 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
|
||||
/// can substitute a tracking client while still exercising the factory's real
|
||||
/// caching and disposal machinery.
|
||||
/// </summary>
|
||||
/// <param name="siteIdentifier">Site the new client talks to; selects which preshared key it presents.</param>
|
||||
/// <param name="grpcEndpoint">gRPC endpoint the new client will connect to.</param>
|
||||
/// <returns>A new <see cref="SiteStreamGrpcClient"/> connected to <paramref name="grpcEndpoint"/>.</returns>
|
||||
protected virtual SiteStreamGrpcClient CreateClient(string grpcEndpoint)
|
||||
protected virtual SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger<SiteStreamGrpcClient>();
|
||||
return new SiteStreamGrpcClient(grpcEndpoint, logger, _options);
|
||||
return new SiteStreamGrpcClient(grpcEndpoint, logger, _options, _pskProvider, siteIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -99,6 +122,10 @@ public class SiteStreamGrpcClientFactory : IAsyncDisposable, IDisposable
|
||||
if (_clients.TryRemove(key, out var client))
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
|
||||
// Drop the cached preshared key too, so a site removed and re-added under the same
|
||||
// identifier (with a rotated key) is not dialed with the stale one.
|
||||
_pskProvider?.Invalidate(siteIdentifier);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
@@ -23,7 +24,15 @@ public static class ServiceCollectionExtensions
|
||||
ServiceDescriptor.Singleton<IValidateOptions<CommunicationOptions>, CommunicationOptionsValidator>());
|
||||
|
||||
services.AddSingleton<CommunicationService>();
|
||||
services.AddSingleton<SiteStreamGrpcClientFactory>();
|
||||
|
||||
// Explicit factory rather than AddSingleton<T>(): the ISitePskProvider dependency is
|
||||
// optional (central registers one, a site node does not), and constructor selection
|
||||
// over a nullable interface parameter is exactly the case the container cannot decide
|
||||
// for itself — GetService returns null cleanly where constructor injection would throw.
|
||||
services.AddSingleton(sp => new SiteStreamGrpcClientFactory(
|
||||
sp.GetRequiredService<ILoggerFactory>(),
|
||||
sp.GetRequiredService<IOptions<CommunicationOptions>>(),
|
||||
sp.GetService<ISitePskProvider>()));
|
||||
services.AddSingleton<DebugStreamService>();
|
||||
|
||||
// Aggregated live alarm cache (plan #10, Task 4): transient, in-memory, shared
|
||||
|
||||
@@ -450,16 +450,22 @@ akka {{
|
||||
siteAlarmLiveCache?.SetActorSystem(_actorSystem!);
|
||||
|
||||
|
||||
// Management Service — accessible via ClusterClient
|
||||
// Management Service — reached IN-PROCESS only, via ManagementActorHolder.
|
||||
//
|
||||
// This actor used to be registered with the ClusterClientReceptionist as well, for
|
||||
// an out-of-cluster CLI that was never built (REQ-HOST-6a). The shipped CLI speaks
|
||||
// HTTP Basic to /management, which asks this actor through the holder below
|
||||
// (ManagementEndpoints), so the registration had no sender anywhere in the repo —
|
||||
// it only advertised a management surface across the cluster-client boundary for
|
||||
// free. Removed 2026-07-22 (ClusterClient→gRPC migration, T0.1).
|
||||
var mgmtLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger<ZB.MOM.WW.ScadaBridge.ManagementService.ManagementActor>();
|
||||
var mgmtActor = _actorSystem!.ActorOf(
|
||||
Props.Create(() => new ZB.MOM.WW.ScadaBridge.ManagementService.ManagementActor(_serviceProvider, mgmtLogger)),
|
||||
"management");
|
||||
ClusterClientReceptionist.Get(_actorSystem).RegisterService(mgmtActor);
|
||||
var mgmtHolder = _serviceProvider.GetRequiredService<ZB.MOM.WW.ScadaBridge.ManagementService.ManagementActorHolder>();
|
||||
mgmtHolder.ActorRef = mgmtActor;
|
||||
_logger.LogInformation("ManagementActor registered with ClusterClientReceptionist");
|
||||
_logger.LogInformation("ManagementActor started at /user/management (in-process access via ManagementActorHolder)");
|
||||
|
||||
// Notification Outbox — cluster singleton so exactly one node owns ingest,
|
||||
// the dispatch sweep and the purge loop. Central actors run on the base
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Gates the site↔central gRPC control plane with a preshared key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The gap this closes.</b> <c>SiteStreamService</c> shipped with no authentication at all —
|
||||
/// plaintext h2c, no interceptor. Anything that could reach a site node's gRPC port could open a
|
||||
/// live data stream or call <c>PullAuditEvents</c>/<c>PullSiteCalls</c> and read audit rows back.
|
||||
/// The only gated surface on that listener was LocalDb sync, and only for its own service. That
|
||||
/// gap exists independently of the ClusterClient→gRPC migration; it becomes indefensible once
|
||||
/// every command crosses this listener.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Modeled on <see cref="LocalDbSyncAuthInterceptor"/>,</b> deliberately: same four server
|
||||
/// handlers funnelling into one <c>Authorize</c>, same <c>authorization: Bearer</c> extraction,
|
||||
/// same <see cref="CryptographicOperations.FixedTimeEquals"/> comparison, same fail-closed
|
||||
/// posture, same <see cref="StatusCode.PermissionDenied"/> rejection. Two differences:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>It gates a <b>set</b> of service prefixes rather than one, so later phases can add the
|
||||
/// new command/control services without a second interceptor.</item>
|
||||
/// <item>Its expected key comes from <see cref="CommunicationOptions.GrpcPsk"/> — the site's own
|
||||
/// key, supplied in production as <c>${secret:SB-GRPC-PSK-<siteId>}</c> and expanded before
|
||||
/// the host is built.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>The two keys are separate on purpose.</b> LocalDb sync keeps its own
|
||||
/// <c>LocalDb:Replication:ApiKey</c>, which authenticates a different peer (the pair partner, not
|
||||
/// central) over a different trust relationship. Sharing one key would mean a site's central-facing
|
||||
/// key also admits writes into its database.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fail-closed, and not optional.</b> With no <c>GrpcPsk</c> configured, every gated call is
|
||||
/// rejected — including the ones that work today. That is a deliberate break: LocalDb replication
|
||||
/// is an opt-in feature whose "off" state is "no peer", whereas streaming and audit pull are
|
||||
/// core paths, so "no key" must not silently mean "no authentication". Every environment must
|
||||
/// carry a key before upgrading to this build.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ControlPlaneAuthInterceptor : Interceptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Service prefixes gated by default. Read from the generated <c>sitestream.proto</c>
|
||||
/// package/service names — <c>package sitestream; service SiteStreamService</c>.
|
||||
/// Later phases append their own services here.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> DefaultGatedPrefixes =
|
||||
new[] { "/sitestream.SiteStreamService/" };
|
||||
|
||||
private readonly IReadOnlyList<string> _gatedPrefixes;
|
||||
private readonly IOptions<CommunicationOptions> _options;
|
||||
private readonly ILogger<ControlPlaneAuthInterceptor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the interceptor gating <see cref="DefaultGatedPrefixes"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This must remain the ONLY public constructor.</b> <c>AddGrpc</c> registers the
|
||||
/// interceptor by type, and <c>Grpc.AspNetCore.Server.InterceptorRegistration.GetFactory()</c>
|
||||
/// throws <c>"Multiple constructors accepting all given argument types have been found"</c>
|
||||
/// when a second one is applicable. That throw happens per call, inside the pipeline, and
|
||||
/// surfaces to the caller as <c>Unknown / "Exception was thrown by handler"</c> — so the gate
|
||||
/// silently stops authorizing anything while still failing every call. A second public
|
||||
/// constructor added here in a later phase reintroduces exactly that. Pinned by
|
||||
/// <c>ControlPlaneAuthInterceptorTests.TheInterceptorHasExactlyOnePublicConstructor</c>.
|
||||
/// </remarks>
|
||||
/// <param name="options">Communication options; <c>GrpcPsk</c> is the expected bearer token.</param>
|
||||
/// <param name="logger">Logger for denial diagnostics.</param>
|
||||
public ControlPlaneAuthInterceptor(
|
||||
IOptions<CommunicationOptions> options,
|
||||
ILogger<ControlPlaneAuthInterceptor> logger)
|
||||
: this(options, logger, DefaultGatedPrefixes)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the interceptor gating an explicit set of service prefixes. <b>Internal</b> —
|
||||
/// see the public constructor's remarks for why this cannot be public. Phases that add a
|
||||
/// service to the gate should extend <see cref="DefaultGatedPrefixes"/> rather than reach
|
||||
/// for a second registration shape.
|
||||
/// </summary>
|
||||
/// <param name="options">Communication options; <c>GrpcPsk</c> is the expected bearer token.</param>
|
||||
/// <param name="logger">Logger for denial diagnostics.</param>
|
||||
/// <param name="gatedPrefixes">Method-path prefixes to gate, e.g. <c>/sitestream.SiteStreamService/</c>.</param>
|
||||
internal ControlPlaneAuthInterceptor(
|
||||
IOptions<CommunicationOptions> options,
|
||||
ILogger<ControlPlaneAuthInterceptor> logger,
|
||||
IReadOnlyList<string> gatedPrefixes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(gatedPrefixes);
|
||||
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_gatedPrefixes = gatedPrefixes;
|
||||
}
|
||||
|
||||
/// <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 gated call that does not carry the configured bearer token. Calls to services
|
||||
/// outside <c>gatedPrefixes</c> — notably LocalDb sync, which has its own interceptor —
|
||||
/// return immediately.
|
||||
/// </summary>
|
||||
private void Authorize(ServerCallContext context)
|
||||
{
|
||||
if (!IsGated(context.Method))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var expected = _options.Value.GrpcPsk;
|
||||
if (string.IsNullOrEmpty(expected))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a control-plane call to {Method}: no ScadaBridge:Communication:GrpcPsk is "
|
||||
+ "configured, so the control plane is closed. Set the same key here (in production, "
|
||||
+ "as ${{secret:SB-GRPC-PSK-<siteId>}}) and in central's secret store.",
|
||||
context.Method);
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"Control plane is not accepting calls: no preshared key is configured on this node."));
|
||||
}
|
||||
|
||||
var presented = ExtractBearerToken(context.RequestHeaders);
|
||||
if (presented is null || !FixedTimeEquals(presented, expected))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Rejected a control-plane 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,
|
||||
"Control plane authentication failed."));
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsGated(string method)
|
||||
{
|
||||
foreach (var prefix in _gatedPrefixes)
|
||||
{
|
||||
if (method.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ExtractBearerToken(Metadata headers)
|
||||
{
|
||||
// gRPC lowercases header keys on the wire; compare case-insensitively anyway so a
|
||||
// hand-built Metadata in a test behaves the same as a real request.
|
||||
foreach (var entry in headers)
|
||||
{
|
||||
if (!string.Equals(entry.Key, ControlPlaneCredentials.AuthorizationHeader,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = entry.Value;
|
||||
if (value is not null && value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value["Bearer ".Length..];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string presented, string expected)
|
||||
=> CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
@@ -6,6 +6,8 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
|
||||
@@ -33,19 +35,23 @@ public sealed class AkkaManualFailoverService : IManualFailoverService
|
||||
{
|
||||
private readonly AkkaHostedService _akka;
|
||||
private readonly ICentralAuditWriter _audit;
|
||||
private readonly CommunicationService _communication;
|
||||
private readonly ILogger<AkkaManualFailoverService> _logger;
|
||||
|
||||
/// <summary>Initializes a new <see cref="AkkaManualFailoverService"/>.</summary>
|
||||
/// <param name="akka">The Akka hosted service exposing the cluster's actor system.</param>
|
||||
/// <param name="audit">Central direct-write audit writer.</param>
|
||||
/// <param name="communication">Central→site command/control transport, used for site failover.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public AkkaManualFailoverService(
|
||||
AkkaHostedService akka,
|
||||
ICentralAuditWriter audit,
|
||||
CommunicationService communication,
|
||||
ILogger<AkkaManualFailoverService> logger)
|
||||
{
|
||||
_akka = akka;
|
||||
_audit = audit;
|
||||
_communication = communication;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -76,46 +82,63 @@ public sealed class AkkaManualFailoverService : IManualFailoverService
|
||||
return target.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId, string actor)
|
||||
{
|
||||
// Audit BEFORE relaying, for the same reason as central: the row must exist even if
|
||||
// the outcome is never observed. Unlike central, the acting node is never the one
|
||||
// leaving — but a relay can still time out, and an un-acked request that DID take
|
||||
// effect at the site would otherwise be unattributed.
|
||||
await WriteAuditAsync(actor, target: siteId, sourceSiteId: siteId);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Manual site failover triggered by {Actor} for site {SiteId}; relaying to the site cluster.",
|
||||
actor, siteId);
|
||||
|
||||
try
|
||||
{
|
||||
var ack = await _communication.TriggerSiteFailoverAsync(siteId, Guid.NewGuid().ToString());
|
||||
return new SiteFailoverOutcome(ack.Accepted, ack.TargetAddress, ack.ErrorMessage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Central never buffers for an unreachable site — the Ask simply times out. Report
|
||||
// that distinctly from a refusal, which is a definitive answer FROM the site.
|
||||
_logger.LogWarning(ex,
|
||||
"Manual site failover for {SiteId} did not get an ack from the site.", siteId);
|
||||
return new SiteFailoverOutcome(
|
||||
Accepted: false,
|
||||
TargetAddress: null,
|
||||
ErrorMessage: $"Site did not respond: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The Akka role scoping central-cluster membership.</summary>
|
||||
private const string CentralRole = "Central";
|
||||
|
||||
/// <summary>
|
||||
/// Oldest Up member with the role leaves — mirrors <c>ActiveNodeEvaluator</c>'s oldest-Up
|
||||
/// rule so the node acted on is exactly the one hosting the singletons. Returns null when
|
||||
/// fewer than 2 Up members carry the role (no peer = failover would be an outage).
|
||||
/// Oldest Up member with the role leaves. Delegates to
|
||||
/// <see cref="ClusterFailoverCoordinator.FailOverOldest"/>, which lives in Communication so
|
||||
/// the site-pair failover path (inside <c>SiteCommunicationActor</c>, which cannot reference
|
||||
/// Host) shares one implementation of the rule.
|
||||
/// </summary>
|
||||
/// <param name="system">The actor system whose cluster is acted on.</param>
|
||||
/// <param name="role">Role scope for membership.</param>
|
||||
/// <param name="dryRun">When true, resolve and return the target without issuing the Leave.</param>
|
||||
/// <returns>The address that leaves (or would leave), or null when there is no peer.</returns>
|
||||
public static Address? FailOverCore(ActorSystem system, string role, bool dryRun = false)
|
||||
{
|
||||
var cluster = Akka.Cluster.Cluster.Get(system);
|
||||
var withRole = cluster.State.Members
|
||||
.Where(m => m.Status == MemberStatus.Up && m.HasRole(role))
|
||||
.OrderBy(m => m, Member.AgeOrdering)
|
||||
.ToList();
|
||||
|
||||
if (withRole.Count < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var oldest = withRole[0];
|
||||
if (!dryRun)
|
||||
{
|
||||
cluster.Leave(oldest.Address);
|
||||
}
|
||||
|
||||
return oldest.Address;
|
||||
}
|
||||
=> ClusterFailoverCoordinator.FailOverOldest(system, role, dryRun);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort audit row. Audit failure must never block the failover the admin asked for
|
||||
/// — the same rule the rest of the Audit Log follows (audit is best-effort; the
|
||||
/// user-facing action's own success path is authoritative).
|
||||
/// </summary>
|
||||
private async Task WriteAuditAsync(string actor, Address target)
|
||||
private Task WriteAuditAsync(string actor, Address target)
|
||||
=> WriteAuditAsync(actor, target.ToString(), sourceSiteId: null);
|
||||
|
||||
/// <inheritdoc cref="WriteAuditAsync(string, Address)"/>
|
||||
private async Task WriteAuditAsync(string actor, string target, string? sourceSiteId)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -124,8 +147,15 @@ public sealed class AkkaManualFailoverService : IManualFailoverService
|
||||
kind: AuditKind.ManualFailover,
|
||||
status: AuditStatus.Submitted,
|
||||
actor: actor,
|
||||
target: target.ToString(),
|
||||
extra: JsonSerializer.Serialize(new { target = target.ToString(), role = CentralRole }));
|
||||
target: target,
|
||||
sourceSiteId: sourceSiteId,
|
||||
extra: JsonSerializer.Serialize(new
|
||||
{
|
||||
target,
|
||||
// Central rows name the Central role; site rows name the site, so a query
|
||||
// can tell which pair an operator moved.
|
||||
scope = sourceSiteId is null ? CentralRole : $"site-{sourceSiteId}"
|
||||
}));
|
||||
|
||||
await _audit.WriteAsync(evt);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,14 @@ try
|
||||
// Shared components
|
||||
builder.Services.AddClusterInfrastructure();
|
||||
builder.Services.AddCommunication();
|
||||
|
||||
// Per-site gRPC preshared keys. Central-only: it is the side that dials sites, and
|
||||
// the only side whose key set is dynamic (sites come from the configuration
|
||||
// database, so there is no fixed list of ${secret:} references to expand at boot —
|
||||
// hence a runtime resolver rather than the pre-host SecretReferenceExpander a site
|
||||
// node uses for its single key). Registered before the clients that consume it.
|
||||
builder.Services.AddSingleton<
|
||||
ZB.MOM.WW.ScadaBridge.Communication.Grpc.ISitePskProvider, SitePskProvider>();
|
||||
builder.Services.AddHealthMonitoring();
|
||||
builder.Services.AddCentralHealthAggregation();
|
||||
builder.Services.AddExternalSystemGateway();
|
||||
@@ -519,12 +527,21 @@ try
|
||||
});
|
||||
});
|
||||
|
||||
// gRPC server registration
|
||||
// The interceptor gates ONLY /localdb_sync.v1.LocalDbSync/ — SiteStream calls on
|
||||
// this same pipeline pass through untouched. It is fail-closed: with no
|
||||
// LocalDb:Replication:ApiKey configured, no sync stream is accepted at all.
|
||||
// gRPC server registration. Two interceptors, two disjoint service prefixes, two
|
||||
// separate keys — neither one's absence weakens the other:
|
||||
//
|
||||
// LocalDbSyncAuthInterceptor gates /localdb_sync.v1.LocalDbSync/ (LocalDb:Replication:ApiKey)
|
||||
// ControlPlaneAuthInterceptor gates /sitestream.SiteStreamService/ (ScadaBridge:Communication:GrpcPsk)
|
||||
//
|
||||
// Both are fail-closed: an unset key closes that surface rather than opening it. For
|
||||
// LocalDb that means replication simply does not start; for the control plane it means
|
||||
// this node serves no streams and no audit pulls until a key is configured, which is
|
||||
// why every environment must carry one before running this build.
|
||||
builder.Services.AddGrpc(options =>
|
||||
options.Interceptors.Add<LocalDbSyncAuthInterceptor>());
|
||||
{
|
||||
options.Interceptors.Add<LocalDbSyncAuthInterceptor>();
|
||||
options.Interceptors.Add<ControlPlaneAuthInterceptor>();
|
||||
});
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
|
||||
// Existing site service registrations (this is also where LocalDb and its
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Central's <see cref="ISitePskProvider"/>: resolves each site's gRPC preshared key at
|
||||
/// channel-build time — from <c>ScadaBridge:Communication:SitePsks</c> if the site is listed
|
||||
/// there, otherwise from the secrets store under the name <c>SB-GRPC-PSK-{siteId}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why resolved at runtime rather than expanded into config at boot.</b> Site nodes get their
|
||||
/// single key through the pre-host <c>SecretReferenceExpander</c> — a <c>${secret:…}</c> in
|
||||
/// appsettings, resolved once before the host is built. Central cannot do that: its set of sites
|
||||
/// comes from the configuration database and changes while the process runs, so there is no
|
||||
/// fixed list of references to expand at boot. This mirrors the pattern the MxGateway data
|
||||
/// connection already uses for its per-connection API keys.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Caching.</b> Successful resolves are cached indefinitely; a site's key changes only on
|
||||
/// rotation, and rotation restarts the pair. Failures are deliberately NOT cached, so a key
|
||||
/// seeded after the first (failed) dial is picked up on the next attempt instead of requiring a
|
||||
/// restart. <see cref="Invalidate"/> drops an entry when a site is removed or a key is rotated
|
||||
/// in place.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fail-closed.</b> A key absent from BOTH sources throws, and the dial that needed it fails.
|
||||
/// It never degrades to an unauthenticated call. The message names the exact config key and the
|
||||
/// exact secret name, so the fix is one setting or one <c>secret</c> CLI seed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SitePskProvider : ISitePskProvider
|
||||
{
|
||||
/// <summary>Prefix of the per-site secret name; the site identifier is appended verbatim.</summary>
|
||||
public const string SecretNamePrefix = "SB-GRPC-PSK-";
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _cache = new(StringComparer.Ordinal);
|
||||
private readonly ISecretResolver _resolver;
|
||||
private readonly IOptionsMonitor<CommunicationOptions> _options;
|
||||
private readonly ILogger<SitePskProvider> _logger;
|
||||
|
||||
/// <summary>Creates the provider over the host's secret resolver.</summary>
|
||||
/// <param name="resolver">Runtime secret resolver from the host container.</param>
|
||||
/// <param name="options">
|
||||
/// Communication options supplying the optional <c>SitePsks</c> map. Read through an
|
||||
/// <see cref="IOptionsMonitor{TOptions}"/> so a reloaded configuration is honoured on the
|
||||
/// next uncached resolve rather than pinned at construction.
|
||||
/// </param>
|
||||
/// <param name="logger">Logger for resolution diagnostics.</param>
|
||||
public SitePskProvider(
|
||||
ISecretResolver resolver,
|
||||
IOptionsMonitor<CommunicationOptions> options,
|
||||
ILogger<SitePskProvider> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_resolver = resolver;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Builds the secret name for a site.</summary>
|
||||
/// <param name="siteId">Site identifier.</param>
|
||||
/// <returns>The secret name, e.g. <c>SB-GRPC-PSK-site-a</c>.</returns>
|
||||
public static string SecretNameFor(string siteId) => SecretNamePrefix + siteId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<string> GetAsync(string siteId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||
|
||||
if (_cache.TryGetValue(siteId, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Configured map first: it is the explicit, operator-stated answer for this site, and
|
||||
// it is the only source available to a host running without a secrets master key.
|
||||
var configured = _options.CurrentValue.SitePsks;
|
||||
if (configured.TryGetValue(siteId, out var fromConfig) && !string.IsNullOrEmpty(fromConfig))
|
||||
{
|
||||
_cache[siteId] = fromConfig;
|
||||
return fromConfig;
|
||||
}
|
||||
|
||||
// Otherwise the store, which is the only source that can serve a site added at runtime.
|
||||
var secretName = SecretNameFor(siteId);
|
||||
var value = await ResolveFromStoreAsync(secretName, siteId, ct).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
_logger.LogError(
|
||||
"gRPC control-plane key for site {SiteId} could not be resolved: it is absent from "
|
||||
+ "ScadaBridge:Communication:SitePsks and secret '{SecretName}' is missing, empty or "
|
||||
+ "tombstoned. Calls to this site are refused until one of the two is set (the site "
|
||||
+ "node must carry the same value in ScadaBridge:Communication:GrpcPsk).",
|
||||
siteId, secretName);
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC preshared key for site '{siteId}' could not be resolved from "
|
||||
+ $"ScadaBridge:Communication:SitePsks or secret '{secretName}'.");
|
||||
}
|
||||
|
||||
// Two concurrent first dials may both resolve; the read is idempotent and the values
|
||||
// identical, so the loser simply overwrites with the same string.
|
||||
_cache[siteId] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the key from the secrets store, treating a store fault as "not found" rather than
|
||||
/// letting it propagate. A host with no master key configured (the development rig) throws
|
||||
/// from the resolver; that must produce the same clear "key not configured" error as a
|
||||
/// missing secret, not an opaque cryptographic one.
|
||||
/// </summary>
|
||||
private async Task<string?> ResolveFromStoreAsync(string secretName, string siteId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _resolver.GetAsync(new SecretName(secretName), ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex,
|
||||
"Secret store lookup for '{SecretName}' (site {SiteId}) faulted; treating as not found.",
|
||||
secretName, siteId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate(string siteId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(siteId))
|
||||
{
|
||||
_cache.TryRemove(siteId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,23 @@ public static class StartupValidator
|
||||
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsPort != port, "must differ from RemotingPort");
|
||||
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsPort != grpcPort, "must differ from GrpcPort");
|
||||
|
||||
// The gRPC control-plane preshared key. Same argument as the inbound
|
||||
// API-key pepper above: without it the node boots and looks healthy, but
|
||||
// ControlPlaneAuthInterceptor is fail-closed, so every SiteStream call —
|
||||
// live subscriptions, audit pulls, cached-telemetry ingest — is refused
|
||||
// with PermissionDenied. A silent, total loss of the site's central-facing
|
||||
// surface is far worse than a loud boot failure, so require it here.
|
||||
// Central holds the matching value per site in its secret store as
|
||||
// SB-GRPC-PSK-{SiteId}; production supplies this one as
|
||||
// ${secret:SB-GRPC-PSK-<siteId>}, expanded before the host is built.
|
||||
p.Require("ScadaBridge:Communication:GrpcPsk",
|
||||
value => !string.IsNullOrWhiteSpace(value),
|
||||
"is required for Site nodes: it is the preshared key the gRPC control "
|
||||
+ "plane authenticates with, and the interceptor is fail-closed, so an "
|
||||
+ "unset key refuses every SiteStream call. Set the same value here (in "
|
||||
+ "production as ${secret:SB-GRPC-PSK-<siteId>}) and under the secret "
|
||||
+ "name SB-GRPC-PSK-<siteId> in central's secret store");
|
||||
|
||||
// ScadaBridge:Database:SiteDbPath was required here until LocalDb
|
||||
// Phase 2. The site's tables now live in the consolidated LocalDb
|
||||
// database (LocalDb:Path, which SiteServiceRegistration requires),
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"SqliteDbPath": "./data/store-and-forward.db"
|
||||
},
|
||||
"Communication": {
|
||||
"_grpcPsk": "REQUIRED on Site nodes (StartupValidator fails the boot without it). The preshared key the gRPC control plane authenticates with: ControlPlaneAuthInterceptor is fail-closed, so an unset key refuses every SiteStream call — live subscriptions, audit pulls, cached-telemetry ingest — while the node still reports healthy. Supply it as ${secret:SB-GRPC-PSK-<siteId>} so the plaintext never sits in this file, and seed the SAME value on central: either as the secret SB-GRPC-PSK-<siteId> in its store, or as ScadaBridge:Communication:SitePsks:<siteId>. BOTH nodes of the pair carry the same key. Distinct from LocalDb:Replication:ApiKey, which authenticates the pair partner, not central — never share the two.",
|
||||
"GrpcPsk": "${secret:SB-GRPC-PSK-site-1}",
|
||||
"_centralContactPoints": "Host-016: each entry MUST be a central node's remoting endpoint, NOT this site's own remoting port. The single dev-loopback default below points only at central-a (localhost:8081). In a multi-central deployment add the second central node here (e.g. 'akka.tcp://scadabridge@central-b-host:8081') so ClusterClient can fail over when central-a is down. The previous template listed localhost:8082 as the second contact — that is THIS site's own RemotingPort and is a permanent failure in the initial-contact rotation.",
|
||||
"CentralContactPoints": [
|
||||
"akka.tcp://scadabridge@localhost:8081"
|
||||
|
||||
+2
-2
@@ -58,7 +58,7 @@ public class GrpcPullAuditEventsClientTests
|
||||
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
|
||||
|
||||
public Task<ProtoPullResponse> InvokeAsync(
|
||||
string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
Endpoint = endpoint;
|
||||
@@ -84,7 +84,7 @@ public class GrpcPullAuditEventsClientTests
|
||||
_byEndpoint = byEndpoint;
|
||||
|
||||
public Task<ProtoPullResponse> InvokeAsync(
|
||||
string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
Dialed.Add(endpoint);
|
||||
return Task.FromResult(_byEndpoint[endpoint]());
|
||||
|
||||
@@ -50,7 +50,7 @@ public class GrpcPullSiteCallsClientTests
|
||||
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
|
||||
|
||||
public Task<ProtoPullResponse> InvokeAsync(
|
||||
string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
Endpoint = endpoint;
|
||||
@@ -77,7 +77,7 @@ public class GrpcPullSiteCallsClientTests
|
||||
_byEndpoint = byEndpoint;
|
||||
|
||||
public Task<ProtoPullResponse> InvokeAsync(
|
||||
string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
|
||||
{
|
||||
Dialed.Add(endpoint);
|
||||
// The Func may throw (transport fault) — the client's try/catch handles it.
|
||||
|
||||
@@ -153,4 +153,89 @@ public class HealthFailoverButtonTests : BunitContext
|
||||
|
||||
Assert.Contains("no standby", cut.Markup, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ---- Site-pair failover (Task 10) ------------------------------------------
|
||||
// Same control, SiteId set. The differences that matter: it calls the site path,
|
||||
// and its confirmation must NOT claim the admin's own page will drop (it won't —
|
||||
// the site is a different cluster).
|
||||
|
||||
private IRenderedComponent<CentralFailoverControl> RenderForSite(int onlineNodes, string siteId)
|
||||
{
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<CentralFailoverControl>(0);
|
||||
builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), onlineNodes);
|
||||
builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<CentralFailoverControl>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_card_confirming_triggers_the_site_failover_path()
|
||||
{
|
||||
Arrange("Administrator");
|
||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "akka.tcp://scadabridge@site-a-a:8082", null)));
|
||||
|
||||
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
||||
await cut.Find("button").ClickAsync(new());
|
||||
|
||||
await _failover.Received(1).FailOverSiteAsync("SiteA", "tester");
|
||||
await _failover.DidNotReceive().FailOverCentralAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_card_confirmation_does_not_claim_this_page_disconnects()
|
||||
{
|
||||
// Central failover drops the admin's circuit; a SITE failover does not, and saying
|
||||
// otherwise would train operators to distrust the warning that does matter.
|
||||
Arrange("Administrator");
|
||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "addr", null)));
|
||||
|
||||
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
||||
await cut.Find("button").ClickAsync(new());
|
||||
|
||||
await _dialog.Received(1).ConfirmAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<string>(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
||||
Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_card_surfaces_a_refusal_from_the_site()
|
||||
{
|
||||
Arrange("Administrator");
|
||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
||||
false, null, "No standby available — failing over a lone node would be an outage.")));
|
||||
|
||||
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
||||
await cut.Find("button").ClickAsync(new());
|
||||
|
||||
Assert.Contains("No standby available", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_card_surfaces_an_unreachable_site_distinctly_from_a_refusal()
|
||||
{
|
||||
// A timeout is not a "no" from the site — the operator must be able to tell the
|
||||
// difference, because the failover may or may not have taken effect.
|
||||
Arrange("Administrator");
|
||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
||||
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
||||
false, null, "Site did not respond: Ask timed out")));
|
||||
|
||||
var cut = RenderForSite(onlineNodes: 2, siteId: "SiteA");
|
||||
await cut.Find("button").ClickAsync(new());
|
||||
|
||||
Assert.Contains("did not respond", cut.Markup);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ using System.Security.Claims;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using Akka.Actor;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
@@ -105,12 +108,43 @@ public class HealthPageTests : BunitContext
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
// The failover control's AuthorizeView evaluates the named RequireAdmin policy, so the
|
||||
// real policy set has to be registered or the page throws on any card that renders it.
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
// BunitContext pre-registers a placeholder IAuthorizationService that throws when a
|
||||
// policy is evaluated; force the real one (same note as NavMenuTests).
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
|
||||
// The cluster cards embed CentralFailoverControl (Task 10), which injects these two.
|
||||
// The page's DI graph genuinely requires them in production, so the test supplies
|
||||
// them rather than the component making its dependencies optional. Behaviour of the
|
||||
// control itself is covered by HealthFailoverButtonTests.
|
||||
Services.AddSingleton(Substitute.For<IManualFailoverService>());
|
||||
Services.AddSingleton(Substitute.For<IDialogService>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the dashboard the way the app does — inside a
|
||||
/// <see cref="CascadingAuthenticationState"/>, which the real layout supplies. The
|
||||
/// cluster cards contain an AuthorizeView (the Task 10 failover control), and
|
||||
/// AuthorizeView throws without that cascading value.
|
||||
/// </summary>
|
||||
private IRenderedComponent<HealthPage> RenderHealthPage()
|
||||
{
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<HealthPage>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<HealthPage>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_OutboxKpiTiles_WithValues()
|
||||
{
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
// KPI data arrives via an async actor Ask after first render.
|
||||
cut.WaitForAssertion(() =>
|
||||
@@ -129,7 +163,7 @@ public class HealthPageTests : BunitContext
|
||||
[Fact]
|
||||
public void RendersLinkToTheNotificationKpisPage()
|
||||
{
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
var link = cut.Find("a[href='/notifications/kpis']");
|
||||
Assert.Contains("View details", link.TextContent);
|
||||
}
|
||||
@@ -148,7 +182,7 @@ public class HealthPageTests : BunitContext
|
||||
AsOfUtc: DateTime.UtcNow)));
|
||||
Services.AddSingleton(auditService);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -166,7 +200,7 @@ public class HealthPageTests : BunitContext
|
||||
[Fact]
|
||||
public void Renders_SiteCallKpiTiles_WithValues()
|
||||
{
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
// KPI data arrives via an async actor Ask after first render.
|
||||
cut.WaitForAssertion(() =>
|
||||
@@ -186,7 +220,7 @@ public class HealthPageTests : BunitContext
|
||||
[Fact]
|
||||
public void RendersLinkToTheSiteCallsReportPage()
|
||||
{
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
var link = cut.Find("a[href='/site-calls/report']");
|
||||
Assert.Contains("View details", link.TextContent);
|
||||
}
|
||||
@@ -197,7 +231,7 @@ public class HealthPageTests : BunitContext
|
||||
_siteCallKpiReply = new SiteCallKpiResponse(
|
||||
"k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -216,7 +250,7 @@ public class HealthPageTests : BunitContext
|
||||
_kpiReply = new NotificationKpiResponse(
|
||||
"k", false, "outbox repository unavailable", 0, 0, 0, 0, null);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -234,7 +268,7 @@ public class HealthPageTests : BunitContext
|
||||
// default-site load produces charts.
|
||||
SeedSites("site-a");
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -268,7 +302,7 @@ public class HealthPageTests : BunitContext
|
||||
throw new InvalidOperationException("kpi history unavailable"));
|
||||
Services.AddSingleton(faulting);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -294,7 +328,7 @@ public class HealthPageTests : BunitContext
|
||||
LastReportReceivedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
});
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -316,7 +350,7 @@ public class HealthPageTests : BunitContext
|
||||
LastStatusChangeAt = changedAt,
|
||||
});
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
|
||||
public IReadOnlyCollection<TrackingClient> Created => _created.ToList();
|
||||
|
||||
protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint)
|
||||
protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
|
||||
{
|
||||
var client = new TrackingClient();
|
||||
_created.Add(client);
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ public class SiteStreamGrpcClientFactoryTests
|
||||
{
|
||||
public TrackingEndpointFactory() : base(NullLoggerFactory.Instance) { }
|
||||
public int CreatedCount { get; private set; }
|
||||
protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint)
|
||||
protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
|
||||
{
|
||||
CreatedCount++;
|
||||
return new TrackingEndpointClient(grpcEndpoint);
|
||||
|
||||
@@ -372,4 +372,89 @@ public class SiteCommunicationActorTests : TestKit
|
||||
Assert.Equal(isActive, heartbeat.IsActive);
|
||||
Assert.Equal("site1", heartbeat.SiteId);
|
||||
}
|
||||
|
||||
// ---- Central→site failover relay (Task 10) ----------------------------------
|
||||
// The failover action is injected for the same reason isActiveCheck is: performing a
|
||||
// real Cluster.Leave would require Akka.Cluster in the TestKit ActorSystem. These
|
||||
// tests pin the ROUTING and GUARD logic; the actual Leave against a real two-node
|
||||
// site cluster is covered by SiteFailoverRelayTests in the integration suite.
|
||||
|
||||
[Fact]
|
||||
public void TriggerSiteFailover_IssuesTheLeave_AndAcksWithTheTarget()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
string? roleAskedFor = null;
|
||||
Func<string, string?> failOver = role =>
|
||||
{
|
||||
roleAskedFor = role;
|
||||
return "akka.tcp://scadabridge@site1-a:8082";
|
||||
};
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref, null, failOver)));
|
||||
|
||||
siteActor.Tell(new TriggerSiteFailover("corr-1", "site1"));
|
||||
|
||||
var ack = ExpectMsg<SiteFailoverAck>();
|
||||
Assert.True(ack.Accepted);
|
||||
Assert.Equal("corr-1", ack.CorrelationId);
|
||||
Assert.Equal("akka.tcp://scadabridge@site1-a:8082", ack.TargetAddress);
|
||||
Assert.Null(ack.ErrorMessage);
|
||||
// Site singletons are scoped to the site-specific role, not the base "Site" role;
|
||||
// failing over the wrong scope would move the wrong node.
|
||||
Assert.Equal("site-site1", roleAskedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerSiteFailover_RefusesWhenThereIsNoPeer()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
Func<string, string?> failOver = _ => null;
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref, null, failOver)));
|
||||
|
||||
siteActor.Tell(new TriggerSiteFailover("corr-2", "site1"));
|
||||
|
||||
var ack = ExpectMsg<SiteFailoverAck>();
|
||||
Assert.False(ack.Accepted);
|
||||
Assert.Null(ack.TargetAddress);
|
||||
Assert.NotNull(ack.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerSiteFailover_RefusesACommandAddressedToAnotherSite()
|
||||
{
|
||||
// A misrouted command must be refused, not silently acted on — acting would fail
|
||||
// over a site the operator never selected.
|
||||
var dmProbe = CreateTestProbe();
|
||||
var invoked = false;
|
||||
Func<string, string?> failOver = _ =>
|
||||
{
|
||||
invoked = true;
|
||||
return "addr";
|
||||
};
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref, null, failOver)));
|
||||
|
||||
siteActor.Tell(new TriggerSiteFailover("corr-3", "site2"));
|
||||
|
||||
var ack = ExpectMsg<SiteFailoverAck>();
|
||||
Assert.False(ack.Accepted);
|
||||
Assert.Contains("site2", ack.ErrorMessage);
|
||||
Assert.False(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TriggerSiteFailover_FaultInTheLeave_IsReportedNotThrown()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
Func<string, string?> failOver = _ => throw new InvalidOperationException("cluster unavailable");
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref, null, failOver)));
|
||||
|
||||
siteActor.Tell(new TriggerSiteFailover("corr-4", "site1"));
|
||||
|
||||
var ack = ExpectMsg<SiteFailoverAck>();
|
||||
Assert.False(ack.Accepted);
|
||||
Assert.Contains("cluster unavailable", ack.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end proof that the two halves of the control-plane PSK actually interoperate:
|
||||
/// <see cref="ControlPlaneCredentials"/> on the client and
|
||||
/// <see cref="ControlPlaneAuthInterceptor"/> on the server, over a real gRPC stack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The unit tests either side of this file each test one half against a hand-built input, and
|
||||
/// would both stay green if the halves disagreed — if the credentials never attached to a
|
||||
/// streaming call, if the metadata key case differed, or if attaching call credentials to a
|
||||
/// plaintext channel were rejected outright (gRPC refuses that by default; the code opts in with
|
||||
/// <c>UnsafeUseInsecureChannelCallCredentials</c>, and nothing but a real call proves the opt-in
|
||||
/// works). Getting that wrong takes down every site's streaming and audit-pull path at once,
|
||||
/// which is a bad thing to discover on the rig.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Runs entirely in-process over <see cref="TestServer"/>: no ports, no containers. The service
|
||||
/// is a stub rather than the real <c>SiteStreamGrpcServer</c> — this is a test of the auth
|
||||
/// pipeline, and the real server would drag in an actor system for no added coverage. The method
|
||||
/// paths and message types are the real generated ones.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
|
||||
{
|
||||
private IHost _host = null!;
|
||||
private TestServer _server = null!;
|
||||
|
||||
/// <summary>Boots the in-process gRPC host with the real interceptor.</summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_host = await new HostBuilder()
|
||||
.ConfigureWebHost(web => web
|
||||
.UseTestServer()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
// Registered exactly as Program.cs does: by TYPE on AddGrpc, with the
|
||||
// interceptor itself NOT in DI. That is load-bearing. An earlier version of
|
||||
// this test added it as a singleton, which let DI hand back the instance and
|
||||
// bypassed Grpc.AspNetCore's own activation — hiding a defect where the
|
||||
// interceptor had two public constructors and
|
||||
// InterceptorRegistration.GetFactory() threw on every single call. The rig
|
||||
// caught it; this test did not. Do not pre-register it.
|
||||
services.AddGrpc(o => o.Interceptors.Add<ControlPlaneAuthInterceptor>());
|
||||
services.AddSingleton(Options.Create(
|
||||
new CommunicationOptions { GrpcPsk = SiteKey }));
|
||||
services.AddSingleton<EchoSiteStreamService>();
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(e => e.MapGrpcService<EchoSiteStreamService>());
|
||||
}))
|
||||
.StartAsync();
|
||||
|
||||
_server = _host.GetTestServer();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _host.StopAsync();
|
||||
_host.Dispose();
|
||||
}
|
||||
|
||||
private const string SiteKey = "the-site-a-preshared-key";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a channel through the test server, credentialed exactly as production does.
|
||||
/// </summary>
|
||||
private GrpcChannel Channel(string? key, string siteId = "site-a")
|
||||
{
|
||||
var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() };
|
||||
if (key is not null)
|
||||
{
|
||||
options.WithSiteCredentials(new FixedPskProvider(key), siteId);
|
||||
}
|
||||
return GrpcChannel.ForAddress(_server.BaseAddress, options);
|
||||
}
|
||||
|
||||
private sealed class FixedPskProvider(string key) : ISitePskProvider
|
||||
{
|
||||
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
|
||||
public void Invalidate(string siteId) { }
|
||||
}
|
||||
|
||||
/// <summary>Stub service: echoes back what the auth pipeline let through.</summary>
|
||||
private sealed class EchoSiteStreamService : SiteStreamService.SiteStreamServiceBase
|
||||
{
|
||||
/// <summary>The site header the last accepted call carried.</summary>
|
||||
public string? LastSiteHeader { get; private set; }
|
||||
|
||||
public override Task<PullAuditEventsResponse> PullAuditEvents(
|
||||
PullAuditEventsRequest request, ServerCallContext context)
|
||||
{
|
||||
LastSiteHeader = context.RequestHeaders
|
||||
.FirstOrDefault(h => h.Key == ControlPlaneCredentials.SiteHeader)?.Value;
|
||||
return Task.FromResult(new PullAuditEventsResponse { MoreAvailable = false });
|
||||
}
|
||||
|
||||
public override async Task SubscribeInstance(
|
||||
InstanceStreamRequest request,
|
||||
IServerStreamWriter<SiteStreamEvent> responseStream,
|
||||
ServerCallContext context)
|
||||
{
|
||||
await responseStream.WriteAsync(new SiteStreamEvent { CorrelationId = request.CorrelationId });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorrectKey_IsAccepted_OnAUnaryCall()
|
||||
{
|
||||
using var channel = Channel(SiteKey);
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
var reply = await client.PullAuditEventsAsync(new PullAuditEventsRequest { BatchSize = 1 });
|
||||
|
||||
Assert.False(reply.MoreAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongKey_IsRejected_WithPermissionDenied()
|
||||
{
|
||||
using var channel = Channel("some-other-sites-key");
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest()));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoCredentialsAtAll_IsRejected()
|
||||
{
|
||||
// The pre-T0.3 client shape. This is the case that proves the gap is actually closed.
|
||||
using var channel = Channel(key: null);
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest()));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CredentialsApplyToStreamingCalls_NotJustUnaryOnes()
|
||||
{
|
||||
// CallCredentials cover every call on the channel; a client interceptor that only
|
||||
// handled the unary path would pass the test above and still break every subscription.
|
||||
using var channel = Channel(SiteKey);
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
using var call = client.SubscribeInstance(
|
||||
new InstanceStreamRequest { CorrelationId = "c1", InstanceUniqueName = "i1" });
|
||||
|
||||
Assert.True(await call.ResponseStream.MoveNext(CancellationToken.None));
|
||||
Assert.Equal("c1", call.ResponseStream.Current.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongKey_IsRejected_OnStreamingCallsToo()
|
||||
{
|
||||
using var channel = Channel("wrong");
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
using var call = client.SubscribeInstance(new InstanceStreamRequest { CorrelationId = "c1" });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await call.ResponseStream.MoveNext(CancellationToken.None));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnresolvableKey_FailsTheCall_RatherThanDialingWithoutOne()
|
||||
{
|
||||
// SitePskProvider throws when a site has no key anywhere. What matters here is that the
|
||||
// throw stops the call: the alternative — swallowing it and sending the request
|
||||
// unauthenticated — is the exact failure this design exists to prevent. The status code
|
||||
// is gRPC's choice, so assert the RpcException and record what it actually is rather
|
||||
// than pinning a guess: callers already treat every non-OK status as a failed call, and
|
||||
// the diagnosable signal is SitePskProvider's own LogError, not this code.
|
||||
var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() }
|
||||
.WithSiteCredentials(new ThrowingPskProvider(), "site-a");
|
||||
using var channel = GrpcChannel.ForAddress(_server.BaseAddress, options);
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest()));
|
||||
|
||||
Assert.NotEqual(StatusCode.OK, ex.StatusCode);
|
||||
|
||||
// And nothing reached the service.
|
||||
Assert.Null(_host.Services.GetRequiredService<EchoSiteStreamService>().LastSiteHeader);
|
||||
}
|
||||
|
||||
private sealed class ThrowingPskProvider : ISitePskProvider
|
||||
{
|
||||
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
|
||||
=> throw new InvalidOperationException($"no key for '{siteId}'");
|
||||
public void Invalidate(string siteId) { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheSiteHeaderTravels_SoCentralCanPickAPerSiteKeyInPhase1A()
|
||||
{
|
||||
// Central's own interceptor (T1A.2) verifies against the key for the site named in this
|
||||
// header. Shipping it now means Phase 1A adds a lookup, not a wire change.
|
||||
using var channel = Channel(SiteKey, siteId: "site-a");
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
await client.PullAuditEventsAsync(new PullAuditEventsRequest());
|
||||
|
||||
var service = _host.Services.GetRequiredService<EchoSiteStreamService>();
|
||||
Assert.Equal("site-a", service.LastSiteHeader);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The site↔central gRPC control plane's inbound gate (ClusterClient→gRPC migration, T0.3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>SiteStreamService</c> shipped unauthenticated: plaintext h2c with no interceptor, so
|
||||
/// anything that could reach a site node's gRPC port could open a live data stream or pull audit
|
||||
/// rows back with <c>PullAuditEvents</c>/<c>PullSiteCalls</c>. These tests pin the gate that
|
||||
/// closes it, and — just as importantly — pin that it does NOT gate LocalDb sync, which has its
|
||||
/// own interceptor and its own key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Sibling of <see cref="LocalDbSyncAuthInterceptorTests"/>; the two interceptors share a shape
|
||||
/// deliberately, so the cases mirror each other.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ControlPlaneAuthInterceptorTests
|
||||
{
|
||||
// Real method paths: package `sitestream`, service `SiteStreamService` (sitestream.proto).
|
||||
private const string SubscribeMethod = "/sitestream.SiteStreamService/SubscribeInstance";
|
||||
private const string PullAuditMethod = "/sitestream.SiteStreamService/PullAuditEvents";
|
||||
private const string LocalDbSyncMethod = "/localdb_sync.v1.LocalDbSync/Sync";
|
||||
|
||||
private static ControlPlaneAuthInterceptor CreateInterceptor(string? psk)
|
||||
=> new(
|
||||
Options.Create(new CommunicationOptions { GrpcPsk = psk ?? "" }),
|
||||
NullLogger<ControlPlaneAuthInterceptor>.Instance);
|
||||
|
||||
private static ServerCallContext CreateContext(string method, string? authorizationHeader)
|
||||
{
|
||||
var headers = new Metadata();
|
||||
if (authorizationHeader is not null)
|
||||
headers.Add("authorization", authorizationHeader);
|
||||
|
||||
return new FakeServerCallContext(method, headers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="ServerCallContext"/> carrying just a method name and request headers —
|
||||
/// the only two things the interceptor reads. Hand-rolled for the same reason the LocalDb
|
||||
/// sibling hand-rolls one: <c>Grpc.Core.Testing.TestServerCallContext</c> lives in the
|
||||
/// retired native package and does not exist on the grpc-dotnet stack.
|
||||
/// </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;
|
||||
}
|
||||
|
||||
/// <summary>Invokes the interceptor's unary path with a trivial continuation.</summary>
|
||||
private static Task<string> Invoke(
|
||||
ControlPlaneAuthInterceptor interceptor, ServerCallContext context)
|
||||
=> interceptor.UnaryServerHandler<string, string>(
|
||||
"request", context, (_, _) => Task.FromResult("ok"));
|
||||
|
||||
[Fact]
|
||||
public async Task LocalDbSyncMethod_PassesThrough_BecauseItHasItsOwnGateAndItsOwnKey()
|
||||
{
|
||||
// Both interceptors sit on the same site AddGrpc pipeline and see every call. If this
|
||||
// one also gated sync, a site would need its central-facing key to equal its pair-replication
|
||||
// key — collapsing two distinct trust relationships into one secret.
|
||||
var interceptor = CreateInterceptor("the-site-key");
|
||||
var context = CreateContext(LocalDbSyncMethod, authorizationHeader: null);
|
||||
|
||||
Assert.Equal("ok", await Invoke(interceptor, context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedMethod_WithNoKeyConfigured_IsDenied_EvenWithABearerToken()
|
||||
{
|
||||
// Fail-closed, and this is the case that differs in consequence from LocalDb's: an
|
||||
// unset key here does not disable an optional feature, it closes the site's entire
|
||||
// central-facing surface. Loud refusal beats silent unauthenticated service.
|
||||
var interceptor = CreateInterceptor(psk: null);
|
||||
var context = CreateContext(SubscribeMethod, "Bearer anything-at-all");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedMethod_WithNoBearerToken_IsDenied()
|
||||
{
|
||||
var interceptor = CreateInterceptor("the-site-key");
|
||||
var context = CreateContext(SubscribeMethod, authorizationHeader: null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedMethod_WithWrongBearerToken_IsDenied()
|
||||
{
|
||||
var interceptor = CreateInterceptor("the-site-key");
|
||||
var context = CreateContext(SubscribeMethod, "Bearer some-other-sites-key");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedMethod_WithCorrectBearerToken_PassesThrough()
|
||||
{
|
||||
var interceptor = CreateInterceptor("the-site-key");
|
||||
var context = CreateContext(SubscribeMethod, "Bearer the-site-key");
|
||||
|
||||
Assert.Equal("ok", await Invoke(interceptor, context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedMethod_WithCorrectKey_ButNoBearerScheme_IsDenied()
|
||||
{
|
||||
// A raw key with no "Bearer " prefix is not what ControlPlaneCredentials sends;
|
||||
// accepting it would widen the accepted credential shape for nothing.
|
||||
var interceptor = CreateInterceptor("the-site-key");
|
||||
var context = CreateContext(SubscribeMethod, "the-site-key");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedMethod_TokenComparison_IsNotAPrefixMatch()
|
||||
{
|
||||
// A StartsWith comparison would accept a truncated key and make the secret recoverable
|
||||
// one character at a time. FixedTimeEquals also rejects on length.
|
||||
var interceptor = CreateInterceptor("the-site-key");
|
||||
var context = CreateContext(SubscribeMethod, "Bearer the-site-ke");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerStreaming_IsGated_BecauseThatIsHowSubscriptionsActuallyRun()
|
||||
{
|
||||
// SubscribeInstance/SubscribeSite are server-streaming. Gating only the unary path
|
||||
// would leave the live data feed wide open while every unary test still passed.
|
||||
var interceptor = CreateInterceptor("the-site-key");
|
||||
var context = CreateContext(SubscribeMethod, "Bearer the-wrong-key");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() =>
|
||||
interceptor.ServerStreamingServerHandler<string, string>(
|
||||
"request",
|
||||
responseStream: null!,
|
||||
context,
|
||||
(_, _, _) => Task.CompletedTask));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullRpcs_AreGated_BecauseTheyReturnAuditRows()
|
||||
{
|
||||
// The strongest reason this gate exists: PullAuditEvents/PullSiteCalls hand back audit
|
||||
// content to anyone who asks. Unary, so it would be easy to miss in a streaming-focused
|
||||
// reading of the service.
|
||||
var interceptor = CreateInterceptor("the-site-key");
|
||||
var context = CreateContext(PullAuditMethod, authorizationHeader: null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedPrefixes_AreConstructorProvided_SoLaterPhasesAddServicesNotInterceptors()
|
||||
{
|
||||
// Phases 1A/1B add CentralControlService and SiteCommandService to this same gate.
|
||||
var interceptor = new ControlPlaneAuthInterceptor(
|
||||
Options.Create(new CommunicationOptions { GrpcPsk = "k" }),
|
||||
NullLogger<ControlPlaneAuthInterceptor>.Instance,
|
||||
new[] { "/scadabridge.sitecommand.v1.SiteCommandService/" });
|
||||
|
||||
var gated = CreateContext("/scadabridge.sitecommand.v1.SiteCommandService/ExecuteQuery", null);
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, gated));
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
|
||||
// ...and the default set is no longer implied once an explicit set is supplied.
|
||||
var notGated = CreateContext(SubscribeMethod, authorizationHeader: null);
|
||||
Assert.Equal("ok", await Invoke(interceptor, notGated));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheInterceptorHasExactlyOnePublicConstructor()
|
||||
{
|
||||
// Grpc.AspNetCore registers this interceptor BY TYPE, and
|
||||
// InterceptorRegistration.GetFactory() throws "Multiple constructors accepting all given
|
||||
// argument types have been found" the moment a second public constructor is applicable.
|
||||
// The throw lands inside the pipeline on every call, so the symptom is not a startup
|
||||
// failure but a gate that authorizes nothing and fails everything with
|
||||
// Unknown / "Exception was thrown by handler" — a shape that looks like a handler bug,
|
||||
// not an auth bug. This shipped once and was caught only on the docker rig; the
|
||||
// prefix-set constructor is internal now to keep it from recurring.
|
||||
var publicCtors = typeof(ControlPlaneAuthInterceptor).GetConstructors();
|
||||
|
||||
Assert.Single(publicCtors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultGatedPrefixes_MatchTheRealSiteStreamServicePath()
|
||||
{
|
||||
// A typo here disables the whole gate silently: every call would simply pass through.
|
||||
// Pin it against a path taken from the generated service, not from the proto text.
|
||||
var method = SiteStreamService.Descriptor.FullName;
|
||||
Assert.Contains(
|
||||
ControlPlaneAuthInterceptor.DefaultGatedPrefixes,
|
||||
p => p == $"/{method}/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Central's per-site gRPC preshared key resolution (ClusterClient→gRPC migration, T0.3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The property that matters most here is the negative one: a site whose key cannot be found
|
||||
/// must produce a throw, never an unauthenticated channel. Everything else — the two sources,
|
||||
/// the caching, the invalidation — exists to make that behaviour usable in practice.
|
||||
/// </remarks>
|
||||
public class SitePskProviderTests
|
||||
{
|
||||
private static SitePskProvider Create(
|
||||
ISecretResolver resolver, CommunicationOptions? options = null)
|
||||
=> new(
|
||||
resolver,
|
||||
new StaticOptionsMonitor(options ?? new CommunicationOptions()),
|
||||
NullLogger<SitePskProvider>.Instance);
|
||||
|
||||
private sealed class StaticOptionsMonitor(CommunicationOptions value)
|
||||
: IOptionsMonitor<CommunicationOptions>
|
||||
{
|
||||
public CommunicationOptions CurrentValue => value;
|
||||
public CommunicationOptions Get(string? name) => value;
|
||||
public IDisposable? OnChange(Action<CommunicationOptions, string?> listener) => null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvesFromTheSecretStore_UnderTheSiteQualifiedName()
|
||||
{
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-a"), Arg.Any<CancellationToken>())
|
||||
.Returns("key-for-a");
|
||||
|
||||
var psk = await Create(resolver).GetAsync("site-a", CancellationToken.None);
|
||||
|
||||
Assert.Equal("key-for-a", psk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfiguredMapWins_SoAHostWithNoMasterKeyCanStillDial()
|
||||
{
|
||||
// The development rig runs with no secrets master key at all — every credential
|
||||
// arrives as an environment override. Without this source, the rig could not use the
|
||||
// gated control plane and the fail-closed design would be untestable there.
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
||||
.Returns("from-the-store");
|
||||
|
||||
var options = new CommunicationOptions();
|
||||
options.SitePsks["site-a"] = "from-config";
|
||||
|
||||
var psk = await Create(resolver, options).GetAsync("site-a", CancellationToken.None);
|
||||
|
||||
Assert.Equal("from-config", psk);
|
||||
await resolver.DidNotReceive().GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreIsStillConsulted_ForASiteMissingFromTheMap()
|
||||
{
|
||||
// Sites are added at runtime from the Central UI, so the map can never be complete.
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-b"), Arg.Any<CancellationToken>())
|
||||
.Returns("key-for-b");
|
||||
|
||||
var options = new CommunicationOptions();
|
||||
options.SitePsks["site-a"] = "from-config";
|
||||
|
||||
var psk = await Create(resolver, options).GetAsync("site-b", CancellationToken.None);
|
||||
|
||||
Assert.Equal("key-for-b", psk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingSecret_Throws_AndNeverYieldsAnEmptyKey()
|
||||
{
|
||||
// Fail-closed. The alternative — returning "" — would build a channel that presents
|
||||
// "Bearer " and gets PermissionDenied anyway, but with a far less diagnosable error.
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
||||
.Returns((string?)null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await Create(resolver).GetAsync("site-a", CancellationToken.None));
|
||||
|
||||
// The message must name both sources — either one fixes it.
|
||||
Assert.Contains("SB-GRPC-PSK-site-a", ex.Message);
|
||||
Assert.Contains("SitePsks", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptySecret_IsTreatedAsMissing()
|
||||
{
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()).Returns("");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await Create(resolver).GetAsync("site-a", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AFaultingStore_SurfacesAsKeyNotConfigured_NotAsACryptoError()
|
||||
{
|
||||
// A host with no master key throws from the resolver. The operator's problem is the
|
||||
// same either way — "this site has no key" — so the diagnosis must say that.
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
||||
.Returns<string?>(_ => throw new InvalidOperationException("no master key configured"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await Create(resolver).GetAsync("site-a", CancellationToken.None));
|
||||
|
||||
Assert.Contains("SB-GRPC-PSK-site-a", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuccessfulResolvesAreCached_SoEveryCallDoesNotHitTheStore()
|
||||
{
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()).Returns("k");
|
||||
var provider = Create(resolver);
|
||||
|
||||
await provider.GetAsync("site-a", CancellationToken.None);
|
||||
await provider.GetAsync("site-a", CancellationToken.None);
|
||||
await provider.GetAsync("site-a", CancellationToken.None);
|
||||
|
||||
await resolver.Received(1).GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailuresAreNotCached_SoASeededKeyIsPickedUpWithoutARestart()
|
||||
{
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
||||
.Returns((string?)null, "seeded-later");
|
||||
var provider = Create(resolver);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await provider.GetAsync("site-a", CancellationToken.None));
|
||||
|
||||
Assert.Equal("seeded-later", await provider.GetAsync("site-a", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalidate_DropsTheCachedKey_SoARotatedKeyIsRead()
|
||||
{
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
||||
.Returns("old", "rotated");
|
||||
var provider = Create(resolver);
|
||||
|
||||
Assert.Equal("old", await provider.GetAsync("site-a", CancellationToken.None));
|
||||
provider.Invalidate("site-a");
|
||||
|
||||
Assert.Equal("rotated", await provider.GetAsync("site-a", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KeysAreScopedPerSite_SoOneSiteNeverPresentsAnothersKey()
|
||||
{
|
||||
// The whole reason the design rejected a single fleet-wide key: blast radius.
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-a"), Arg.Any<CancellationToken>())
|
||||
.Returns("key-a");
|
||||
resolver.GetAsync(new SecretName("SB-GRPC-PSK-site-b"), Arg.Any<CancellationToken>())
|
||||
.Returns("key-b");
|
||||
var provider = Create(resolver);
|
||||
|
||||
Assert.Equal("key-a", await provider.GetAsync("site-a", CancellationToken.None));
|
||||
Assert.Equal("key-b", await provider.GetAsync("site-b", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@ public class StartupValidatorTests
|
||||
["ScadaBridge:Database:SiteDbPath"] = "./data/scadabridge.db",
|
||||
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@site-a-node1:8082",
|
||||
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8082",
|
||||
// T0.3: the gRPC control plane is fail-closed, so a Site node without a preshared
|
||||
// key serves nothing while still looking healthy. Required at boot for that reason.
|
||||
["ScadaBridge:Communication:GrpcPsk"] = "test-site-control-plane-key",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
@@ -56,6 +59,44 @@ public class StartupValidatorTests
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteWithoutGrpcPsk_FailsValidation()
|
||||
{
|
||||
// The failure this prevents is silent: ControlPlaneAuthInterceptor refuses every
|
||||
// SiteStream call without a key, so the node boots, joins its pair, reports healthy —
|
||||
// and serves no live streams, no audit pulls and no cached-telemetry ingest. Central
|
||||
// sees a site that is up and answering heartbeats but never sends anything.
|
||||
var values = ValidSiteConfig();
|
||||
values.Remove("ScadaBridge:Communication:GrpcPsk");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("GrpcPsk", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteWithBlankGrpcPsk_FailsValidation()
|
||||
{
|
||||
// Whitespace is not a key. An empty-string value would otherwise satisfy a
|
||||
// key-present check while leaving the interceptor in its fail-closed state.
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Communication:GrpcPsk"] = " ";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("GrpcPsk", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralWithoutGrpcPsk_PassesValidation()
|
||||
{
|
||||
// Central holds one key PER SITE (SitePsks / the secret store), not a single key of
|
||||
// its own, so this setting is meaningless there and must not be required.
|
||||
var config = BuildConfig(ValidCentralConfig());
|
||||
|
||||
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingRole_FailsValidation()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// Real-cluster proof for the central→site failover relay (Task 10). The unit tests in
|
||||
/// <c>SiteCommunicationActorTests</c> pin the routing and guard logic with the failover action
|
||||
/// stubbed out; this pins the part they cannot — that the shared
|
||||
/// <see cref="ClusterFailoverCoordinator"/> actually moves a SITE pair when scoped to the
|
||||
/// site-specific role.
|
||||
///
|
||||
/// <para>The site-specific role scope is the load-bearing detail: site singletons (the
|
||||
/// Deployment Manager) are placed on <c>site-{SiteId}</c>, not on the base <c>Site</c> role, so
|
||||
/// failing over the wrong scope would move the wrong node.</para>
|
||||
/// </summary>
|
||||
public sealed class SiteFailoverRelayTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Failing_over_a_site_pair_moves_the_oldest_and_the_survivor_takes_over()
|
||||
{
|
||||
// A site pair, both nodes carrying the site-specific role.
|
||||
await using var f = await TwoNodeClusterFixture.StartAsync(role: "site-SiteA");
|
||||
var oldest = Akka.Cluster.Cluster.Get(f.NodeA);
|
||||
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(oldest, "site-SiteA"),
|
||||
"precondition: NodeA must be the active (oldest Up) site node");
|
||||
|
||||
// Issued from the other node, as the relay does when contact rotation lands there.
|
||||
var target = ClusterFailoverCoordinator.FailOverOldest(f.NodeB, "site-SiteA");
|
||||
|
||||
Assert.Equal(oldest.SelfAddress, target);
|
||||
await f.NodeA.WhenTerminated.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
await TwoNodeClusterFixture.WaitForMemberRemoved(f.NodeB, oldest.SelfAddress, TimeSpan.FromSeconds(30));
|
||||
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(f.NodeB), "site-SiteA"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_site_role_scope_that_matches_no_member_is_refused()
|
||||
{
|
||||
// Guards the role-scoping mistake directly: asking for a site that isn't this pair
|
||||
// must find no members and refuse, rather than falling back to some other node.
|
||||
await using var f = await TwoNodeClusterFixture.StartAsync(role: "site-SiteA");
|
||||
|
||||
var target = ClusterFailoverCoordinator.FailOverOldest(f.NodeB, "site-SiteB");
|
||||
|
||||
Assert.Null(target);
|
||||
// Positive control: the pair is untouched and still fully formed.
|
||||
await TwoNodeClusterFixture.WaitForMembersUp(f.NodeB, 2, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
@@ -3643,7 +3643,8 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
private sealed class TrackingGrpcFactory : SiteStreamGrpcClientFactory
|
||||
{
|
||||
public TrackingGrpcFactory() : base(NullLoggerFactory.Instance) { }
|
||||
protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint) => new TrackingClient(grpcEndpoint);
|
||||
protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
|
||||
=> new TrackingClient(grpcEndpoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user