Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2201fd8828 | |||
| f1d9ad5ca2 | |||
| 1cc0fa4007 | |||
| 908951be9d | |||
| bde042b4d4 | |||
| 4090a478c8 | |||
| ed3c6c61c5 | |||
| ef498c80c2 | |||
| fed0685633 | |||
| 0c6e5b3ace | |||
| c823a7b60b | |||
| f61c816acf | |||
| 73ce824f6c | |||
| ec6f82b124 | |||
| 579282f015 | |||
| e15c3cb052 | |||
| 96702321b1 | |||
| cf1b3d40a5 | |||
| 5e2e40a927 | |||
| baae59ce3b | |||
| 34aede6767 | |||
| 7d42c85345 | |||
| 5a3951bcfd | |||
| 06e1046317 | |||
| 4fe1917aa6 | |||
| 69ea7937ca | |||
| b75bfbe8f4 | |||
| a038363e74 | |||
| de57d834b1 | |||
| 32d6b48910 | |||
| bfbfcdd112 | |||
| 309296f467 | |||
| ebe6aeac98 | |||
| c8b3a2281a | |||
| e1a505d662 | |||
| 11a716a07f | |||
| 8c06635ee5 | |||
| 74e815c4d7 | |||
| c2df4a0aae | |||
| 17f16ea181 | |||
| 970613eebd | |||
| c185f621a4 | |||
| 197731ae2d | |||
| 8fb1a814d4 | |||
| d5248f61a2 | |||
| 219fa6ddb4 |
@@ -0,0 +1,124 @@
|
||||
# Continuous integration for mxaccessgw (Gitea Actions; origin is Gitea at gitea.dohertylan.com).
|
||||
#
|
||||
# Scope note: the x86 Worker (src/ZB.MOM.WW.MxGateway.Worker) and Worker.Tests target
|
||||
# .NET Framework 4.8 / x86 and need MXAccess COM installed, so they build ONLY on a Windows host.
|
||||
# They are out of scope for this Linux `portable` job — the `windows` job below (self-hosted windev
|
||||
# runner) covers them, and live-MXAccess integration runs are scheduled-only so they never gate a push.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
# Nightly live-MXAccess smoke (windev). Push/PR runs skip the live-mxaccess job.
|
||||
- cron: '0 6 * * *'
|
||||
|
||||
jobs:
|
||||
portable:
|
||||
# Any Linux runner: no MXAccess, no x86. Covers the NonWindows solution, gateway fake-worker
|
||||
# tests, the codegen/descriptor freshness guards, and the clients that build on Linux.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
# protoc is pinned to 34.1 (see docs/ToolchainLinks.md) so the committed client descriptor
|
||||
# regenerates reproducibly. The freshness check normalizes source_code_info, so it tolerates
|
||||
# patch drift, but keep the CI toolchain on the pin.
|
||||
- name: Install protoc 34.1
|
||||
run: |
|
||||
curl -sSL -o /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v34.1/protoc-34.1-linux-x86_64.zip
|
||||
sudo unzip -o /tmp/protoc.zip -d /usr/local bin/protoc 'include/*'
|
||||
protoc --version
|
||||
|
||||
- name: Build NonWindows solution
|
||||
run: dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx -c Release
|
||||
|
||||
# IPC-01 / IPC-19 / IPC-20: descriptor set + Contracts/Generated must match the current protos.
|
||||
- name: Codegen / descriptor freshness
|
||||
shell: pwsh
|
||||
run: ./scripts/check-codegen.ps1
|
||||
|
||||
- name: Gateway fake-worker tests
|
||||
run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj -c Release --no-build
|
||||
|
||||
- name: .NET client
|
||||
run: dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx -c Release
|
||||
|
||||
- name: Go client
|
||||
working-directory: clients/go
|
||||
run: |
|
||||
test -z "$(gofmt -l .)" || (gofmt -l . && echo 'gofmt needed' && exit 1)
|
||||
go build ./...
|
||||
go test ./...
|
||||
|
||||
- name: Rust client
|
||||
working-directory: clients/rust
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Python client
|
||||
working-directory: clients/python
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
python -m pytest
|
||||
|
||||
java:
|
||||
# Java client runs on a JDK-17 Linux runner (the macOS dev box has no JRE). The protobuf gradle
|
||||
# plugin rewrites MxaccessGateway.java with spurious protobuf-runtime-version churn on every
|
||||
# build; when no .proto changed, revert that one file so checkGeneratedClean / a dirty tree does
|
||||
# not fail the build (repo memory project_java_generated_churn).
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
- name: Gradle test
|
||||
working-directory: clients/java
|
||||
run: gradle test
|
||||
- name: Revert spurious protobuf-version churn (no .proto changed)
|
||||
run: git checkout -- clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java || true
|
||||
- name: Verify generated tree is clean
|
||||
run: git diff --exit-code -- clients/java/src/main/generated
|
||||
|
||||
windows:
|
||||
# Self-hosted windev runner (10.100.0.48): the only host that can build the x86 / net48 Worker
|
||||
# and MXAccess-adjacent code. Not required for a green portable build; runs where a runner exists.
|
||||
runs-on: [self-hosted, windows]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build x86 Worker
|
||||
run: dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
|
||||
- name: Worker tests
|
||||
run: dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86
|
||||
|
||||
live-mxaccess:
|
||||
# Opt-in, scheduled only — needs installed MXAccess COM + live provider state. Never gates a push.
|
||||
if: ${{ github.event_name == 'schedule' }}
|
||||
runs-on: [self-hosted, windows, mxaccess]
|
||||
env:
|
||||
MXGATEWAY_RUN_LIVE_MXACCESS_TESTS: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Live MXAccess smoke
|
||||
run: dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests
|
||||
@@ -76,11 +76,11 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
|
||||
- **Style guides** in `docs/style-guides/` are authoritative. Follow `CSharpStyleGuide.md` for gateway/worker/.NET-client code: file-scoped namespaces, `sealed` by default, `Async` suffix on Task-returning methods, MXAccess-aligned names (`MxStatusProxy`, `ServerHandle`, `ItemHandle`, `HResult`).
|
||||
- **MXAccess parity is the contract.** Don't "fix" surprising MXAccess behavior (e.g., `WriteSecured` failing before a value-bearing NMX body, distinct `OperationComplete` semantics, invalid-handle exceptions) unless the client explicitly opts into a non-parity mode. The installed MXAccess COM component is the baseline.
|
||||
- **Don't synthesize events.** The gateway forwards only events the worker emits; it never invents `OperationComplete` from write completion or command replies.
|
||||
- **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. See `docs/DesignDecisions.md` and `docs/Sessions.md`.
|
||||
- **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. The reconnect protocol is consumable end-to-end: a resuming `StreamEvents` (via `after_worker_sequence`) that predates the retained ring gets a `ReplayGap` sentinel, and all five official clients surface it as a typed signal. Orphan-worker reattach after a gateway restart is **deferred, not planned** — see `oldtasks.md` (session-resilience epic Phase 5); the invariant on the next line stands. See `docs/DesignDecisions.md` and `docs/Sessions.md`.
|
||||
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
|
||||
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
|
||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
|
||||
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
|
||||
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
|
||||
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
|
||||
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
|
||||
|
||||
@@ -88,7 +88,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
|
||||
|
||||
When source code changes, build and test the affected component before reporting work done. If the change crosses component boundaries, build each affected component — don't rely on a single top-level build:
|
||||
|
||||
**Run targeted tests per task, never the full suite each time.** When executing a plan task-by-task, run only the tests that exercise the code that task touched (`dotnet test --filter "FullyQualifiedName~<TestClass>"`, or the per-task test named in the plan). The full gateway suite is slow and leaves orphaned testhost processes — run it at most once per phase (after a related batch of tasks lands), not after every task.
|
||||
**Run targeted tests per task, never the full suite each time.** When executing a plan task-by-task, run only the tests that exercise the code that task touched (`dotnet test --filter "FullyQualifiedName~<TestClass>"`, or the per-task test named in the plan). The full gateway suite is slow — run it at most once per phase (after a related batch of tasks lands), not after every task. This is a speed guideline, not a correctness one: the suite exits cleanly (verified on macOS and the Windows dev box — 0 surviving `testhost`/worker processes after a full run), so filtered runs are about turnaround, not about avoiding a process leak.
|
||||
|
||||
| Changed area | Required verification |
|
||||
|---|---|
|
||||
@@ -123,7 +123,7 @@ Gateway gRPC clients authenticate with an API key in metadata: `authorization: B
|
||||
|
||||
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session.
|
||||
|
||||
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled. `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
|
||||
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
|
||||
|
||||
## Process / Platform Notes
|
||||
|
||||
|
||||
@@ -62,18 +62,18 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| GWC-01 | Critical | P0 | M | — | Done | Alarm monitor and distributor pump both drain the single worker event channel |
|
||||
| GWC-02 | High | P0 | M | — | Done | Faulted sessions are never swept |
|
||||
| GWC-03 | High | P0 | S | — | Done | Documented sparse-array max-length bound is unimplemented |
|
||||
| GWC-04 | Medium | P1 | M | — | Not started | Full event channel stalls the worker read loop behind command replies |
|
||||
| GWC-04 | Medium | P1 | M | — | Done | Full event channel stalls the worker read loop behind command replies |
|
||||
| GWC-05 | Medium | — | S | — | Not started | Worker pipe created with no ACL / no CurrentUserOnly |
|
||||
| GWC-06 | Medium | P2 | S | GWC-07,08 | Not started | Stopwatch allocated per streamed event |
|
||||
| GWC-07 | Medium | P2 | S | GWC-06,08 | Not started | Every mapped event is deep-cloned |
|
||||
| GWC-08 | Medium | P2 | M | GWC-06,07 | Not started | Pipe framing allocates per frame and writes twice |
|
||||
| GWC-06 | Medium | P2 | S | GWC-07,08 | Done | Stopwatch allocated per streamed event |
|
||||
| GWC-07 | Medium | P2 | S | GWC-06,08 | Done | Every mapped event is deep-cloned |
|
||||
| GWC-08 | Medium | P2 | M | GWC-06,07 | Done | Pipe framing allocates per frame and writes twice |
|
||||
| GWC-09 | Medium | — | M | — | Not started | Startup probe is a no-op; its retry pipeline can never retry |
|
||||
| GWC-10 | Medium | — | S | — | Not started | Envelope `sequence` monotonicity specified but not enforced |
|
||||
| GWC-11 | Low | — | S | — | Not started | `_workerClient` written under lock but read lock-free |
|
||||
| GWC-12 | Low | — | S | — | Not started | `WorkerClient.DisposeAsync` not safe against double-dispose |
|
||||
| GWC-13 | Low | — | M | — | Not started | Worker-ready wait is a 25 ms poll loop |
|
||||
| GWC-14 | Low | P2 | M | — | Not started | Replay ring is a `LinkedList` with a node alloc per event |
|
||||
| GWC-15 | Low | P2 | S | — | Not started | Per-event gauge reads `ChannelReader.Count` every event |
|
||||
| GWC-14 | Low | P2 | M | — | Done | Replay ring is a `LinkedList` with a node alloc per event |
|
||||
| GWC-15 | Low | P2 | S | — | Done | Per-event gauge reads `ChannelReader.Count` every event |
|
||||
| GWC-16 | Low | — | S | — | Not started | `Invoke` resolves the session twice per command |
|
||||
| GWC-17 | Low | — | M | — | Not started | Sessions layer throws `Grpc.Core.RpcException` (layering leak) |
|
||||
| GWC-18 | Low | — | S | — | Not started | `GatewaySession` implements `DisposeAsync` without `IAsyncDisposable` |
|
||||
@@ -90,18 +90,18 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| WRK-01 | High | P0 | M | — | Done | Long `ReadBulk` self-faults as `StaHung`; all replies then dropped |
|
||||
| WRK-02 | Medium | — | M | — | Not started | STA thread death after startup is silent; future work hangs forever |
|
||||
| WRK-03 | Medium | — | S | WRK-02 | Not started | Commands after shutdown starts are dropped with no reply |
|
||||
| WRK-04 | Medium | — | M | — | Not started | Envelope `sequence` can appear out of order on the wire |
|
||||
| WRK-04 | Medium | — | M | — | Done | Envelope `sequence` can appear out of order on the wire |
|
||||
| WRK-05 | Medium | — | M | — | Not started | One transient alarm-poll failure kills the whole session |
|
||||
| WRK-06 | Medium | P2 | S | — | Not started | `MXSTATUS_PROXY` conversion reflects per field, per event |
|
||||
| WRK-07 | Medium | P1 | M | WRK-04 | Not started | Documented outbound write priority not implemented |
|
||||
| WRK-06 | Medium | P2 | S | — | Done | `MXSTATUS_PROXY` conversion reflects per field, per event |
|
||||
| WRK-07 | Medium | P1 | M | WRK-04 | Done | Documented outbound write priority not implemented |
|
||||
| WRK-08 | Low | — | S | — | Not started | Residual event queue discarded at graceful shutdown, undocumented |
|
||||
| WRK-09 | Low | — | S | — | Not started | No `AppDomain.UnhandledException` hook |
|
||||
| WRK-10 | Low | — | S | — | Not started | Top-level catch logs exception type but never message |
|
||||
| WRK-11 | Low | P2 | S | — | Not started | Every accepted event is defensively cloned on enqueue |
|
||||
| WRK-12 | Low | P2 | M | WRK-04 | Not started | No event batching per envelope; one flush per event; 25 ms poll |
|
||||
| WRK-11 | Low | P2 | S | — | Done | Every accepted event is defensively cloned on enqueue |
|
||||
| WRK-12 | Low | P2 | M | WRK-04 | Done | No event batching per envelope; one flush per event; 25 ms poll |
|
||||
| WRK-13 | Low | — | S | — | Not started | Frame writer allocates a fresh buffer per frame; reader pools |
|
||||
| WRK-14 | Low | — | M | — | Not started | Private-field naming split `_camelCase` vs `camelCase` |
|
||||
| WRK-15 | Low | P2 | S | — | Not started | Doc drift: STA thread name and heartbeat-counter note |
|
||||
| WRK-15 | Low | P2 | S | — | Done | Doc drift: STA thread name and heartbeat-counter note |
|
||||
| WRK-16 | Low | — | S | — | Not started | Boilerplate duplication in IPC envelope/ctor overloads |
|
||||
| WRK-17 | Low | — | S | — | Not started | Gateway death exits with wrong exit code (6 not 5) |
|
||||
| WRK-18 | Low | — | S | — | Not started | Event-queue overflow exits as generic `UnexpectedFailure` |
|
||||
@@ -112,45 +112,45 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
|
||||
| ID | Sev | Tier | Eff | Dep | Status | Title |
|
||||
|---|---|:-:|:-:|---|---|---|
|
||||
| IPC-01 | High | P1 | M | — | Not started | Published client descriptor set 7 weeks stale; nothing enforces freshness |
|
||||
| IPC-02 | Medium | P1 | M | IPC-03 | Not started | Worker max frame size hard-coded, cannot follow gateway config |
|
||||
| IPC-03 | Medium | P1 | M | IPC-02 | Not started | gRPC max == pipe max with zero headroom; oversized write faults whole session |
|
||||
| IPC-04 | Medium | P1 | S | IPC-03 | Not started | `DrainEvents max_events=0` packs entire queue into one reply frame |
|
||||
| IPC-05 | Medium | P2 | S | — | Not started | Redundant deep copies on command and event hot paths |
|
||||
| IPC-06 | Medium | P2 | S | — | Not started | `gateway.md` Worker Envelope sketch no longer matches the contract |
|
||||
| IPC-07 | Medium | P2 | S | — | Not started | `docs/Grpc.md` says six RPCs; there are seven |
|
||||
| IPC-01 | High | P1 | M | — | Done | Published client descriptor set 7 weeks stale; nothing enforces freshness |
|
||||
| IPC-02 | Medium | P1 | M | IPC-03 | Done | Worker max frame size hard-coded, cannot follow gateway config |
|
||||
| IPC-03 | Medium | P1 | M | IPC-02 | Done | gRPC max == pipe max with zero headroom; oversized write faults whole session |
|
||||
| IPC-04 | Medium | P1 | S | IPC-03 | Done | `DrainEvents max_events=0` packs entire queue into one reply frame |
|
||||
| IPC-05 | Medium | P2 | S | — | Done | Redundant deep copies on command and event hot paths |
|
||||
| IPC-06 | Medium | P2 | S | — | Done | `gateway.md` Worker Envelope sketch no longer matches the contract |
|
||||
| IPC-07 | Medium | P2 | S | — | Done | `docs/Grpc.md` says six RPCs; there are seven |
|
||||
| IPC-08 | Medium | — | M | — | Not started | `WorkerCancel` defined and handled but never sent |
|
||||
| IPC-09 | Medium | P1 | M | IPC-01 | Not started | Known codegen fragilities have no in-repo guards |
|
||||
| IPC-09 | Medium | P1 | M | IPC-01 | Done | Known codegen fragilities have no in-repo guards |
|
||||
| IPC-10 | Low | — | S | — | Not started | Envelope `sequence` is write-only; monotonicity never validated |
|
||||
| IPC-11 | Low | — | S | — | Not started | No protocol version negotiation despite `supported_protocol_version` name |
|
||||
| IPC-12 | Low | — | S | — | Not started | Gateway frame writer has no write lock; integrity rests on undocumented invariant |
|
||||
| IPC-13 | Low | P2 | S | IPC-05 | Not started | Gateway writer serializes twice and issues two stream writes |
|
||||
| IPC-14 | Low | P2 | S | IPC-05 | Not started | Gateway reader allocates fresh array per frame; worker rents from `ArrayPool` |
|
||||
| IPC-15 | Low | P2 | M | — | Not started | Worker event delivery 25 ms poll with per-event write+flush, no batching |
|
||||
| IPC-13 | Low | P2 | S | IPC-05 | Done | Gateway writer serializes twice and issues two stream writes |
|
||||
| IPC-14 | Low | P2 | S | IPC-05 | Done | Gateway reader allocates fresh array per frame; worker rents from `ArrayPool` |
|
||||
| IPC-15 | Low | P2 | M | — | Done | Worker event delivery 25 ms poll with per-event write+flush, no batching |
|
||||
| IPC-16 | Low | — | S | — | N/A | Correlation id carried twice per reply (Info) |
|
||||
| IPC-17 | Low | P2 | S | — | Not started | Two docs name the wrong Python generated-output directory |
|
||||
| IPC-17 | Low | P2 | S | — | Done | Two docs name the wrong Python generated-output directory |
|
||||
| IPC-18 | Low | — | S | IPC-01 | N/A | Generated-code hygiene verified good — preserve under change (positive) |
|
||||
| IPC-19 | Low | P1 | S | IPC-01 | Not started | Generated/-must-be-committed rule for net48 undocumented and unguarded |
|
||||
| IPC-20 | Low | P1 | S | IPC-01 | Not started | Descriptor `-Check` byte-compares source-info bytes; protoc-version-sensitive |
|
||||
| IPC-21 | Low | P2 | S | IPC-06 | Not started | `gateway.md` presents unimplemented `Session` RPC as live |
|
||||
| IPC-19 | Low | P1 | S | IPC-01 | Done | Generated/-must-be-committed rule for net48 undocumented and unguarded |
|
||||
| IPC-20 | Low | P1 | S | IPC-01 | Done | Descriptor `-Check` byte-compares source-info bytes; protoc-version-sensitive |
|
||||
| IPC-21 | Low | P2 | S | IPC-06 | Done | `gateway.md` presents unimplemented `Session` RPC as live |
|
||||
| IPC-22 | Low | — | S | — | N/A | Public error-detail model stops at status codes plus prose (Info) |
|
||||
|
||||
### Security & dashboard — [40-security-dashboard.md](40-security-dashboard.md)
|
||||
|
||||
| ID | Sev | Tier | Eff | Dep | Status | Title |
|
||||
|---|---|:-:|:-:|---|---|---|
|
||||
| SEC-01 | Medium | P1 | M | — | Not started | Windows-absolute default paths become relative files off-Windows |
|
||||
| SEC-02 | Medium | P1 | S | — | Not started | `AllowAnonymousLocalhost` satisfies AdminOnly, not just Viewer |
|
||||
| SEC-03 | Medium | P2 | S | — | Not started | Dashboard cookie lost its `__Host-` prefix; four docs still promise it |
|
||||
| SEC-04 | Medium | P1 | S | SEC-03 | Not started | `DisableLogin` has no production guard |
|
||||
| SEC-05 | Medium | P1 | M | — | Not started | Hub bearer tokens irrevocable for 30 min, carried in query string |
|
||||
| SEC-06 | Medium | P1 | M | — | Not started | LDAP plaintext-by-default with a committed service password |
|
||||
| SEC-07 | Medium | P1 | S | — | Not started | `QueryActiveAlarmsRequest` missing from scope resolver; tests mislabeled |
|
||||
| SEC-08 | Medium | P1 | L | — | Not started | Per-RPC SQLite read + `last_used_utc` write; no verification cache |
|
||||
| SEC-09 | Medium | P2 | S | — | Not started | Dashboard design-doc `GroupToRole` sample now fails startup validation |
|
||||
| SEC-10 | Medium | P1 | L | — | Not started | No API-key expiry |
|
||||
| SEC-11 | Medium | P1 | M | SEC-08 | Not started | No rate limiting or lockout on either auth surface |
|
||||
| SEC-12 | Medium | P1 | M | — | Not started | Dashboard Close/Kill bypass the canonical audit store |
|
||||
| SEC-01 | Medium | P1 | M | — | Done | Windows-absolute default paths become relative files off-Windows |
|
||||
| SEC-02 | Medium | P1 | S | — | Done | `AllowAnonymousLocalhost` satisfies AdminOnly, not just Viewer |
|
||||
| SEC-03 | Medium | P2 | S | — | Done | Dashboard cookie lost its `__Host-` prefix; four docs still promise it |
|
||||
| SEC-04 | Medium | P1 | S | SEC-03 | Done | `DisableLogin` has no production guard |
|
||||
| SEC-05 | Medium | P1 | M | — | Done | Hub bearer tokens irrevocable for 30 min, carried in query string |
|
||||
| SEC-06 | Medium | P1 | M | — | Done | LDAP plaintext-by-default with a committed service password |
|
||||
| SEC-07 | Medium | P1 | S | — | Done | `QueryActiveAlarmsRequest` missing from scope resolver; tests mislabeled |
|
||||
| SEC-08 | Medium | P1 | L | — | Done | Per-RPC SQLite read + `last_used_utc` write; no verification cache |
|
||||
| SEC-09 | Medium | P2 | S | — | Done | Dashboard design-doc `GroupToRole` sample now fails startup validation |
|
||||
| SEC-10 | Medium | P1 | L | — | Done | No API-key expiry |
|
||||
| SEC-11 | Medium | P1 | M | SEC-08 | Done | No rate limiting or lockout on either auth surface |
|
||||
| SEC-12 | Medium | P1 | M | — | Done | Dashboard Close/Kill bypass the canonical audit store |
|
||||
| SEC-13 | Low | — | S | SEC-30 | Not started | Redactor's credential-command list omits secured-bulk variants |
|
||||
| SEC-14 | Low | — | S | SEC-02,20 | Not started | `/metrics` + `/health` unauthenticated; a metric leaks session ids |
|
||||
| SEC-15 | Low | — | S | — | Not started | GET `/logout` skips antiforgery |
|
||||
@@ -158,26 +158,26 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| SEC-17 | Low | — | S | — | Not started | Interceptor does not override client-streaming/duplex handlers |
|
||||
| SEC-18 | Low | — | S | — | Not started | Pepper-unavailable detection matches library message text |
|
||||
| SEC-19 | Low | — | S | — | Not started | Canonical audit store re-issues `CREATE TABLE` per write/read |
|
||||
| SEC-20 | Low | P1 | S | — | Not started | `session_id` metric tag is unbounded cardinality |
|
||||
| SEC-20 | Low | P1 | S | — | Done | `session_id` metric tag is unbounded cardinality |
|
||||
| SEC-21 | Low | — | M | — | Not started | Snapshot publisher works every second regardless of audience |
|
||||
| SEC-22 | Low | P2 | M | — | Not started | `docs/Authentication.md` documents types no longer in this repo |
|
||||
| SEC-22 | Low | P2 | M | — | Done | `docs/Authentication.md` documents types no longer in this repo |
|
||||
| SEC-23 | Low | — | S | SEC-03,04 | Not started | Validator ignores `DisableLogin`/`AutoLoginUser`/`RequireHttpsCookie`/`CookieName` |
|
||||
| SEC-24 | Low | — | S | SEC-04 | Not started | Effective-config view omits the riskiest dashboard flags |
|
||||
| SEC-25 | Low | P2 | M | SEC-02 | Not started | Per-session EventsHub ACL is an acknowledged TODO |
|
||||
| SEC-25 | Low | P2 | M | SEC-02 | Done | Per-session EventsHub ACL is an acknowledged TODO |
|
||||
| SEC-26 | Low | — | M | — | Not started | Audit trail has no retention or pruning |
|
||||
| SEC-27 | Low | — | S | — | Not started | Dashboard `GatewayStatus` is hardcoded Healthy |
|
||||
| SEC-28 | Info | — | S | — | N/A | Positive security observations (preserve under change) |
|
||||
| SEC-29 | Info | — | S | — | N/A | UI-stack rule verified compliant |
|
||||
| SEC-30 | Info | P2 | S | SEC-13 | Not started | Value-logging feature is unwired end-to-end |
|
||||
| SEC-30 | Info | P2 | S | SEC-13 | Done | Value-logging feature is unwired end-to-end |
|
||||
|
||||
### Clients — [50-clients.md](50-clients.md)
|
||||
|
||||
| ID | Sev | Tier | Eff | Dep | Status | Title |
|
||||
|---|---|:-:|:-:|---|---|---|
|
||||
| CLI-01 | High | P0 | M | — | Done | Go `Session.Events()` silently closes stream on 16-slot overflow |
|
||||
| CLI-02 | High | P1 | M | — | Not started | Rust crate unbuildable outside the repo (`build.rs` path + `--no-verify`) |
|
||||
| CLI-02 | High | P1 | M | — | Done | Rust crate unbuildable outside the repo (`build.rs` path + `--no-verify`) |
|
||||
| CLI-03 | High | P0 | M | — | Done | Rust `invoke` never validates HRESULT / MXSTATUS_PROXY |
|
||||
| CLI-04 | High | P2 | L | CLI-15 | Not started | Typed-command parity gap across all five clients |
|
||||
| CLI-04 | High | P2 | L | CLI-15 | Done | Typed-command parity gap across all five clients |
|
||||
| CLI-05 | Medium | — | S | — | Not started | .NET session cannot be re-attached to an existing session id |
|
||||
| CLI-06 | Medium | — | S | — | Not started | .NET `DisposeAsync` blocks/throws on unreachable gateway |
|
||||
| CLI-07 | Medium | — | S | — | Not started | .NET retry budget self-defeats on `DeadlineExceeded` |
|
||||
@@ -185,25 +185,25 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| CLI-09 | Medium | — | M | — | Not started | Go has no typed auth-error mapping (Unauthenticated vs PermissionDenied) |
|
||||
| CLI-10 | Medium | — | M | — | Not started | Go uses deprecated `grpc.DialContext` + `grpc.WithBlock()` |
|
||||
| CLI-11 | Medium | — | S | — | Not started | Go CLI cannot opt into strict TLS validation |
|
||||
| CLI-12 | Medium | P2 | S | — | Not started | Java docs still say "Java 21" after the JDK 17 retarget |
|
||||
| CLI-12 | Medium | P2 | S | — | Done | Java docs still say "Java 21" after the JDK 17 retarget |
|
||||
| CLI-13 | Medium | — | M | — | Not started | Java event-stream buffer hardcoded 16, cancel-on-overflow |
|
||||
| CLI-14 | Medium | — | S | — | Not started | Python default TLS is blocking TOFU pin with silent `localhost` SNI |
|
||||
| CLI-15 | Medium | P2 | M | — | Not started | No client handles `ReplayGap` or offers a reconnect helper |
|
||||
| CLI-16 | Medium | P2 | S | CLI-12 | Not started | `docs/ClientPackaging.md` drifted from Python naming and .NET `.slnx` |
|
||||
| CLI-15 | Medium | P2 | M | — | Done | No client handles `ReplayGap` or offers a reconnect helper |
|
||||
| CLI-16 | Medium | P2 | S | CLI-12 | Done | `docs/ClientPackaging.md` drifted from Python naming and .NET `.slnx` |
|
||||
| CLI-17 | Medium | — | M | CLI-14 | Not started | TLS default posture inconsistent across the five clients |
|
||||
| CLI-18 | Low | P2 | S | — | Not started | .NET csproj records no `<Version>` |
|
||||
| CLI-18 | Low | P2 | S | — | Done | .NET csproj records no `<Version>` |
|
||||
| CLI-19 | Low | — | S | — | Not started | .NET duplicate `InternalsVisibleTo` (AssemblyInfo + csproj) |
|
||||
| CLI-20 | Low | — | S | CLI-17 | Not started | .NET `--tls` without CA installs accept-all callback |
|
||||
| CLI-21 | Low | P2 | S | — | Not started | Go `ClientVersion = "0.1.0-dev"` stale vs tagged releases |
|
||||
| CLI-21 | Low | P2 | S | — | Done | Go `ClientVersion = "0.1.0-dev"` stale vs tagged releases |
|
||||
| CLI-22 | Low | — | S | — | Not started | Go `newCorrelationID` swallows `crypto/rand` error → empty id |
|
||||
| CLI-23 | Low | — | S | — | Not started | Go nil-vs-empty bulk short-circuit asymmetry |
|
||||
| CLI-24 | Low | — | S | — | Not started | Java `MxEventStream` single-consumer constraint undocumented |
|
||||
| CLI-25 | Low | — | S | — | Not started | Java `close()` does not await channel termination |
|
||||
| CLI-26 | Low | P2 | S | — | Not started | Python `version.py` (0.1.0) ≠ `pyproject.toml` (0.1.2) |
|
||||
| CLI-26 | Low | P2 | S | — | Done | Python `version.py` (0.1.0) ≠ `pyproject.toml` (0.1.2) |
|
||||
| CLI-27 | Low | — | S | — | Not started | Python `Session.close()` not concurrency-safe; synthesizes reply |
|
||||
| CLI-28 | Low | — | S | — | Not started | Python circular-import workaround at end of `session.py` |
|
||||
| CLI-29 | Low | P2 | S | — | Not started | Rust `CLIENT_VERSION` (0.1.0-dev) ≠ `Cargo.toml` (0.1.2) |
|
||||
| CLI-30 | Low | — | S | CLI-04 | Not started | Rust has no `unregister` typed helper |
|
||||
| CLI-29 | Low | P2 | S | — | Done | Rust `CLIENT_VERSION` (0.1.0-dev) ≠ `Cargo.toml` (0.1.2) |
|
||||
| CLI-30 | Low | — | S | CLI-04 | Done | Rust has no `unregister` typed helper |
|
||||
| CLI-31 | Low | — | M | — | Not started | Rust CLI is a single 2,699-line `main.rs` |
|
||||
| CLI-32 | Low | — | S | — | Not started | Client-side bulk caps differ (.NET/Java unbounded) |
|
||||
| CLI-33 | Low | — | S | CLI-01,13 | Not started | Per-language event backpressure semantics undocumented |
|
||||
@@ -213,19 +213,19 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
|
||||
| ID | Sev | Tier | Eff | Dep | Status | Title |
|
||||
|---|---|:-:|:-:|---|---|---|
|
||||
| TST-01 | High | P2 | L | TST-04 | Not started | Reconnect/replay has no e2e test and no client consumer |
|
||||
| TST-01 | High | P2 | L | TST-04 | Done | Reconnect/replay has no e2e test and no client consumer |
|
||||
| TST-02 | High | P0 | M | TST-04 | Done | Reconnect owner re-validation not implemented |
|
||||
| TST-03 | High | P1 | M | — | Not started | No CI exists |
|
||||
| TST-04 | High | P2 | L | — | Not started | Session-resilience epic 16/28 tasks unfinished |
|
||||
| TST-03 | High | P1 | M | — | In review | No CI exists |
|
||||
| TST-04 | High | P2 | L | — | Done | Session-resilience epic 16/28 tasks unfinished |
|
||||
| TST-05 | Medium | P1 | S | TST-03 | Not started | Real-worker control/COM paths verified opt-in only |
|
||||
| TST-06 | Medium | — | M | — | Not started | Dashboard live-data path untested |
|
||||
| TST-07 | Medium | — | S | — | Not started | Real-clock sleeps with negative assertions are latent flakes |
|
||||
| TST-08 | Medium | P1 | M | — | Not started | Full-suite orphaned testhost processes |
|
||||
| TST-08 | Medium | P1 | M | — | Done | Full-suite orphaned testhost processes (does not reproduce; doc de-stale) |
|
||||
| TST-09 | Medium | — | M | — | Not started | Health checks cover only the auth store |
|
||||
| TST-10 | Medium | — | M | — | Not started | Deployment/upgrade undocumented and hand-rolled |
|
||||
| TST-11 | Medium | P2 | M | — | Not started | No version discipline on server; client versions drift |
|
||||
| TST-11 | Medium | P2 | M | — | Done | No version discipline on server; client versions drift |
|
||||
| TST-12 | Medium | P0 | S | — | Done | CLAUDE.md misstates default retention behaviour |
|
||||
| TST-13 | Medium | P2 | S | — | Not started | gateway.md carries stale design-era sketches |
|
||||
| TST-13 | Medium | P2 | S | — | Done | gateway.md carries stale design-era sketches |
|
||||
| TST-14 | Medium | P2 | S | — | Not started | Repo-root working artifacts need triage |
|
||||
| TST-15 | Medium | P2 | M | TST-04 | Not started | Dashboard EventsHub has no per-session ACL |
|
||||
| TST-16 | Medium | — | S | — | Not started | `Dashboard:ShowTagValues` is a dead flag |
|
||||
@@ -235,7 +235,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| TST-20 | Low | — | S | — | Not started | E2E script papers over a real advise-without-consumer sharp edge |
|
||||
| TST-21 | Low | — | S | — | Not started | Log rotation configured but minimal |
|
||||
| TST-22 | Low | — | S | — | Not started | Config-shape JSON block omits documented keys |
|
||||
| TST-23 | Low | P2 | S | — | Not started | Bidirectional `Session` RPC never built |
|
||||
| TST-23 | Low | P2 | S | — | Done | Bidirectional `Session` RPC never built |
|
||||
| TST-24 | Low | P2 | M | TST-03 | Not started | Client wire behaviour has no automated verification |
|
||||
|
||||
## Cross-cutting clusters
|
||||
@@ -253,6 +253,28 @@ Findings the review flagged as one coordinated design pass — sequence them tog
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-07-09 | **P2 Epic wrap — user decision: DEFER TST-15 + TST-24, close the epic.** Epic bucket result: 5 of 7 findings `Done` (CLI-15, CLI-04, CLI-30, TST-01, TST-04); **TST-15** and **TST-24** intentionally deferred to a follow-up (kept `Not started`, not `Won't fix` — they are gated, not rejected). **TST-15** (dashboard EventsHub per-session ACL) is epic Phase 4 — a real feature needing a new session-"tag" mechanism + dashboard group→tag config, not a mechanical fix; the `EventsHub` `TODO(per-session-acl)` stays, and the already-shipped **SEC-25** mitigation (tag *values* redacted from the dashboard mirror by default) means no sensitive payload leaks through the hub today regardless of the missing ACL — so deferring carries no value-leak risk. **TST-24** (per-client wire tests) depends on **TST-03** (CI), which is `In review` (YAML authored, never run on a Gitea runner) — no point wiring client tests into a pipeline that isn't live yet. Net P2: 35/38 `Done`; remaining = TST-15 (deferred feature), TST-24 (deferred, CI-gated), TST-14 (user deletes their own untracked gitignored `*-docs-*.md` files). |
|
||||
| 2026-07-09 | P2 Epic — **Java client completes CLI-15 + CLI-04 locally** (commit `1cc0fa4`); **CLI-15, CLI-04, CLI-30, TST-01 all → `Done` (5/5 clients + server e2e)**. Java CLI-15: `MxEventStreamItem` record + `MxEventStream.nextItem()` (`isReplayGap()`/`replayGap()`/`event()`); existing `Iterator<MxEvent>` path unchanged, sentinel never swallowed. Java CLI-04: Phase 1 `adviseSupervisory`/`writeSecured`/`writeSecured2`/`authenticateUser`/`archestrAUserToId` + Phase 2 `addBufferedItem`/`setBufferedUpdateInterval`/`suspend`/`activate` (unregister already present) on `MxGatewaySession`, each through `invokeCommand` → `ensureProtocolSuccess`+`ensureMxAccessSuccess`; credentials scrubbed via `MxGatewaySecrets.redactCredentials` (tests assert absent from message/toString/CLI). `gradle test` 106/0 (58 client + 48 cli), no generated churn. Built locally with `JAVA_HOME=/opt/homebrew/opt/openjdk@17` — Java toolchain now works on the Mac (see prior note). Shared docs `ClientLibrariesDesign.md` + CLAUDE.md updated to "all five clients". **TST-01 → Done** (server e2e `fed0685` + all 5 client `ReplayGap` consumers). This closes session-resilience epic Phase 3 fully. |
|
||||
| 2026-07-09 | P2 Epic Wave E2 — typed-command parity (commit `bde042b`). CLI-04 → `In progress` (4/5 clients; Java pending in the SAME session — see next note), CLI-30 → `Done`. Every parity-critical single-item command now has a typed session helper across .NET/Go/Rust/Python: Phase 1 `AdviseSupervisory`/`WriteSecured`/`WriteSecured2`/`AuthenticateUser`/`ArchestrAUserToId`, Phase 2 `AddBufferedItem`/`SetBufferedUpdateInterval`/`Suspend`/`Activate`, plus `Unregister` (CLI-30: Rust + .NET added; Go/Java/Python already had it — CLI-30 fully Done). Each wraps existing raw-command machinery (no new wire surface) and runs the client's MXAccess-level reply validation (`hresult < 0` + `MxStatusProxy`). MXAccess parity preserved (WriteSecured-before-authenticate surfaces the native failure). **Credentials route through each client's secret-redaction seam** (never in logs/exceptions/ToString/Debug/Display; each suite asserts a distinctive credential is absent from surfaced errors); new CLI subcommands take credentials via flag/env, never echoed. Verified per toolchain: .NET build clean 102 passed; Go gofmt/vet/build/test clean; Rust fmt/check/test/clippy clean; Python 145 passed. Shared doc: `ClientLibrariesDesign.md` "Typed Command Parity" section. |
|
||||
| 2026-07-09 | **Java client toolchain now works locally on the Mac** (user-flagged + verified): homebrew `openjdk@17` (17.0.19), @21, @26 are installed (off PATH); `JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test` → BUILD SUCCESSFUL. Retires the "Java client must be built on windev" constraint — the remaining Java halves of CLI-15 + CLI-04 are done locally this session (no windev batch). Memory `project_java_build_host` rewritten. |
|
||||
| 2026-07-09 | P2 Epic — TST-04 (session-resilience epic governance) → `Done` (commit `ed3c6c6`, docs-only umbrella). Resolved the epic's shipped-vs-planned entanglement into three decisions: **Phase 3 finished** (Task 13=TST-02 owner-scoped attach P0, Task 15=TST-01 reconnect e2e, Task 14=CLI-15 4/5 clients w/ Java pending); **Phase 4 scoped** as TST-15 with the Viewer-default decision settled (admin-sees-all, Viewer strict per owned/granted session, matching TST-02's gRPC owner binding); **Phase 5 (orphan-worker reattach) DEFERRED, not planned** — the CLAUDE.md "gateway restart does not reattach orphan workers" invariant stands and the `EnableOrphanReattach` flag does not exist / must not be referenced. Updated `oldtasks.md`, the `tasks.json` mirror (per-task statuses + governance note), and the CLAUDE.md reconnect paragraph (clients consume ReplayGap; reattach deferred). The actionable slices carry their own verification: TST-01 (done), TST-02 (done, P0), TST-15 (pending, E3). |
|
||||
| 2026-07-09 | P2 Epic Wave E1 — reconnect-replay: CLI-15 + TST-01 → `In progress` (both gated on the Java client, deferred to the windev batch). **CLI-15 (commit `0c6e5b3`)**: four clients now surface the gateway's `ReplayGap` reconnect sentinel as a distinct, typed, non-terminal signal (never synthesized, never swallowed) — .NET `MxEventStreamItem`/`StreamEventItemsAsync` (build clean, 87 passed), Go `EventResult.ReplayGap`+`IsReplayGap()` (build/vet/test clean), Rust `EventItem::ReplayGap` enum (`EventStream` now yields `Result<EventItem, Error>`; fmt/check/test/clippy clean), Python `ReplayGap` dataclass yielded from `stream_events` (131 passed). Shared docs: `ClientLibrariesDesign.md` non-goals reframed (reconnect-replay protocol is consumable; auto-reconnect stays a non-goal) + `CrossLanguageSmokeMatrix.md` resume-gap note. Resume contract uniform: `after_worker_sequence = oldest_available_sequence - 1`. **TST-01 server half (commit `fed0685`)**: `GatewayEndToEndReconnectReplayTests` (2 facts) proves the default-on reconnect protocol end-to-end over the real gRPC StreamEvents path via the fake worker — replay-tail-no-gap (capacity 16, mid-batch cursor) and stale-cursor-emits-ReplayGap-first (capacity 3, 6 events force eviction, cursor=1); single-subscriber detach→reconnect, ring survives detach. macOS-verified 2/2 (needs `TMPDIR=/tmp` — default macOS TMPDIR pushes the CoreFxPipe Unix-socket path past the 104-byte `sun_path` limit; Windows CI unaffected). No product bugs; server behavior matched the contract exactly. Remaining to close both: Java `ReplayGap` surface (windev) + Java client consumer for TST-01. |
|
||||
| 2026-07-09 | P2 Wave B — worker event hot-path (commit `f61c816`), **windev-verified**. WRK-06 → `Done`: `MxStatusProxyConverter` caches the four resolved `FieldInfo` per status type in a static `ConcurrentDictionary` (the `GetField` metadata scan ran 4× per status per event on the STA path); `GetValue`+`Convert.ToInt32` still run per event (late-bound RCW); both exception messages byte-identical (missing-field via `ResolveField`, not cached on throw through `GetOrAdd`; null-value unchanged). WRK-11 → `Done`: `MxAccessEventQueue.Enqueue` takes ownership of the passed `MxEvent` (stamps `WorkerSequence`/`WorkerTimestamp` in place, no `Clone()`); audited all 3 callers (base/alarm event sinks + provider-mode handler) — each builds a fresh event, none reuse it; `MxAccessValueCache.Set` now deep-copies its retained `Value`/`SourceTimestamp`/`Statuses` so the cache snapshot never aliases the queue-owned (later serialized) event. WRK-12 → `Done`: `WorkerFrameWriter` coalesces the flush across a drained batch (write each frame, one `FlushAsync` after the batch, then complete all) — preserves the written+flushed completion contract; a burst of N events costs 1 flush not N; on failure the in-flight batch + queue all fail so no caller hangs. IPC-15 → `Done`: the multi-event `WorkerEnvelope` body stays unimplemented (wire still one event per `worker_event` frame); gateway.md Performance section now distinguishes the shipped flush-coalescing from that deferred additive-proto change. **Windev:** x86 Worker builds clean (fixed a Windows-only `TreatWarningsAsErrors` CS8604 in the value-cache null-guard that macOS can't surface); Worker.Tests **356 passed / 0 failed / 11 skipped** (+4 new: converter cache-reuse, queue ownership-transfer, value-cache snapshot independence, writer batch-flush-once); gateway Tests **815 passed / 1 failed** — the 1 is the known windev-environmental SelfSigned-SAN assertion (passes on macOS), and all pipe-harness tests (which also exercise the earlier G-frame gateway frame change + WRK-12 end-to-end over a real pipe) pass. |
|
||||
| 2026-07-09 | P2 Wave B — quick Mac items (commit `ec6f82b`). TST-11 → `Done`: `src/Directory.Build.props` single-sources the .NET-side version (Server/Worker/Contracts/tests stamped 0.1.2, was SDK-default 1.0.0) and appends the git short SHA to InformationalVersion (`0.1.2+<sha>`, guarded so a git-less build still works); verified Server assembly stamps `0.1.2+579282f`, Server+Tests build clean. Kept 0.1.2 (matches Contracts + aligned Python/Rust/Go); converging all to a single 0.2.0 cadence left as a release decision. WRK-15 → `Done` (docs-only path, no x86 build): STA thread-name refs corrected to the actual `MxGateway.Worker.STA` in `docs/WorkerSta.md` + `docs/MxAccessWorkerInstanceDesign.md`, and the stale heartbeat-counter note fixed (CaptureHeartbeat populates queue depth + sequence from the live queue). TST-23 → `Done`: already resolved by the Wave A gateway.md edit — the bidi `Session` RPC now sits under a "Future work: not implemented" heading (no "best long-term shape" framing), consistent with the proto. **TST-14 left open by design**: its only concrete step is deleting the user's untracked, gitignored `*-docs-*.md` working files (zero repo impact) — surfaced to the user rather than deleting files not created here. |
|
||||
| 2026-07-09 | P2 Wave B — dashboard/security (commit `e15c3cb`). SEC-25 → `Done` (near-term hardening only; full per-session EventsHub ACL stays deferred to roadmap item 12 / TST-15): `DashboardEventBroadcaster` redacts tag values from a deep clone of the event before mirroring to SignalR when `Dashboard:ShowTagValues` is false (default), so the hub seam cannot leak values regardless of the missing ACL. Clears `MxEvent.value` (the OnDataChange/OnWriteComplete/OperationComplete/OnBufferedDataChange bodies are empty discriminators — values ride in the top-level field, incl. buffered arrays) + the alarm body `current_value`/`limit_value`; source event never mutated (shared with gRPC path + replay ring; verified). Makes the formerly-dead `ShowTagValues` flag live for the mirror. `EventsHub` TODO(per-session-acl) kept + tied to roadmap item 12. Test `DashboardEventBroadcasterTests` (3). SEC-30 → `Done`: `docs/Diagnostics.md` trimmed to mark opt-in command-value logging NOT YET IMPLEMENTED (no `LogCommandValues` knob, `RedactCommandValue` unwired, no values logged); wiring deferred pending secured-bulk redaction (SEC-13), not wired now. Server build clean; Dashboard tests 152/152. |
|
||||
| 2026-07-09 | P2 Wave B — gateway event/frame hot-path performance (8 findings). **Frame I/O (commit `baae59c`):** GWC-08/IPC-13 gateway `WorkerFrameWriter` serializes once into one ArrayPool-rented buffer (LE length prefix + payload) and issues a single write (was: second serialization via `ToByteArray`, separate prefix array, two writes); IPC-14 `WorkerFrameReader` rents the payload buffer from ArrayPool instead of `new byte[]` per frame, read/parse length-bounded, returned in finally. Wire format byte-identical, cross-checked against the worker writer/reader. **Event/command hot path (commit `5e2e40a`):** GWC-06 `StreamEvents` timing via `Stopwatch.GetTimestamp()`/`GetElapsedTime()` (no per-event Stopwatch alloc); GWC-07/IPC-05 `MapEvent` transfers ownership of the inner `MxEvent` instead of cloning (safe — single-consumer `WorkerEvent`, MapEvent runs once pre-fan-out, all downstream consumers read-only; verified no post-mapping mutation) and `CreateCommandEnvelope` drops its redundant second clone (`MapCommand` already isolated the graph); every load-bearing isolation clone kept; GWC-15 `grpc_stream_queue.depth` converted from a per-event push counter to an `ObservableGauge` summing registered channel sources only at scrape time (name/semantics unchanged). **Replay ring (commit `cf1b3d4`):** GWC-14 replaces the `LinkedList` (node alloc per retained event) with a preallocated circular `ReplayEntry[]` (head+count), preserving ascending order, capacity eviction, time trim, oldest-read/ReplayGap math, both query paths, and the lock. → all `Done`. Server build clean (0 warnings); per-cluster tests green (WorkerFrameProtocol 10/10 incl. new near-cap round-trip, EventStream/Metrics/Distributor/Mapper 62/62, Distributor/Replay 33/33 incl. 2 new wrap-around cases). Full-suite macOS checkpoint: 769 passed / 44 failed, all 44 the known named-pipe/fake-worker-harness UDS-104-char limit (0 failures in any touched area). **Pending: windev run** to exercise the frame I/O over a real pipe (the 44 pipe-harness tests are macOS-blind). |
|
||||
| 2026-07-09 | P2 Wave B — SEC-03 (dashboard `__Host-` cookie prefix) → `Done` (commit `7d42c85`). Conditionally restore the prefix instead of a pure doc fix: `DashboardServiceCollectionExtensions` PostConfigure resolves the cookie name as explicit `MxGateway:Dashboard:CookieName` override → else `__Host-MxGatewayDashboard` when `SecurePolicy==Always` (`RequireHttpsCookie` true) → else the plain `MxGatewayDashboard` default; guard never applies `__Host-` without Secure (browsers drop it). `DashboardAuthenticationDefaults.SecureCookieName` added; plain name kept as the non-secure fallback. Five stale docs corrected to the conditional contract (gateway.md, GatewayProcessDesign, ImplementationPlanGateway, GatewayDashboardDesign, CLAUDE.md; GatewayConfiguration phrasing tightened). Test `DashboardCookieOptionsTests` asserts the name flips with `RequireHttpsCookie` and that an override wins. Server build clean; Dashboard tests 149/149. |
|
||||
| 2026-07-09 | P2 Wave A (doc-drift sweep + client version alignment) via parallel agents, on `fix/archreview-p2` off `main`. **Version alignment (commit `4fe1917`):** CLI-18 (.NET `<Version>0.1.2</Version>`), CLI-21 (Go `ClientVersion` 0.1.0-dev→0.1.2), CLI-26 (Python `__version__`→0.1.2), CLI-29 (Rust `CLIENT_VERSION`→`env!(CARGO_PKG_VERSION)`) → `Done`; verified dotnet build + `go build`/`test` + `cargo check`/`test`/`clippy` + pytest 127 pass. **Doc-drift (commit `06e1046`):** IPC-06/07/21 + TST-13 (gateway.md envelope sketch → proto-as-truth, six→seven RPCs incl. `QueryActiveAlarms` in `docs/Grpc.md`, `Session` RPC marked not-implemented, stale sketches removed), SEC-09 (dashboard `GroupToRole` sample `Administrator` so it passes the validator), SEC-22 (`docs/Authentication.md` rewritten to the shipped `ZB.MOM.WW.Auth.ApiKeys`-package pipeline; 18 stale type names removed, grep-verified absent; named gateway types re-verified present), IPC-17 (wrong Python gen dir `mxgateway`→`zb_mom_ww_mxgateway` in CLAUDE.md + 3 docs), CLI-12 (Java 21→17), CLI-16 (`docs/ClientPackaging.md` reconciled with `.slnx`/Python package name/gradle project names) → `Done`. Docs-only claims verified against source. 13 findings closed. |
|
||||
| 2026-07-09 | Initial tracking doc generated from the six domain remediation designs. All 153 findings `Not started`. |
|
||||
| 2026-07-09 | P0 tier executed via parallel agents. GWC-01/02/03, TST-02, TST-12, CLI-01, CLI-03 → `Done` (NonWindows build clean; gateway 135/135 targeted tests pass, Go `go test ./...` + `-race` pass, Rust `test`/`clippy -D warnings` pass). |
|
||||
| 2026-07-09 | P0 tier executed via parallel agents. GWC-01/02/03, TST-02, TST-12, CLI-01, CLI-03 → `Done` (NonWindows build clean; gateway 135/135 targeted tests pass, Go `go test ./...` + `-race` pass, Rust `test`/`clippy -D warnings` pass). |
|
||||
| 2026-07-09 | Windows full-suite temp-file-lock flakiness (discovered via TST-08) → **fixed** (commit `11a716a`). Two shared-state parallel collisions that macOS never surfaces: (1) self-signed cert generation — `SelfSignedCertificateProvider` now stages the PFX in a unique `<path>.<guid>.tmp` (was a fixed name that concurrent/interrupted writers collided on); `TestHostEnvironmentInitializer` defaults the cert path to a per-process temp dir (so the suite never writes shared `ProgramData` or fights the deployed service); `GatewayTlsBootstrapTests` moved to a `DisableParallelization` collection so its process-global env-var mutation can't bleed into parallel tests. (2) `AuthStoreHealthCheckTests` — reuse the existing `TempDatabaseDirectory` helper (`SqliteConnection.ClearAllPools()` before delete) so the Microsoft.Data.Sqlite pool releases the temp `.db` handle. **windev-verified:** 3 consecutive full-suite runs all **793 passed / 2 failed, 0 new orphans** (was flaky 2–4 fails, count-varying). The 2 stable remaining failures are pre-existing windev-environmental (`SelfSignedCertificateProviderTests.GenerateCertificate_HasExpectedSansEkuAndValidity` machine-name/FQDN SAN assertion; `EventStreamServiceTests…TracksAggregateQueueDepth` timing) — both pass on macOS, unrelated to this change. |
|
||||
| 2026-07-09 | P1 TST-08 (orphaned testhost) → `Done` by evidence: the claimed leak does **not** reproduce. Full `ZB.MOM.WW.MxGateway.Tests` suite exits cleanly on macOS (0 survivors) and on the Windows dev box (windev, `origin/main` worktree: 0 new `testhost` and 0 new `ZB.MOM.WW.MxGateway.Worker` processes after a full run, isolated by StartTime). Static audit confirms the prime-suspect fixtures already dispose deterministically (WorkerClient cancels its `_stopCts` + awaits read/write/heartbeat tasks with a 5 s timeout; GatewaySession disposes the client; E2E/harness fixtures use `await using`). CLAUDE.md "Source Update Workflow" updated to drop the stale "leaves orphaned testhost processes" rationale (filtered runs kept as a speed guideline). **Discovered separately (NOT TST-08, still open):** Windows-only temp-file-lock flakiness in ~4 full-host test classes — `AuthStoreHealthCheckTests` (Microsoft.Data.Sqlite connection-pool holds the temp `.db` handle after `await using`, so the finally `File.Delete` throws "used by another process"), `GatewayTlsBootstrapTests`/`DashboardCookieOptionsTests`/`DashboardHubsRegistrationTests` (a `gw.pfx.tmp` handle from `SelfSignedCertificateProvider`'s fixed-name `path+".tmp"` atomic-write racing teardown). Count varies run-to-run (2–4 fails); macOS never sees it (Unix allows deleting open files). Fix = `SqliteConnection.ClearAllPools()` before temp delete + a unique/guarded `.tmp` name; verify on windev. |
|
||||
| 2026-07-09 | SEC-10 polish (gateway-side follow-up to the G-2 auth-lib core) → done. `apikey create-key` gains an optional `--expires` (absolute ISO-8601 UTC or relative `<N>d`/`<N>h`; omit = non-expiring, opt-in) threaded through `ApiKeyAdminCommand`/parser/runner into the library's `CreateKeyAsync(..., expiresUtc, ...)`; `list-keys` shows an expiry column + `active`/`expired`/`revoked` status. Dashboard API Keys page surfaces expiry: an `Expires` column (`Never` when unset) and a status badge reading `Expired` (red) / `Expiring` (≤7 days, amber) / `Revoked` / `Active` (`DashboardApiKeySummary.ExpiresUtc` projected in `DashboardSnapshotService`; `StatusBadge` maps the new states). Server build clean; targeted tests 32/32 (parser abs/relative/invalid + end-to-end past-expiry rejection via the live verifier) + dashboard 28/28. Docs: `docs/Authentication.md` (verification flow expiry step, CLI table + examples, dashboard badge). SEC-10 was already `Done` (core); this closes the tracked polish. |
|
||||
| 2026-07-09 | P1 Wave 3 (event-channel decoupling). GWC-04 → `Done`. `WorkerClient`'s read loop awaited `EnqueueWorkerEventAsync` inline, which blocks in the bounded event channel's timed `WriteAsync` (≤ `EventChannelFullModeTimeout`, 5 s) when the channel is full with a slow/absent `StreamEvents` consumer — stalling any `WorkerCommandReply`/heartbeat queued behind an event frame, so an in-flight `InvokeAsync` could hit `CommandTimeout` despite a timely worker reply. Fix mirrors the existing outbound `WriteLoopAsync`: an unbounded event **staging** channel + a dedicated `EventWriteLoopAsync`; `DispatchEnvelope` is now fully synchronous and the `WorkerEvent` branch hands off with a non-blocking `TryWrite`, so the read loop never awaits event enqueue. The event write loop owns the timed write + the sustained-overflow `ProtocolViolation` fault (unchanged contract); registered in `WaitForBackgroundTasks`, completed on close/fault/dispose. Server build clean (0 warnings); non-pipe event tests 37/38 (the 1 is the known parallel-load `EventStreamServiceTests…TracksAggregateQueueDepth` timing flake — passes in isolation). New pipe-harness test (reply after events with a full consumer-less channel dispatches without `CommandTimeout`) verified on windev. Docs: GatewayProcessDesign read/write/event-loop section. Commit `32d6b48`. **Wave 3 complete.** |
|
||||
| 2026-07-09 | P1 Wave 3 (size/backpressure topology + write ordering). IPC-02/03/04 + WRK-04/07 → `Done`. **Size negotiation (IPC-02):** added `GatewayHello.max_frame_bytes` (regen `Generated/` + `clients/proto` descriptor refresh with pinned protoc 34.1); the gateway sends its negotiated worker-frame max and the worker adopts it (`WorkerFrameProtocolOptions.AdoptNegotiatedMaxMessageBytes`, 0 = keep default, >256 MiB rejected) instead of a hard-coded default. **Headroom (IPC-03):** the pipe frame max now sits `EnvelopeOverheadReserveBytes` (64 KiB) above the public gRPC cap (default `Worker.MaxMessageBytes` 16 MiB→16 MiB+64 KiB), cross-validated at startup; `WorkerClient` pre-checks command envelope size and fails only the offending correlation (`ResourceExhausted`) instead of `SetFaulted`ing the session. **Drain bound (IPC-04):** gateway request validator rejects `DrainEvents max_events` above 10 000; the worker caps each reply at `MaxDrainEventsPerReply` (10 000) and treats `max_events = 0` as that cap, not "drain all". **Sequence (WRK-04):** `WorkerFrameWriter` stamps the envelope `Sequence` at the point of writing under the write lock, so wire order and stamped sequence always agree under concurrent producers. **Priority (WRK-07):** the worker writer is now a cooperative priority scheduler — control frames (reply/fault/heartbeat/shutdown-ack) drain ahead of event frames; per-frame validation/size rejections fail only that frame, a stream failure fails all queued. Docs same-change (GatewayConfiguration, WorkerFrameProtocol, gateway.md). **Verified:** macOS NonWindows build clean + validator/grpc tests green; **windev** x86 worker builds clean, `Worker.Tests` 352 passed / 0 failed / 11 skipped (incl. new monotonic-sequence, control-before-event priority, negotiated-max, drain-bound tests), gateway `Tests` 799 passed / 3 failed — all 3 pre-existing windev-environmental (SelfSigned SAN + 2 `EventStreamServiceTests` timing, both pass in isolation). Commits `c8b3a22` (gateway half), `ebe6aea` (worker half), `309296f` (descriptor + default-expectation refresh). GWC-04 (event-channel decoupling) is the remaining Wave 3 item. |
|
||||
| 2026-07-09 | P1 S-misc (dashboard/observability hardening). SEC-02/12/20 → `Done`. SEC-02: `DashboardAuthorizationHandler` restricts the loopback + `Authentication:Mode=Disabled` bypasses to read-only (they satisfy a Viewer-bearing requirement but never `AdminOnly`), closing the policy-layer gap where anonymous localhost was authorized for Admin surfaces. SEC-12: `DashboardSessionAdminService` now emits canonical `AuditEvent`s (`dashboard-close-session`/`dashboard-kill-worker`, category `SessionAdmin`) through `IAuditWriter` on Success/Failure/Denied, so Close/Kill land durable audit rows. SEC-20: dropped the unbounded `session_id` tag from the exported `mxgateway.heartbeats.failed` counter. Docs updated same-change (CLAUDE.md, GatewayDashboardDesign.md, Metrics.md). Server build clean (0 warnings); targeted classes 30/30 pass; broader Dashboard+Security+GatewayApplication+Metrics sweep 295/295 pass. |
|
||||
| 2026-07-09 | P1 Wave 2b (security authz+hub). SEC-05/07/08/11 → `Done` (hub-token lifetime; QueryActiveAlarms scope arm; gateway-side verification cache + last-used coalescing; login rate limit + per-peer gRPC failure limiter). Full-suite checkpoint caught + fixed regressions the earlier narrow SEC-01/04/06 filter missed: cross-platform path-rooting, an `IHostEnvironment` fallback for minimal DI containers, a test-assembly `ASPNETCORE_ENVIRONMENT=Development` default, and a platform-correct default-path assertion. Suite: 747 passed / 42 failed, all 42 pre-existing macOS named-pipe-harness env failures. |
|
||||
| 2026-07-09 | P1 Wave 2a (security). SEC-01/04/06 → `Done` (config path-rooting + production validator guards; Server build clean, validator+hygiene tests 53/53). SEC-10 → `Done`: the shared `ZB.MOM.WW.Auth.ApiKeys` gained optional `ExpiresUtc` (expired keys rejected, auth DB auto-migrates to schema v3) via a concurrent HistorianGateway-remediation session's "G-2"; this repo consumes it by bumping the four `Auth.*` refs 0.1.2→0.1.4 (commit 197731a). Remaining SEC-10 polish (`apikey create --expires` + dashboard staleness badge) tracked as a small follow-up. |
|
||||
| 2026-07-09 | P1 Wave 1 (CI + codegen freshness + Rust buildability) via parallel agents. IPC-01/09/19/20, CLI-02 → `Done`; TST-03 → `In review` (CI pipeline authored + YAML/layout-validated, but not yet executed on a Gitea runner — proven on first push). Verified on macOS: NonWindows build clean, `ClientProtoInputTests` 5/5, `publish-client-proto-inputs.ps1 -Check` exit 0, `cargo package` (no `--no-verify`) compiles standalone. Added a vendored-Rust-proto drift guard (Check 3) to `check-codegen.ps1` closing the CLI-02 static-copy risk. IPC-09 script guards not executed end-to-end (need pinned python/JRE toolchains); logic is PATH-resolution + version assertion. |
|
||||
| 2026-07-09 | WRK-01 → `Done`. Verified on Windows host (windev) via an isolated `origin/main` worktree: worker builds x86 clean, `StaRuntimeTests`+`WorkerPipeSessionTests` 33/33 pass. Fixed an `xUnit1030` build error (the new worker test used `.ConfigureAwait(false)` in `[Fact]` bodies) that the macOS tree could not surface. Also ran GWC-01's Windows-only `WorkerClientTests` on windev: 18/18 pass (incl. `ReadEventsAsync_SecondEnumerator_Throws`). All 8 P0 findings now `Done`. Not yet committed. |
|
||||
|
||||
@@ -84,6 +84,48 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
|
||||
available, and command helpers have `*RawAsync` variants when callers need the
|
||||
complete `MxCommandReply`.
|
||||
|
||||
### Event Streaming And Replay Gaps
|
||||
|
||||
`StreamEventsAsync(afterWorkerSequence)` yields raw generated `MxEvent`
|
||||
messages. Passing a non-zero `afterWorkerSequence` resumes a session's event
|
||||
stream after a known worker sequence — this is the reconnect cursor. If that
|
||||
cursor is *stale* — older than the oldest event the gateway still retains in the
|
||||
session replay ring — the events in between were evicted and cannot be replayed.
|
||||
The gateway signals this by emitting a single **replay-gap sentinel** at the head
|
||||
of the resumed stream: an `MxEvent` with its `ReplayGap` field set, `Family`
|
||||
unspecified, and no body. It means "you missed events — discard local state and
|
||||
re-snapshot."
|
||||
|
||||
Rather than force callers to inspect the raw sentinel, the client exposes a
|
||||
typed surface, `StreamEventItemsAsync`, which yields `MxEventStreamItem` values:
|
||||
|
||||
```csharp
|
||||
await foreach (MxEventStreamItem item in session.StreamEventItemsAsync(
|
||||
afterWorkerSequence: lastSeenSequence))
|
||||
{
|
||||
if (item.IsReplayGap)
|
||||
{
|
||||
// We missed events: throw away local state and re-snapshot.
|
||||
ReplayGap gap = item.ReplayGap!;
|
||||
// Resume without incurring another gap:
|
||||
lastSeenSequence = gap.OldestAvailableSequence - 1;
|
||||
await ReSnapshotAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
HandleEvent(item.Event); // normal MXAccess event, IsReplayGap == false
|
||||
lastSeenSequence = item.Event.WorkerSequence;
|
||||
}
|
||||
```
|
||||
|
||||
The typed surface never synthesizes or drops events — it only makes the
|
||||
gateway's own sentinel observable. Normal events pass through with
|
||||
`IsReplayGap == false` and `ReplayGap == null`. The gap is only ever produced by
|
||||
`StreamEvents`; the diagnostic drain path never emits it. If you already consume
|
||||
the raw `StreamEventsAsync` (or the client-level stream), the
|
||||
`AsStreamItemsAsync()` extension projects any `IAsyncEnumerable<MxEvent>` into
|
||||
the same `MxEventStreamItem` surface.
|
||||
|
||||
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
|
||||
the active alarms the gateway's central monitor currently holds),
|
||||
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
||||
|
||||
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
|
||||
/// <summary>Utility to redact secrets (API keys, MXAccess credentials) from error messages for safe output.</summary>
|
||||
internal static class MxGatewayCliSecretRedactor
|
||||
{
|
||||
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
|
||||
/// <summary>
|
||||
/// Replaces every occurrence of any supplied secret in the value with a
|
||||
/// redacted placeholder. Null or empty secrets are ignored, so callers can
|
||||
/// pass optional credentials without pre-filtering.
|
||||
/// </summary>
|
||||
/// <param name="value">The message text to redact.</param>
|
||||
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
||||
public static string Redact(string value, string? apiKey)
|
||||
/// <param name="secrets">The secret values to remove (API key, verify-user password, secured payloads).</param>
|
||||
public static string Redact(string value, params string?[] secrets)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
||||
if (string.IsNullOrEmpty(value) || secrets is null)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.Replace(apiKey, "[redacted]", StringComparison.Ordinal);
|
||||
string redacted = value;
|
||||
foreach (string? secret in secrets)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(secret))
|
||||
{
|
||||
redacted = redacted.Replace(secret, "[redacted]", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
return redacted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,24 @@ public static class MxGatewayClientCli
|
||||
.ConfigureAwait(false),
|
||||
"advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"unregister" => await UnregisterAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"add-buffered-item" => await AddBufferedItemAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"set-buffered-update-interval" => await SetBufferedUpdateIntervalAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"suspend" => await SuspendAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"activate" => await ActivateAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"write-secured" => await WriteSecuredAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"write-secured2" => await WriteSecured2Async(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"authenticate-user" => await AuthenticateUserAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"archestra-user-to-id" => await ArchestraUserToIdAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
|
||||
@@ -157,9 +175,15 @@ public static class MxGatewayClientCli
|
||||
{
|
||||
// Client.Dotnet-028: redact the *effective* key — from --api-key or the
|
||||
// --api-key-env environment variable — so an env-var-sourced key echoed
|
||||
// in a transport error never reaches stderr unredacted.
|
||||
// in a transport error never reaches stderr unredacted. CLI-04: also
|
||||
// redact MXAccess credentials (AuthenticateUser password, WriteSecured
|
||||
// payloads) that could otherwise be echoed back in a surfaced error.
|
||||
string? apiKey = TryResolveApiKey(arguments);
|
||||
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
|
||||
string message = MxGatewayCliSecretRedactor.Redact(
|
||||
exception.Message,
|
||||
apiKey,
|
||||
TryResolveVerifyUserPassword(arguments),
|
||||
arguments.GetOptional("value"));
|
||||
|
||||
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||
{
|
||||
@@ -319,6 +343,48 @@ public static class MxGatewayClientCli
|
||||
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective MXAccess verify-user credential from
|
||||
/// <c>--verify-user-password</c> or, failing that, the
|
||||
/// <c>--verify-user-password-env</c>-named environment variable (default
|
||||
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>). The credential is never echoed;
|
||||
/// this resolver exists so the error-redaction catch block can strip it
|
||||
/// from any surfaced error (CLI-04), mirroring <see cref="TryResolveApiKey"/>.
|
||||
/// </summary>
|
||||
private static string? TryResolveVerifyUserPassword(CliArguments arguments)
|
||||
{
|
||||
string? password = arguments.GetOptional("verify-user-password");
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
return password;
|
||||
}
|
||||
|
||||
string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
|
||||
?? "MXGATEWAY_VERIFY_USER_PASSWORD";
|
||||
|
||||
return Environment.GetEnvironmentVariable(passwordEnvironmentName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the verify-user credential for <c>authenticate-user</c>, throwing
|
||||
/// a redaction-safe error when neither the flag nor the env var is set. The
|
||||
/// thrown message names only the option/env var, never the value.
|
||||
/// </summary>
|
||||
private static string ResolveVerifyUserPassword(CliArguments arguments)
|
||||
{
|
||||
string? password = TryResolveVerifyUserPassword(arguments);
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
return password;
|
||||
}
|
||||
|
||||
string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
|
||||
?? "MXGATEWAY_VERIFY_USER_PASSWORD";
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Verify-user password is required. Pass --verify-user-password or set {passwordEnvironmentName}.");
|
||||
}
|
||||
|
||||
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
|
||||
{
|
||||
var cancellation = new CancellationTokenSource();
|
||||
@@ -475,6 +541,215 @@ public static class MxGatewayClientCli
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> UnregisterAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Unregister,
|
||||
Unregister = new UnregisterCommand
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> AddBufferedItemAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddBufferedItem,
|
||||
AddBufferedItem = new AddBufferedItemCommand
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
ItemDefinition = arguments.GetRequired("item"),
|
||||
ItemContext = arguments.GetOptional("item-context") ?? string.Empty,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> SetBufferedUpdateIntervalAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.SetBufferedUpdateInterval,
|
||||
SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
UpdateIntervalMilliseconds = arguments.GetInt32("interval-ms"),
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> SuspendAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Suspend,
|
||||
Suspend = new SuspendCommand
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
ItemHandle = arguments.GetInt32("item-handle"),
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> ActivateAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Activate,
|
||||
Activate = new ActivateCommand
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
ItemHandle = arguments.GetInt32("item-handle"),
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> WriteSecuredAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured,
|
||||
WriteSecured = new WriteSecuredCommand
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
ItemHandle = arguments.GetInt32("item-handle"),
|
||||
CurrentUserId = arguments.GetInt32("current-user-id"),
|
||||
VerifierUserId = arguments.GetInt32("verifier-user-id", 0),
|
||||
Value = ParseValue(arguments),
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> WriteSecured2Async(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2,
|
||||
WriteSecured2 = new WriteSecured2Command
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
ItemHandle = arguments.GetInt32("item-handle"),
|
||||
CurrentUserId = arguments.GetInt32("current-user-id"),
|
||||
VerifierUserId = arguments.GetInt32("verifier-user-id", 0),
|
||||
Value = ParseValue(arguments),
|
||||
TimestampValue = ParseTimestampValue(arguments),
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> AuthenticateUserAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The credential is resolved from --verify-user-password or its env var and
|
||||
// is never echoed. On any surfaced error the RunCoreAsync catch block routes
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AuthenticateUser,
|
||||
AuthenticateUser = new AuthenticateUserCommand
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
VerifyUser = arguments.GetRequired("verify-user"),
|
||||
VerifyUserPassword = ResolveVerifyUserPassword(arguments),
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> ArchestraUserToIdAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
output,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.ArchestraUserToId,
|
||||
ArchestraUserToId = new ArchestrAUserToIdCommand
|
||||
{
|
||||
ServerHandle = arguments.GetInt32("server-handle"),
|
||||
UserIdGuid = arguments.GetRequired("user-guid"),
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Task<int> SubscribeBulkAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
@@ -2029,6 +2304,15 @@ public static class MxGatewayClientCli
|
||||
or "add-item"
|
||||
or "advise"
|
||||
or "advise-supervisory"
|
||||
or "unregister"
|
||||
or "add-buffered-item"
|
||||
or "set-buffered-update-interval"
|
||||
or "suspend"
|
||||
or "activate"
|
||||
or "write-secured"
|
||||
or "write-secured2"
|
||||
or "authenticate-user"
|
||||
or "archestra-user-to-id"
|
||||
or "subscribe-bulk"
|
||||
or "unsubscribe-bulk"
|
||||
or "read-bulk"
|
||||
@@ -2092,6 +2376,15 @@ public static class MxGatewayClientCli
|
||||
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet advise-supervisory --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet unregister --session-id <id> --server-handle <n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet add-buffered-item --session-id <id> --server-handle <n> --item <ref> [--item-context <s>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet set-buffered-update-interval --session-id <id> --server-handle <n> --interval-ms <n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet suspend --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet activate --session-id <id> --server-handle <n> --item-handle <n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write-secured --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write-secured2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--timestamp <iso>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet authenticate-user --session-id <id> --server-handle <n> --verify-user <user> (--verify-user-password <pw> | --verify-user-password-env <ENVVAR>) [--json]");
|
||||
writer.WriteLine("mxgw-dotnet archestra-user-to-id --session-id <id> --server-handle <n> --user-guid <guid> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");
|
||||
|
||||
@@ -129,6 +129,120 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.WriteSecured,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"write-secured",
|
||||
"--endpoint", "http://localhost:5000",
|
||||
"--api-key", "test-api-key",
|
||||
"--session-id", "session-fixture",
|
||||
"--server-handle", "12",
|
||||
"--item-handle", "34",
|
||||
"--type", "int32",
|
||||
"--value", "123",
|
||||
"--current-user-id", "5",
|
||||
"--verifier-user-id", "6",
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
||||
Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind);
|
||||
Assert.Equal(123, request.Command.WriteSecured.Value.Int32Value);
|
||||
Assert.Equal(5, request.Command.WriteSecured.CurrentUserId);
|
||||
Assert.Equal(6, request.Command.WriteSecured.VerifierUserId);
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
|
||||
/// credential from the flag, and that the credential never appears in stdout/stderr.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
|
||||
{
|
||||
const string password = "cli-secret-credential-987";
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.AuthenticateUser,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
AuthenticateUser = new AuthenticateUserReply { UserId = 4242 },
|
||||
});
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"authenticate-user",
|
||||
"--endpoint", "http://localhost:5000",
|
||||
"--api-key", "test-api-key",
|
||||
"--session-id", "session-fixture",
|
||||
"--server-handle", "12",
|
||||
"--verify-user", "operator",
|
||||
"--verify-user-password", password,
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
||||
Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind);
|
||||
Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser);
|
||||
Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
|
||||
Assert.DoesNotContain(password, output.ToString());
|
||||
Assert.DoesNotContain(password, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-04: a surfaced error for authenticate-user must have the credential
|
||||
/// redacted (never echoed to stderr), mirroring the API-key redaction seam.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
|
||||
{
|
||||
const string password = "leaky-credential-value";
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"authenticate-user",
|
||||
"--endpoint", "http://localhost:5000",
|
||||
"--api-key", "test-api-key",
|
||||
"--session-id", "session-fixture",
|
||||
"--server-handle", "12",
|
||||
"--verify-user", "operator",
|
||||
"--verify-user-password", password,
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => throw new InvalidOperationException($"boom {password}"));
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.DoesNotContain(password, error.ToString());
|
||||
Assert.Contains("[redacted]", error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
||||
|
||||
@@ -215,6 +215,55 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal("session-fixture", request.SessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a reconnect-replay gap sentinel is surfaced as a typed
|
||||
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
|
||||
/// events pass through unchanged with IsReplayGap false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddEvent(new MxEvent
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
ReplayGap = new ReplayGap
|
||||
{
|
||||
RequestedAfterSequence = 5,
|
||||
OldestAvailableSequence = 42,
|
||||
},
|
||||
});
|
||||
transport.AddEvent(new MxEvent
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
WorkerSequence = 42,
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
List<MxEventStreamItem> items = [];
|
||||
await foreach (MxEventStreamItem item in session.StreamEventItemsAsync(afterWorkerSequence: 5))
|
||||
{
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
Assert.Equal(2, items.Count);
|
||||
|
||||
MxEventStreamItem gap = items[0];
|
||||
Assert.True(gap.IsReplayGap);
|
||||
Assert.NotNull(gap.ReplayGap);
|
||||
Assert.Equal(5UL, gap.ReplayGap!.RequestedAfterSequence);
|
||||
Assert.Equal(42UL, gap.ReplayGap.OldestAvailableSequence);
|
||||
Assert.Same(gap.ReplayGap, gap.Event.ReplayGap);
|
||||
|
||||
MxEventStreamItem normal = items[1];
|
||||
Assert.False(normal.IsReplayGap);
|
||||
Assert.Null(normal.ReplayGap);
|
||||
Assert.Equal(42UL, normal.Event.WorkerSequence);
|
||||
Assert.Equal(MxEventFamily.OnDataChange, normal.Event.Family);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
||||
[Fact]
|
||||
public async Task CloseAsync_IsExplicitAndIdempotent()
|
||||
@@ -366,6 +415,297 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal(7, el.Value.Int32Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
|
||||
[Fact]
|
||||
public async Task UnregisterAsync_BuildsUnregisterCommand()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Unregister,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await session.UnregisterAsync(12);
|
||||
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.Unregister, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.Unregister.ServerHandle);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
|
||||
[Fact]
|
||||
public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.AdviseSupervisory,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await session.AdviseSupervisoryAsync(12, 34);
|
||||
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.AdviseSupervisory, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.AdviseSupervisory.ServerHandle);
|
||||
Assert.Equal(34, request.Command.AdviseSupervisory.ItemHandle);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
|
||||
[Fact]
|
||||
public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.AddBufferedItem,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
AddBufferedItem = new AddBufferedItemReply { ItemHandle = 77 },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
int itemHandle = await session.AddBufferedItemAsync(12, "Area001.Pump001.Speed", "runtime");
|
||||
|
||||
Assert.Equal(77, itemHandle);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.AddBufferedItem, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.AddBufferedItem.ServerHandle);
|
||||
Assert.Equal("Area001.Pump001.Speed", request.Command.AddBufferedItem.ItemDefinition);
|
||||
Assert.Equal("runtime", request.Command.AddBufferedItem.ItemContext);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
|
||||
[Fact]
|
||||
public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.SetBufferedUpdateInterval,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
await session.SetBufferedUpdateIntervalAsync(12, 500);
|
||||
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.SetBufferedUpdateInterval, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.SetBufferedUpdateInterval.ServerHandle);
|
||||
Assert.Equal(500, request.Command.SetBufferedUpdateInterval.UpdateIntervalMilliseconds);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
|
||||
[Fact]
|
||||
public async Task SuspendAsync_BuildsCommandAndReturnsStatus()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Suspend,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Suspend = new SuspendReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
MxStatusProxy? status = await session.SuspendAsync(12, 34);
|
||||
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal(1, status!.Success);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.Suspend, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.Suspend.ServerHandle);
|
||||
Assert.Equal(34, request.Command.Suspend.ItemHandle);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that activate builds the command and returns the reply status.</summary>
|
||||
[Fact]
|
||||
public async Task ActivateAsync_BuildsCommandAndReturnsStatus()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.Activate,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Activate = new ActivateReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
MxStatusProxy? status = await session.ActivateAsync(12, 34);
|
||||
|
||||
Assert.NotNull(status);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.Activate, request.Command.Kind);
|
||||
Assert.Equal(34, request.Command.Activate.ItemHandle);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
|
||||
[Fact]
|
||||
public async Task WriteSecuredAsync_BuildsWriteSecuredCommand()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.WriteSecured,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
MxValue value = 123.ToMxValue();
|
||||
|
||||
await session.WriteSecuredAsync(12, 34, value, currentUserId: 5, verifierUserId: 6);
|
||||
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.WriteSecured.ServerHandle);
|
||||
Assert.Equal(34, request.Command.WriteSecured.ItemHandle);
|
||||
Assert.Same(value, request.Command.WriteSecured.Value);
|
||||
Assert.Equal(5, request.Command.WriteSecured.CurrentUserId);
|
||||
Assert.Equal(6, request.Command.WriteSecured.VerifierUserId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MXAccess parity: WriteSecured issued before a prior AuthenticateUser fails
|
||||
/// natively. The client must surface that scripted failure (as an
|
||||
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.WriteSecured,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure },
|
||||
Hresult = unchecked((int)0x80040200),
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
MxAccessException exception = await Assert.ThrowsAsync<MxAccessException>(
|
||||
async () => await session.WriteSecuredAsync(12, 34, 123.ToMxValue(), currentUserId: 0, verifierUserId: 0));
|
||||
|
||||
// The native HRESULT is surfaced; the request payload is never in the message.
|
||||
Assert.Contains("WriteSecured", exception.Message);
|
||||
Assert.Single(transport.InvokeCalls);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
|
||||
[Fact]
|
||||
public async Task WriteSecured2Async_BuildsWriteSecured2Command()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.WriteSecured2,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
MxValue value = 123.ToMxValue();
|
||||
MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue();
|
||||
|
||||
await session.WriteSecured2Async(12, 34, value, timestampValue, currentUserId: 5, verifierUserId: 6);
|
||||
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.WriteSecured2, request.Command.Kind);
|
||||
Assert.Same(value, request.Command.WriteSecured2.Value);
|
||||
Assert.Same(timestampValue, request.Command.WriteSecured2.TimestampValue);
|
||||
Assert.Equal(5, request.Command.WriteSecured2.CurrentUserId);
|
||||
Assert.Equal(6, request.Command.WriteSecured2.VerifierUserId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.AuthenticateUser,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
AuthenticateUser = new AuthenticateUserReply { UserId = 4242 },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
int userId = await session.AuthenticateUserAsync(12, "operator", "s3cr3t-p@ss");
|
||||
|
||||
Assert.Equal(4242, userId);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind);
|
||||
Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser);
|
||||
Assert.Equal("s3cr3t-p@ss", request.Command.AuthenticateUser.VerifyUserPassword);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SECRET REDACTION: when AuthenticateUser fails, the surfaced exception message
|
||||
/// must never contain the credential — the error path is built only from
|
||||
/// reply-derived diagnostics, not the request payload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage()
|
||||
{
|
||||
const string password = "super-secret-credential-123";
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.AuthenticateUser,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure },
|
||||
Hresult = unchecked((int)0x80040210),
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
MxAccessException exception = await Assert.ThrowsAsync<MxAccessException>(
|
||||
async () => await session.AuthenticateUserAsync(12, "operator", password));
|
||||
|
||||
Assert.DoesNotContain(password, exception.Message);
|
||||
Assert.DoesNotContain(password, exception.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
|
||||
[Fact]
|
||||
public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.ArchestraUserToId,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
ArchestraUserToId = new ArchestrAUserToIdReply { UserId = 909 },
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
int userId = await session.ArchestraUserToIdAsync(12, "BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
|
||||
|
||||
Assert.Equal(909, userId);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.ArchestraUserToId, request.Command.Kind);
|
||||
Assert.Equal("BCC47053-9542-4D65-BDAA-BCDEA6A32A73", request.Command.ArchestraUserToId.UserIdGuid);
|
||||
}
|
||||
|
||||
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
|
||||
{
|
||||
return new MxGatewayClient(transport.Options, transport);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods that project a raw <see cref="MxEvent"/> stream into the
|
||||
/// typed <see cref="MxEventStreamItem"/> surface, making the gateway's
|
||||
/// reconnect-replay gap sentinel observable.
|
||||
/// </summary>
|
||||
public static class MxEventStreamExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects a raw <see cref="MxEvent"/> stream (e.g.
|
||||
/// <see cref="MxGatewaySession.StreamEventsAsync"/> or
|
||||
/// <see cref="MxGatewayClient.StreamEventsAsync"/>) into typed
|
||||
/// <see cref="MxEventStreamItem"/> values. Normal events pass through with
|
||||
/// <see cref="MxEventStreamItem.IsReplayGap"/> false; the gateway's
|
||||
/// reconnect-replay gap sentinel is surfaced with
|
||||
/// <see cref="MxEventStreamItem.IsReplayGap"/> true and
|
||||
/// <see cref="MxEventStreamItem.ReplayGap"/> populated. The stream is
|
||||
/// forwarded faithfully — no event is synthesized or dropped.
|
||||
/// </summary>
|
||||
/// <param name="source">The raw event stream to wrap.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the enumeration.</param>
|
||||
/// <returns>The same events, each wrapped as an <see cref="MxEventStreamItem"/>.</returns>
|
||||
public static async IAsyncEnumerable<MxEventStreamItem> AsStreamItemsAsync(
|
||||
this IAsyncEnumerable<MxEvent> source,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
await foreach (MxEvent gatewayEvent in source
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
yield return MxEventStreamItem.From(gatewayEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// One item yielded by the typed event stream. It is either a normal MXAccess
|
||||
/// <see cref="MxEvent"/> or a reconnect-replay <em>gap sentinel</em>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The gateway emits a single sentinel <see cref="MxEvent"/> at the head of a
|
||||
/// <c>StreamEvents</c> stream that was resumed via
|
||||
/// <c>StreamEventsRequest.after_worker_sequence</c> when the requested sequence
|
||||
/// predates the oldest event still retained in the session replay ring — i.e.
|
||||
/// events were evicted and cannot be replayed. On that sentinel the
|
||||
/// <c>MxEvent.replay_gap</c> field is set, <see cref="MxEvent.Family"/> is
|
||||
/// <see cref="MxEventFamily.Unspecified"/>, the <c>body</c> oneof is unset, and
|
||||
/// no per-item fields are populated.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This wrapper makes that sentinel observable instead of forcing the consumer
|
||||
/// to inspect the raw <see cref="MxEvent"/>. It never synthesizes or swallows an
|
||||
/// event: the gap is exactly the gateway's own sentinel, exposed with
|
||||
/// <see cref="IsReplayGap"/> set and <see cref="ReplayGap"/> populated. Every
|
||||
/// other event flows through unchanged with <see cref="IsReplayGap"/> false.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <see cref="IsReplayGap"/> is <see langword="true"/> the consumer has
|
||||
/// missed events and MUST discard local state and re-snapshot. To resume without
|
||||
/// incurring another gap, reconnect with
|
||||
/// <c>after_worker_sequence = ReplayGap.OldestAvailableSequence - 1</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MxEventStreamItem
|
||||
{
|
||||
private MxEventStreamItem(MxEvent gatewayEvent, ReplayGap? replayGap)
|
||||
{
|
||||
Event = gatewayEvent;
|
||||
ReplayGap = replayGap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The underlying raw <see cref="MxEvent"/>. For a normal event this is the
|
||||
/// MXAccess event itself; for a replay-gap item this is the gateway's
|
||||
/// sentinel event whose only meaningful payload is <see cref="ReplayGap"/>.
|
||||
/// Never <see langword="null"/>.
|
||||
/// </summary>
|
||||
public MxEvent Event { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The reconnect-replay gap payload when this item is a gap sentinel;
|
||||
/// otherwise <see langword="null"/>. Read
|
||||
/// <see cref="Contracts.Proto.ReplayGap.RequestedAfterSequence"/> and
|
||||
/// <see cref="Contracts.Proto.ReplayGap.OldestAvailableSequence"/> to learn
|
||||
/// which events were lost.
|
||||
/// </summary>
|
||||
public ReplayGap? ReplayGap { get; }
|
||||
|
||||
/// <summary>
|
||||
/// <see langword="true"/> when this item is a reconnect-replay gap sentinel:
|
||||
/// the consumer missed events and must discard local state and re-snapshot.
|
||||
/// Resume without another gap by reconnecting with
|
||||
/// <c>after_worker_sequence = ReplayGap.OldestAvailableSequence - 1</c>.
|
||||
/// </summary>
|
||||
public bool IsReplayGap => ReplayGap is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a raw stream <see cref="MxEvent"/> as a typed item, classifying it
|
||||
/// as a replay-gap sentinel when <c>MxEvent.replay_gap</c> is present.
|
||||
/// </summary>
|
||||
/// <param name="gatewayEvent">The raw event from the <c>StreamEvents</c> stream.</param>
|
||||
/// <returns>A typed item exposing either the normal event or the replay gap.</returns>
|
||||
internal static MxEventStreamItem From(MxEvent gatewayEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gatewayEvent);
|
||||
|
||||
// For a message-typed proto3 field, presence is a non-null reference.
|
||||
return gatewayEvent.ReplayGap is { } replayGap
|
||||
? new MxEventStreamItem(gatewayEvent, replayGap)
|
||||
: new MxEventStreamItem(gatewayEvent, replayGap: null);
|
||||
}
|
||||
}
|
||||
@@ -842,6 +842,525 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a previously registered client from the MXAccess session
|
||||
/// (MXAccess <c>Unregister</c>), releasing its ServerHandle.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task UnregisterAsync(
|
||||
int serverHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await UnregisterRawAsync(serverHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a previously registered client without error checking.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> UnregisterRawAsync(
|
||||
int serverHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Unregister,
|
||||
Unregister = new UnregisterCommand { ServerHandle = serverHandle },
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to supervisory events for an item (MXAccess <c>AdviseSupervisory</c>).
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task AdviseSupervisoryAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await AdviseSupervisoryRawAsync(serverHandle, itemHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to supervisory events for an item without error checking.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> AdviseSupervisoryRawAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AdviseSupervisory,
|
||||
AdviseSupervisory = new AdviseSupervisoryCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a buffered item to the MXAccess session (MXAccess <c>AddBufferedItem</c>),
|
||||
/// returning an ItemHandle.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemDefinition">The item tag address.</param>
|
||||
/// <param name="itemContext">Additional context for the item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The item handle assigned to the new buffered item.</returns>
|
||||
public async Task<int> AddBufferedItemAsync(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
string itemContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await AddBufferedItemRawAsync(
|
||||
serverHandle,
|
||||
itemDefinition,
|
||||
itemContext,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.AddBufferedItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a buffered item to the MXAccess session without error checking.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemDefinition">The item tag address.</param>
|
||||
/// <param name="itemContext">Additional context for the item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> AddBufferedItemRawAsync(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
string itemContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
|
||||
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddBufferedItem,
|
||||
AddBufferedItem = new AddBufferedItemCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemDefinition = itemDefinition,
|
||||
ItemContext = itemContext ?? string.Empty,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the buffered-item update interval on the MXAccess session
|
||||
/// (MXAccess <c>SetBufferedUpdateInterval</c>).
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task SetBufferedUpdateIntervalAsync(
|
||||
int serverHandle,
|
||||
int updateIntervalMilliseconds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await SetBufferedUpdateIntervalRawAsync(
|
||||
serverHandle,
|
||||
updateIntervalMilliseconds,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the buffered-item update interval without error checking.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> SetBufferedUpdateIntervalRawAsync(
|
||||
int serverHandle,
|
||||
int updateIntervalMilliseconds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.SetBufferedUpdateInterval,
|
||||
SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
UpdateIntervalMilliseconds = updateIntervalMilliseconds,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suspends updates for an item (MXAccess <c>Suspend</c>).
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The item's MXSTATUS_PROXY as reported by the worker, or <c>null</c> if the reply omitted it.</returns>
|
||||
public async Task<MxStatusProxy?> SuspendAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await SuspendRawAsync(serverHandle, itemHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.Suspend?.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suspends updates for an item without error checking.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> SuspendRawAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Suspend,
|
||||
Suspend = new SuspendCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes updates for a suspended item (MXAccess <c>Activate</c>).
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The item's MXSTATUS_PROXY as reported by the worker, or <c>null</c> if the reply omitted it.</returns>
|
||||
public async Task<MxStatusProxy?> ActivateAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await ActivateRawAsync(serverHandle, itemHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.Activate?.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes updates for a suspended item without error checking.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> ActivateRawAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Activate,
|
||||
Activate = new ActivateCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a secured value to an item on the MXAccess server (MXAccess <c>WriteSecured</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MXAccess parity: <c>WriteSecured</c> fails when it is issued before a value-bearing
|
||||
/// NMX body or before a prior <c>AuthenticateUser</c> + <c>AdviseSupervisory</c>. That
|
||||
/// native failure is surfaced unchanged — the client does not pre-validate or reorder it.
|
||||
/// The <paramref name="value"/> is credential-sensitive and must never reach logs; the
|
||||
/// client mirrors the single-item WriteSecured redaction contract.
|
||||
/// </remarks>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="value">The secured value to write.</param>
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task WriteSecuredAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxValue value,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await WriteSecuredRawAsync(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
value,
|
||||
currentUserId,
|
||||
verifierUserId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a secured value to an item without error checking. See
|
||||
/// <see cref="WriteSecuredAsync"/> for the parity and redaction contract.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="value">The secured value to write.</param>
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> WriteSecuredRawAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxValue value,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured,
|
||||
WriteSecured = new WriteSecuredCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
CurrentUserId = currentUserId,
|
||||
VerifierUserId = verifierUserId,
|
||||
Value = value,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a secured value and timestamp to an item (MXAccess <c>WriteSecured2</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Same parity and redaction contract as <see cref="WriteSecuredAsync"/>: the native
|
||||
/// failure that occurs before a value-bearing NMX body or a prior authenticate is
|
||||
/// surfaced unchanged, and the credential-sensitive <paramref name="value"/> must never
|
||||
/// reach logs.
|
||||
/// </remarks>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="value">The secured value to write.</param>
|
||||
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task WriteSecured2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxValue value,
|
||||
MxValue timestampValue,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await WriteSecured2RawAsync(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
value,
|
||||
timestampValue,
|
||||
currentUserId,
|
||||
verifierUserId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a secured value and timestamp to an item without error checking. See
|
||||
/// <see cref="WriteSecured2Async"/> for the parity and redaction contract.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="value">The secured value to write.</param>
|
||||
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> WriteSecured2RawAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxValue value,
|
||||
MxValue timestampValue,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
ArgumentNullException.ThrowIfNull(timestampValue);
|
||||
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2,
|
||||
WriteSecured2 = new WriteSecured2Command
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
CurrentUserId = currentUserId,
|
||||
VerifierUserId = verifierUserId,
|
||||
Value = value,
|
||||
TimestampValue = timestampValue,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates an MXAccess verify-user (MXAccess <c>AuthenticateUser</c>), returning
|
||||
/// the resolved user id used by <c>WriteSecured</c> / <c>WriteSecured2</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <paramref name="verifyUserPassword"/> is a raw MXAccess credential. It is never
|
||||
/// logged and never placed on the exception path: gateway/MXAccess failures surface only
|
||||
/// reply-derived diagnostics (kind, HRESULT, MXSTATUS_PROXY), never the request payload.
|
||||
/// </remarks>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="verifyUser">The user to verify.</param>
|
||||
/// <param name="verifyUserPassword">The verify-user credential. Never logged.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The authenticated user id.</returns>
|
||||
public async Task<int> AuthenticateUserAsync(
|
||||
int serverHandle,
|
||||
string verifyUser,
|
||||
string verifyUserPassword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await AuthenticateUserRawAsync(
|
||||
serverHandle,
|
||||
verifyUser,
|
||||
verifyUserPassword,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.AuthenticateUser?.UserId ?? reply.ReturnValue.Int32Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates an MXAccess verify-user without error checking. See
|
||||
/// <see cref="AuthenticateUserAsync"/> for the credential-handling contract.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="verifyUser">The user to verify.</param>
|
||||
/// <param name="verifyUserPassword">The verify-user credential. Never logged.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> AuthenticateUserRawAsync(
|
||||
int serverHandle,
|
||||
string verifyUser,
|
||||
string verifyUserPassword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(verifyUser);
|
||||
ArgumentNullException.ThrowIfNull(verifyUserPassword);
|
||||
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AuthenticateUser,
|
||||
AuthenticateUser = new AuthenticateUserCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
VerifyUser = verifyUser,
|
||||
VerifyUserPassword = verifyUserPassword,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an ArchestrA user GUID to its MXAccess user id
|
||||
/// (MXAccess <c>ArchestrAUserToId</c>).
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="userIdGuid">The ArchestrA user GUID to resolve.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resolved MXAccess user id.</returns>
|
||||
public async Task<int> ArchestraUserToIdAsync(
|
||||
int serverHandle,
|
||||
string userIdGuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
MxCommandReply reply = await ArchestraUserToIdRawAsync(serverHandle, userIdGuid, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.ArchestraUserToId?.UserId ?? reply.ReturnValue.Int32Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an ArchestrA user GUID to its MXAccess user id without error checking.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="userIdGuid">The ArchestrA user GUID to resolve.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The raw server reply.</returns>
|
||||
public Task<MxCommandReply> ArchestraUserToIdRawAsync(
|
||||
int serverHandle,
|
||||
string userIdGuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userIdGuid);
|
||||
|
||||
return InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.ArchestraUserToId,
|
||||
ArchestraUserToId = new ArchestrAUserToIdCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
UserIdGuid = userIdGuid,
|
||||
},
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes an MXAccess command on this session.
|
||||
/// </summary>
|
||||
@@ -875,6 +1394,34 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams events as typed <see cref="MxEventStreamItem"/> values, surfacing
|
||||
/// the gateway's reconnect-replay gap sentinel as an observable, typed signal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When resuming with a stale <paramref name="afterWorkerSequence"/> (older than
|
||||
/// the oldest event still retained in the session replay ring), the gateway emits
|
||||
/// a single gap sentinel at the head of the stream. It arrives here as an item
|
||||
/// with <see cref="MxEventStreamItem.IsReplayGap"/> true and
|
||||
/// <see cref="MxEventStreamItem.ReplayGap"/> populated, meaning the consumer
|
||||
/// missed events and MUST discard local state and re-snapshot. To resume without
|
||||
/// incurring another gap, reconnect with
|
||||
/// <c>afterWorkerSequence = item.ReplayGap.OldestAvailableSequence - 1</c>.
|
||||
/// All other events pass through with <see cref="MxEventStreamItem.IsReplayGap"/>
|
||||
/// false. Use <see cref="StreamEventsAsync"/> when raw generated
|
||||
/// <see cref="MxEvent"/> messages are needed instead.
|
||||
/// </remarks>
|
||||
/// <param name="afterWorkerSequence">The sequence number to stream from. Defaults to 0.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of typed event items.</returns>
|
||||
public IAsyncEnumerable<MxEventStreamItem> StreamEventItemsAsync(
|
||||
ulong afterWorkerSequence = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return StreamEventsAsync(afterWorkerSequence, cancellationToken)
|
||||
.AsStreamItemsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the session and releases resources.
|
||||
/// </summary>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
|
||||
<Version>0.1.2</Version>
|
||||
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<!-- Only the shipped library generates XML docs (matching src/Contracts). The Cli and
|
||||
|
||||
+61
-16
@@ -84,7 +84,9 @@ true` to verify against the OS/system trust roots without pinning. See
|
||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||
|
||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
||||
`AddItem`, `AddItem2`, `Advise`, `AdviseSupervisory`, `Write`, `WriteSecured`,
|
||||
`WriteSecured2`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`,
|
||||
`SetBufferedUpdateInterval`, `Suspend`, `Activate`, `Events`, and `Close`. Prefer
|
||||
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||
returned subscription owns cancellation and exposes `Close` for deterministic
|
||||
goroutine cleanup. Raw protobuf messages remain available through the
|
||||
@@ -92,6 +94,44 @@ goroutine cleanup. Raw protobuf messages remain available through the
|
||||
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
||||
errors preserve the raw reply.
|
||||
|
||||
### Reconnect-replay gap
|
||||
|
||||
Each `EventResult` carries exactly one of `Event`, `ReplayGap`, or `Err`. When
|
||||
you resume a stream with `EventsAfter`/`SubscribeEventsAfter` and a non-zero
|
||||
`afterWorkerSequence`, the gateway replays buffered events from that point. If
|
||||
the requested sequence predates the oldest event still retained in its replay
|
||||
ring, it delivers a single **replay-gap sentinel** at the head of the resumed
|
||||
stream: `res.ReplayGap` is non-nil (`res.Event` is nil, `res.IsReplayGap()` is
|
||||
true) and normal events follow it.
|
||||
|
||||
A gap means events were lost, so any locally cached tag/alarm state is now
|
||||
stale. On seeing it, discard your cached state and re-snapshot. To resume
|
||||
without provoking another gap, reconnect from just before the oldest retained
|
||||
sequence:
|
||||
|
||||
```go
|
||||
for res := range events {
|
||||
switch {
|
||||
case res.Err != nil:
|
||||
// terminal: stream ended (see ErrSlowConsumer for the overflow case)
|
||||
return res.Err
|
||||
case res.IsReplayGap():
|
||||
gap := res.ReplayGap
|
||||
log.Printf("replay gap: requested after %d, oldest available %d; re-snapshotting",
|
||||
gap.GetRequestedAfterSequence(), gap.GetOldestAvailableSequence())
|
||||
resnapshot()
|
||||
// to resume cleanly, reconnect with:
|
||||
// session.EventsAfter(ctx, gap.GetOldestAvailableSequence()-1)
|
||||
default:
|
||||
handle(res.Event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The gateway sets `ReplayGap` only on `StreamEvents` results (never on a fresh,
|
||||
non-resumed stream and never on `DrainEvents`). The client makes the gateway's
|
||||
sentinel typed and observable; it never synthesizes or swallows it.
|
||||
|
||||
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
|
||||
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
|
||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
|
||||
@@ -113,29 +153,32 @@ still need the write attributed to a user id, you must first advise the item
|
||||
supervisory and then pass that user id on the write. Without the supervisory
|
||||
advise the `userID` on a plain write is ignored.
|
||||
|
||||
The session exposes `Advise`/`UnAdvise` but not supervisory advise, so send it
|
||||
through the generic command channel:
|
||||
The session exposes a typed `AdviseSupervisory` helper alongside `Advise`/`UnAdvise`:
|
||||
|
||||
```go
|
||||
_, err := client.Invoke(ctx, &pb.MxCommandRequest{
|
||||
SessionId: session.ID(),
|
||||
Command: &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
Payload: &pb.MxCommand_AdviseSupervisory{
|
||||
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := session.AdviseSupervisory(ctx, serverHandle, itemHandle)
|
||||
// ...
|
||||
err = session.Write(ctx, serverHandle, itemHandle, value, userID)
|
||||
```
|
||||
|
||||
The CLI exposes the same command as `advise-supervisory`, and `write`
|
||||
takes `-user-id`.
|
||||
|
||||
### Secured writes and user authentication
|
||||
|
||||
The verified/secured path has typed single-item helpers too:
|
||||
`AuthenticateUser(ctx, serverHandle, verifyUser, verifyUserPassword)` returns the
|
||||
resolved MXAccess user id, and `WriteSecured` / `WriteSecured2` issue the secured
|
||||
write. Credentials passed to `AuthenticateUser`, and the string content of a
|
||||
`WriteSecured`/`WriteSecured2` value, are kept out of any error the client
|
||||
surfaces (they route through the same redaction seam as the API key) and are
|
||||
never logged — callers must likewise keep them out of their own logs. MXAccess
|
||||
parity holds: a `WriteSecured` issued without a matching prior `AuthenticateUser`
|
||||
and supervisory advise fails natively, and that failure is surfaced unchanged
|
||||
rather than pre-empted. The CLI exposes `authenticate-user` (credential via
|
||||
`-password-env`, default `MXGATEWAY_VERIFY_PASSWORD`, or `-password`) and
|
||||
`write-secured`.
|
||||
|
||||
### Array writes replace the whole array
|
||||
|
||||
A write to an array attribute **replaces the entire array**; it is not an
|
||||
@@ -340,6 +383,8 @@ Every subcommand wired into the CLI. All accept the common flags
|
||||
| `unsubscribe-bulk` | Unadvise many item handles in one call. |
|
||||
| `read-bulk` | Read snapshots for many item handles in one call. |
|
||||
| `write` | Write one value (`-type`, `-value`). |
|
||||
| `write-secured` | Secured single-item write (`-current-user-id`, `-verifier-user-id`, `-type`, `-value`). |
|
||||
| `authenticate-user` | Authenticate a user, printing the resolved user id (`-verify-user`, `-password-env`/`-password`). |
|
||||
| `write-bulk` | Write many values (`-item-handles`, `-values`, counts must match). |
|
||||
| `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). |
|
||||
| `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). |
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
@@ -90,6 +89,10 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
|
||||
return runAdvise(ctx, args[1:], stdout, stderr)
|
||||
case "advise-supervisory":
|
||||
return runAdviseSupervisory(ctx, args[1:], stdout, stderr)
|
||||
case "write-secured":
|
||||
return runWriteSecured(ctx, args[1:], stdout, stderr)
|
||||
case "authenticate-user":
|
||||
return runAuthenticateUser(ctx, args[1:], stdout, stderr)
|
||||
case "subscribe-bulk":
|
||||
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
|
||||
case "unsubscribe-bulk":
|
||||
@@ -383,21 +386,90 @@ func runAdviseSupervisory(ctx context.Context, args []string, stdout, stderr io.
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
reply, err := client.Invoke(ctx, &pb.MxCommandRequest{
|
||||
SessionId: *sessionID,
|
||||
Command: &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
Payload: &pb.MxCommand_AdviseSupervisory{
|
||||
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
|
||||
ServerHandle: int32(*serverHandle),
|
||||
ItemHandle: int32(*itemHandle),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
reply, err := session.AdviseSupervisoryRaw(ctx, int32(*serverHandle), int32(*itemHandle))
|
||||
return writeCommandOutput(stdout, *jsonOutput, "advise-supervisory", options, reply, err)
|
||||
}
|
||||
|
||||
func runWriteSecured(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("write-secured", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
sessionID := flags.String("session-id", "", "gateway session id")
|
||||
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||
itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
|
||||
currentUserID := flags.Int("current-user-id", 0, "MXAccess current user id")
|
||||
verifierUserID := flags.Int("verifier-user-id", 0, "MXAccess verifier user id")
|
||||
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
|
||||
valueText := flags.String("value", "", "value text")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *sessionID == "" {
|
||||
return errors.New("session-id is required")
|
||||
}
|
||||
|
||||
value, err := parseValue(*valueType, *valueText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
reply, err := session.WriteSecuredRaw(ctx, int32(*serverHandle), int32(*itemHandle), int32(*currentUserID), int32(*verifierUserID), value)
|
||||
return writeCommandOutput(stdout, *jsonOutput, "write-secured", options, reply, err)
|
||||
}
|
||||
|
||||
func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("authenticate-user", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
common := bindCommonFlags(flags)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
sessionID := flags.String("session-id", "", "gateway session id")
|
||||
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||
verifyUser := flags.String("verify-user", "", "MXAccess user to authenticate")
|
||||
// The credential is never accepted echoed on the command line by default:
|
||||
// prefer the environment variable so it stays out of shell history and the
|
||||
// process table. The -password flag remains for non-interactive scripting.
|
||||
password := flags.String("password", "", "verify-user password (prefer -password-env)")
|
||||
passwordEnv := flags.String("password-env", "MXGATEWAY_VERIFY_PASSWORD", "environment variable containing the verify-user password")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *sessionID == "" {
|
||||
return errors.New("session-id is required")
|
||||
}
|
||||
if *verifyUser == "" {
|
||||
return errors.New("verify-user is required")
|
||||
}
|
||||
|
||||
resolvedPassword := *password
|
||||
if resolvedPassword == "" && *passwordEnv != "" {
|
||||
resolvedPassword = os.Getenv(*passwordEnv)
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
// The raw reply carries only the resolved user id, never the credential, so
|
||||
// writeCommandOutput can render it as-is; the credential is additionally
|
||||
// scrubbed from any surfaced error by AuthenticateUserRaw.
|
||||
reply, err := session.AuthenticateUserRaw(ctx, int32(*serverHandle), *verifyUser, resolvedPassword)
|
||||
return writeCommandOutput(stdout, *jsonOutput, "authenticate-user", options, reply, err)
|
||||
}
|
||||
|
||||
func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -1295,7 +1367,7 @@ type protojsonMessage interface {
|
||||
}
|
||||
|
||||
func writeUsage(writer io.Writer) {
|
||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
|
||||
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured|write-secured-bulk|write-secured2-bulk|authenticate-user|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
|
||||
}
|
||||
|
||||
// batchEOR is the end-of-result sentinel emitted to stdout after every command
|
||||
|
||||
@@ -568,6 +568,36 @@ func TestRunAdviseSupervisoryRequiresSessionID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWriteSecuredRequiresSessionID pins the write-secured session-id guard so
|
||||
// it fails fast before dialing.
|
||||
func TestRunWriteSecuredRequiresSessionID(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{"write-secured", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "session-id is required") {
|
||||
t.Fatalf("write-secured without -session-id error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthenticateUserRequiresVerifyUser pins that authenticate-user fails fast
|
||||
// (before dialing) when the user is absent, and never echoes any credential in
|
||||
// the guard error.
|
||||
func TestRunAuthenticateUserRequiresVerifyUser(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{
|
||||
"authenticate-user",
|
||||
"-session-id", "s1",
|
||||
"-password", "hunter2-password",
|
||||
"-plaintext",
|
||||
"-api-key", "test",
|
||||
}, &stdout, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "verify-user is required") {
|
||||
t.Fatalf("authenticate-user without -verify-user error = %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "hunter2-password") {
|
||||
t.Fatalf("authenticate-user guard error leaked the credential: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
|
||||
// guard so a write-bulk with unequal item-handles / values counts fails fast
|
||||
// before any dial.
|
||||
|
||||
@@ -5,25 +5,46 @@ $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||
$protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
|
||||
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
||||
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
||||
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
||||
$goPluginPath = 'C:\Users\dohertj2\go\bin'
|
||||
|
||||
if (-not (Test-Path $protoc)) {
|
||||
throw "protoc was not found at $protoc. See docs/ToolchainLinks.md."
|
||||
}
|
||||
function Resolve-Tool {
|
||||
# Resolve a codegen tool from PATH first (portable), then the documented Windows fallbacks,
|
||||
# instead of the previous hard-coded per-machine paths. See docs/ToolchainLinks.md.
|
||||
param(
|
||||
[string[]]$Names,
|
||||
[string[]]$FallbackPaths
|
||||
)
|
||||
|
||||
foreach ($pluginName in @('protoc-gen-go.exe', 'protoc-gen-go-grpc.exe')) {
|
||||
$pluginPath = Join-Path $goPluginPath $pluginName
|
||||
if (-not (Test-Path $pluginPath)) {
|
||||
throw "$pluginName was not found at $pluginPath. See docs/ToolchainLinks.md."
|
||||
foreach ($name in $Names) {
|
||||
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $cmd) {
|
||||
return $cmd.Source
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($fallback in $FallbackPaths) {
|
||||
if ($fallback -and (Test-Path $fallback)) {
|
||||
return $fallback
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not find $($Names -join '/') on PATH. See docs/ToolchainLinks.md."
|
||||
}
|
||||
|
||||
$wingetProtoc = if ($env:LOCALAPPDATA) {
|
||||
Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
||||
} else { $null }
|
||||
$goBin = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE 'go\bin' } elseif ($env:HOME) { Join-Path $env:HOME 'go/bin' } else { $null }
|
||||
|
||||
$protoc = Resolve-Tool -Names @('protoc', 'protoc.exe') -FallbackPaths @($wingetProtoc)
|
||||
$protocGenGo = Resolve-Tool -Names @('protoc-gen-go', 'protoc-gen-go.exe') -FallbackPaths @((if ($goBin) { Join-Path $goBin 'protoc-gen-go.exe' }), (if ($goBin) { Join-Path $goBin 'protoc-gen-go' }))
|
||||
$protocGenGoGrpc = Resolve-Tool -Names @('protoc-gen-go-grpc', 'protoc-gen-go-grpc.exe') -FallbackPaths @((if ($goBin) { Join-Path $goBin 'protoc-gen-go-grpc.exe' }), (if ($goBin) { Join-Path $goBin 'protoc-gen-go-grpc' }))
|
||||
|
||||
# protoc discovers the plugins on PATH; prepend the directories the resolved plugins live in.
|
||||
$env:Path = (Split-Path $protocGenGo -Parent) + [System.IO.Path]::PathSeparator + (Split-Path $protocGenGoGrpc -Parent) + [System.IO.Path]::PathSeparator + $env:Path
|
||||
|
||||
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
|
||||
Get-ChildItem -Path $outputRoot -Filter '*.pb.go' -File | Remove-Item
|
||||
|
||||
$env:Path = "$goPluginPath;$env:Path"
|
||||
|
||||
& $protoc `
|
||||
--proto_path=$protoRoot `
|
||||
--go_out=$outputRoot `
|
||||
@@ -35,6 +56,10 @@ $env:Path = "$goPluginPath;$env:Path"
|
||||
mxaccess_worker.proto `
|
||||
galaxy_repository.proto
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "protoc (go) failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
& $protoc `
|
||||
--proto_path=$protoRoot `
|
||||
--go-grpc_out=$outputRoot `
|
||||
@@ -44,3 +69,6 @@ $env:Path = "$goPluginPath;$env:Path"
|
||||
mxaccess_gateway.proto `
|
||||
galaxy_repository.proto
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "protoc (go-grpc) failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
@@ -200,6 +200,63 @@ func TestEventsSlowConsumerYieldsErrSlowConsumerBeforeClose(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsSurfacesReplayGapSentinelAsTypedSignal(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
streamStarted: make(chan struct{}),
|
||||
streamReplayGap: &pb.ReplayGap{
|
||||
RequestedAfterSequence: 5,
|
||||
OldestAvailableSequence: 42,
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
events, err := session.EventsAfter(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("EventsAfter() error = %v", err)
|
||||
}
|
||||
<-fake.streamStarted
|
||||
|
||||
// First result must be the typed replay-gap signal, not a normal event.
|
||||
first := <-events
|
||||
if first.Err != nil {
|
||||
t.Fatalf("first result error = %v", first.Err)
|
||||
}
|
||||
if !first.IsReplayGap() {
|
||||
t.Fatalf("first result IsReplayGap() = false, want true")
|
||||
}
|
||||
if first.Event != nil {
|
||||
t.Fatalf("replay-gap result carried a non-nil Event %+v; want the sentinel to clear Event", first.Event)
|
||||
}
|
||||
if got := first.ReplayGap.GetRequestedAfterSequence(); got != 5 {
|
||||
t.Fatalf("ReplayGap.RequestedAfterSequence = %d, want 5", got)
|
||||
}
|
||||
if got := first.ReplayGap.GetOldestAvailableSequence(); got != 42 {
|
||||
t.Fatalf("ReplayGap.OldestAvailableSequence = %d, want 42", got)
|
||||
}
|
||||
|
||||
// Normal events after the sentinel are unaffected: Event set, ReplayGap nil.
|
||||
second := <-events
|
||||
if second.Err != nil {
|
||||
t.Fatalf("second result error = %v", second.Err)
|
||||
}
|
||||
if second.IsReplayGap() {
|
||||
t.Fatalf("second result IsReplayGap() = true, want false for a normal event")
|
||||
}
|
||||
if second.Event == nil {
|
||||
t.Fatal("second result Event = nil, want a normal event")
|
||||
}
|
||||
if got := second.Event.GetWorkerSequence(); got != 1 {
|
||||
t.Fatalf("normal event worker sequence = %d, want 1", got)
|
||||
}
|
||||
if second.Event.GetFamily() != pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE {
|
||||
t.Fatalf("normal event family = %s, want ON_DATA_CHANGE", second.Event.GetFamily())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
@@ -643,6 +700,7 @@ type fakeGatewayServer struct {
|
||||
streamStarted chan struct{}
|
||||
streamDone chan struct{}
|
||||
streamEventCount int
|
||||
streamReplayGap *pb.ReplayGap
|
||||
invokeReply *pb.MxCommandReply
|
||||
invokeRequest *pb.MxCommandRequest
|
||||
}
|
||||
@@ -691,6 +749,16 @@ func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grp
|
||||
if s.streamStarted != nil {
|
||||
close(s.streamStarted)
|
||||
}
|
||||
if s.streamReplayGap != nil {
|
||||
// Emit the reconnect-replay gap sentinel at the head of the resumed
|
||||
// stream: family UNSPECIFIED, body unset, only replay_gap populated.
|
||||
if err := stream.Send(&pb.MxEvent{
|
||||
SessionId: req.GetSessionId(),
|
||||
ReplayGap: s.streamReplayGap,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
eventCount := s.streamEventCount
|
||||
if eventCount == 0 {
|
||||
eventCount = 1
|
||||
|
||||
@@ -3,10 +3,68 @@ package mxgateway
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
)
|
||||
|
||||
// redactedSecretMarker is the placeholder substituted for credential material in
|
||||
// surfaced error text. It matches the marker used by RedactAPIKey so the client
|
||||
// presents one consistent redaction shape everywhere secrets could otherwise
|
||||
// leak.
|
||||
const redactedSecretMarker = "<redacted>"
|
||||
|
||||
// secretRedactingError wraps a typed error so any occurrence of a known
|
||||
// credential in the underlying message is replaced with redactedSecretMarker in
|
||||
// the surfaced text. Unwrap still exposes the wrapped error, so errors.As /
|
||||
// errors.Is continue to reach the underlying MxAccessError, CommandError, or
|
||||
// GatewayError. This is the seam that keeps AuthenticateUser credentials and
|
||||
// WriteSecured/WriteSecured2 payload strings out of any error a caller might log,
|
||||
// even if a gateway diagnostic message were to echo them back.
|
||||
type secretRedactingError struct {
|
||||
err error
|
||||
secrets []string
|
||||
}
|
||||
|
||||
// Error returns the wrapped error's message with every non-empty secret redacted.
|
||||
func (e *secretRedactingError) Error() string {
|
||||
if e == nil || e.err == nil {
|
||||
return ""
|
||||
}
|
||||
message := e.err.Error()
|
||||
for _, secret := range e.secrets {
|
||||
if secret != "" {
|
||||
message = strings.ReplaceAll(message, secret, redactedSecretMarker)
|
||||
}
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped error so typed-error inspection still works through
|
||||
// the redaction wrapper.
|
||||
func (e *secretRedactingError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.err
|
||||
}
|
||||
|
||||
// redactSecrets wraps err so any occurrence of a non-empty secret in the surfaced
|
||||
// message is redacted, while errors.As / errors.Is still reach the wrapped typed
|
||||
// error. It returns nil unchanged and skips wrapping when no non-empty secret is
|
||||
// supplied, so non-secret-bearing calls keep their original error verbatim.
|
||||
func redactSecrets(err error, secrets ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
for _, secret := range secrets {
|
||||
if secret != "" {
|
||||
return &secretRedactingError{err: err, secrets: secrets}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrSlowConsumer is the terminal error sent on the Events/EventsAfter
|
||||
// (cancel-when-full) path when the buffered results channel overflows because
|
||||
// the consumer fell behind. It is delivered as the final EventResult.Err before
|
||||
|
||||
@@ -27,14 +27,39 @@ const eventBufferSize = 16
|
||||
// non-blockingly on overflow, even when all data slots are full.
|
||||
const eventBufferReservedSlots = 1
|
||||
|
||||
// EventResult carries either the next ordered event or a terminal stream error.
|
||||
// EventResult carries the next ordered event, a replay-gap signal, or a
|
||||
// terminal stream error. Exactly one of Event, ReplayGap, or Err is set on any
|
||||
// delivered result.
|
||||
type EventResult struct {
|
||||
// Event is the next event from the stream when Err is nil.
|
||||
// Event is the next MXAccess event from the stream when both ReplayGap and
|
||||
// Err are nil.
|
||||
Event *MxEvent
|
||||
// ReplayGap, when non-nil, is the gateway's reconnect-replay gap sentinel: it
|
||||
// is delivered at the head of a resumed stream (one opened with a non-zero
|
||||
// after_worker_sequence via EventsAfter/SubscribeEventsAfter) when the
|
||||
// requested sequence predates the oldest event still retained in the replay
|
||||
// ring, so the events in between were lost.
|
||||
//
|
||||
// It is a non-terminal, observable signal — the stream continues with normal
|
||||
// events after it, and Event is nil on a gap result so a gap is never
|
||||
// mistaken for a normal MXAccess event. On seeing a gap the consumer must
|
||||
// discard any locally cached tag/alarm state and re-snapshot. To resume
|
||||
// cleanly (without provoking another gap), reconnect with EventsAfter using
|
||||
// afterWorkerSequence = ReplayGap.GetOldestAvailableSequence() - 1.
|
||||
//
|
||||
// The gateway sets ReplayGap only on StreamEvents results, never on a normal
|
||||
// (non-resumed) stream and never on DrainEvents.
|
||||
ReplayGap *ReplayGap
|
||||
// Err is the terminal stream error; when non-nil no further results follow.
|
||||
Err error
|
||||
}
|
||||
|
||||
// IsReplayGap reports whether this result carries the gateway's reconnect-replay
|
||||
// gap sentinel rather than a normal event or a terminal error.
|
||||
func (r EventResult) IsReplayGap() bool {
|
||||
return r.ReplayGap != nil
|
||||
}
|
||||
|
||||
// EventSubscription owns a running gateway event stream.
|
||||
type EventSubscription struct {
|
||||
results <-chan EventResult
|
||||
@@ -681,6 +706,277 @@ func (s *Session) Write2Raw(ctx context.Context, serverHandle, itemHandle int32,
|
||||
})
|
||||
}
|
||||
|
||||
// AdviseSupervisory invokes MXAccess AdviseSupervisory, advising an item on the
|
||||
// supervisory (as opposed to runtime) data path.
|
||||
func (s *Session) AdviseSupervisory(ctx context.Context, serverHandle, itemHandle int32) error {
|
||||
_, err := s.AdviseSupervisoryRaw(ctx, serverHandle, itemHandle)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdviseSupervisoryRaw invokes MXAccess AdviseSupervisory and returns the raw reply.
|
||||
func (s *Session) AdviseSupervisoryRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
Payload: &pb.MxCommand_AdviseSupervisory{
|
||||
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WriteSecured invokes MXAccess WriteSecured (secured single-item write).
|
||||
//
|
||||
// The value is credential-sensitive: callers must not log it, and any error this
|
||||
// call surfaces has the value's string content redacted (see WriteSecuredRaw).
|
||||
// MXAccess parity is preserved — WriteSecured legitimately fails when it is not
|
||||
// preceded by a matching AuthenticateUser + AdviseSupervisory or when the body
|
||||
// carries no value; that native failure is surfaced as-is, never pre-empted or
|
||||
// reordered by this client.
|
||||
func (s *Session) WriteSecured(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) error {
|
||||
_, err := s.WriteSecuredRaw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteSecuredRaw invokes MXAccess WriteSecured and returns the raw reply. Any
|
||||
// surfaced error is routed through the client's secret-redaction seam so a string
|
||||
// write value never appears in error text.
|
||||
func (s *Session) WriteSecuredRaw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) (*MxCommandReply, error) {
|
||||
if value == nil {
|
||||
return nil, errors.New("mxgateway: write-secured value is required")
|
||||
}
|
||||
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED,
|
||||
Payload: &pb.MxCommand_WriteSecured{
|
||||
WriteSecured: &pb.WriteSecuredCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
CurrentUserId: currentUserID,
|
||||
VerifierUserId: verifierUserID,
|
||||
Value: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
return reply, redactSecrets(err, stringSecrets(value)...)
|
||||
}
|
||||
|
||||
// WriteSecured2 invokes MXAccess WriteSecured2 (secured, timestamped single-item write).
|
||||
//
|
||||
// Like WriteSecured, the value is credential-sensitive and its string content is
|
||||
// scrubbed from any surfaced error. Native parity failures (missing prior
|
||||
// AuthenticateUser/AdviseSupervisory, value-less body) are surfaced unchanged.
|
||||
func (s *Session) WriteSecured2(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) error {
|
||||
_, err := s.WriteSecured2Raw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value, timestampValue)
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteSecured2Raw invokes MXAccess WriteSecured2 and returns the raw reply. Any
|
||||
// surfaced error is routed through the client's secret-redaction seam.
|
||||
func (s *Session) WriteSecured2Raw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) (*MxCommandReply, error) {
|
||||
if value == nil {
|
||||
return nil, errors.New("mxgateway: write-secured2 value is required")
|
||||
}
|
||||
if timestampValue == nil {
|
||||
return nil, errors.New("mxgateway: write-secured2 timestamp value is required")
|
||||
}
|
||||
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2,
|
||||
Payload: &pb.MxCommand_WriteSecured2{
|
||||
WriteSecured2: &pb.WriteSecured2Command{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
CurrentUserId: currentUserID,
|
||||
VerifierUserId: verifierUserID,
|
||||
Value: value,
|
||||
TimestampValue: timestampValue,
|
||||
},
|
||||
},
|
||||
})
|
||||
return reply, redactSecrets(err, stringSecrets(value)...)
|
||||
}
|
||||
|
||||
// AuthenticateUser invokes MXAccess AuthenticateUser and returns the resolved
|
||||
// MXAccess user id.
|
||||
//
|
||||
// verifyUserPassword is a raw MXAccess credential: this client never logs it and
|
||||
// scrubs it from any error it surfaces (see AuthenticateUserRaw). Callers must
|
||||
// likewise keep it out of their own logs, metrics, and diagnostics.
|
||||
func (s *Session) AuthenticateUser(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (int32, error) {
|
||||
reply, err := s.AuthenticateUserRaw(ctx, serverHandle, verifyUser, verifyUserPassword)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if reply.GetAuthenticateUser() != nil {
|
||||
return reply.GetAuthenticateUser().GetUserId(), nil
|
||||
}
|
||||
return reply.GetReturnValue().GetInt32Value(), nil
|
||||
}
|
||||
|
||||
// AuthenticateUserRaw invokes MXAccess AuthenticateUser and returns the raw
|
||||
// reply. The credential is scrubbed from any surfaced error via the client's
|
||||
// secret-redaction seam, so even a gateway diagnostic echoing the password back
|
||||
// cannot leak it through this call's error.
|
||||
func (s *Session) AuthenticateUserRaw(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (*MxCommandReply, error) {
|
||||
if verifyUser == "" {
|
||||
return nil, errors.New("mxgateway: verify user is required")
|
||||
}
|
||||
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
Payload: &pb.MxCommand_AuthenticateUser{
|
||||
AuthenticateUser: &pb.AuthenticateUserCommand{
|
||||
ServerHandle: serverHandle,
|
||||
VerifyUser: verifyUser,
|
||||
VerifyUserPassword: verifyUserPassword,
|
||||
},
|
||||
},
|
||||
})
|
||||
return reply, redactSecrets(err, verifyUserPassword)
|
||||
}
|
||||
|
||||
// ArchestrAUserToId invokes MXAccess ArchestrAUserToId, resolving an ArchestrA
|
||||
// user GUID to its MXAccess integer user id.
|
||||
func (s *Session) ArchestrAUserToId(ctx context.Context, serverHandle int32, userIDGuid string) (int32, error) {
|
||||
reply, err := s.ArchestrAUserToIdRaw(ctx, serverHandle, userIDGuid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if reply.GetArchestraUserToId() != nil {
|
||||
return reply.GetArchestraUserToId().GetUserId(), nil
|
||||
}
|
||||
return reply.GetReturnValue().GetInt32Value(), nil
|
||||
}
|
||||
|
||||
// ArchestrAUserToIdRaw invokes MXAccess ArchestrAUserToId and returns the raw reply.
|
||||
func (s *Session) ArchestrAUserToIdRaw(ctx context.Context, serverHandle int32, userIDGuid string) (*MxCommandReply, error) {
|
||||
if userIDGuid == "" {
|
||||
return nil, errors.New("mxgateway: user id GUID is required")
|
||||
}
|
||||
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID,
|
||||
Payload: &pb.MxCommand_ArchestraUserToId{
|
||||
ArchestraUserToId: &pb.ArchestrAUserToIdCommand{
|
||||
ServerHandle: serverHandle,
|
||||
UserIdGuid: userIDGuid,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AddBufferedItem invokes MXAccess AddBufferedItem and returns the item handle.
|
||||
func (s *Session) AddBufferedItem(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) {
|
||||
reply, err := s.AddBufferedItemRaw(ctx, serverHandle, itemDefinition, itemContext)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if reply.GetAddBufferedItem() != nil {
|
||||
return reply.GetAddBufferedItem().GetItemHandle(), nil
|
||||
}
|
||||
return reply.GetReturnValue().GetInt32Value(), nil
|
||||
}
|
||||
|
||||
// AddBufferedItemRaw invokes MXAccess AddBufferedItem and returns the raw reply.
|
||||
func (s *Session) AddBufferedItemRaw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) {
|
||||
if itemDefinition == "" {
|
||||
return nil, errors.New("mxgateway: item definition is required")
|
||||
}
|
||||
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_BUFFERED_ITEM,
|
||||
Payload: &pb.MxCommand_AddBufferedItem{
|
||||
AddBufferedItem: &pb.AddBufferedItemCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemDefinition: itemDefinition,
|
||||
ItemContext: itemContext,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SetBufferedUpdateInterval invokes MXAccess SetBufferedUpdateInterval.
|
||||
func (s *Session) SetBufferedUpdateInterval(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) error {
|
||||
_, err := s.SetBufferedUpdateIntervalRaw(ctx, serverHandle, updateIntervalMilliseconds)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetBufferedUpdateIntervalRaw invokes MXAccess SetBufferedUpdateInterval and returns the raw reply.
|
||||
func (s *Session) SetBufferedUpdateIntervalRaw(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) (*MxCommandReply, error) {
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL,
|
||||
Payload: &pb.MxCommand_SetBufferedUpdateInterval{
|
||||
SetBufferedUpdateInterval: &pb.SetBufferedUpdateIntervalCommand{
|
||||
ServerHandle: serverHandle,
|
||||
UpdateIntervalMilliseconds: updateIntervalMilliseconds,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Suspend invokes MXAccess Suspend and returns the resulting item status.
|
||||
func (s *Session) Suspend(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) {
|
||||
reply, err := s.SuspendRaw(ctx, serverHandle, itemHandle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetSuspend().GetStatus(), nil
|
||||
}
|
||||
|
||||
// SuspendRaw invokes MXAccess Suspend and returns the raw reply.
|
||||
func (s *Session) SuspendRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND,
|
||||
Payload: &pb.MxCommand_Suspend{
|
||||
Suspend: &pb.SuspendCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Activate invokes MXAccess Activate and returns the resulting item status.
|
||||
func (s *Session) Activate(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) {
|
||||
reply, err := s.ActivateRaw(ctx, serverHandle, itemHandle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetActivate().GetStatus(), nil
|
||||
}
|
||||
|
||||
// ActivateRaw invokes MXAccess Activate and returns the raw reply.
|
||||
func (s *Session) ActivateRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ACTIVATE,
|
||||
Payload: &pb.MxCommand_Activate{
|
||||
Activate: &pb.ActivateCommand{
|
||||
ServerHandle: serverHandle,
|
||||
ItemHandle: itemHandle,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// stringSecrets collects the non-empty string content of the given values so it
|
||||
// can be scrubbed from surfaced errors. Only string-typed MxValues carry
|
||||
// scrubable text; non-string values (numbers, timestamps, arrays) contribute
|
||||
// nothing.
|
||||
func stringSecrets(values ...*MxValue) []string {
|
||||
var secrets []string
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if stringValue, ok := value.GetKind().(*pb.MxValue_StringValue); ok && stringValue.StringValue != "" {
|
||||
secrets = append(secrets, stringValue.StringValue)
|
||||
}
|
||||
}
|
||||
return secrets
|
||||
}
|
||||
|
||||
// Events streams ordered session events until the server ends the stream,
|
||||
// context cancellation stops Recv, or a terminal error is sent.
|
||||
//
|
||||
@@ -736,7 +1032,15 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
|
||||
for {
|
||||
event, err := stream.Recv()
|
||||
if err == nil {
|
||||
if !sendEventResult(streamCtx, results, EventResult{Event: event}, cancelWhenResultBufferFull, cancel) {
|
||||
result := EventResult{Event: event}
|
||||
// The gateway marks a reconnect-replay gap with a sentinel MxEvent
|
||||
// carrying replay_gap (family UNSPECIFIED, body unset). Surface it
|
||||
// as a distinct typed signal rather than a normal event: clear
|
||||
// Event so consumers never process the sentinel as a data change.
|
||||
if gap := event.GetReplayGap(); gap != nil {
|
||||
result = EventResult{ReplayGap: gap}
|
||||
}
|
||||
if !sendEventResult(streamCtx, results, result, cancelWhenResultBufferFull, cancel) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
)
|
||||
|
||||
// TestAdviseSupervisoryBuildsCommandAndExposesRawReply pins that the promoted
|
||||
// typed helper emits an ADVISE_SUPERVISORY command carrying the server/item
|
||||
// handles and returns the raw reply.
|
||||
func TestAdviseSupervisoryBuildsCommandAndExposesRawReply(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
reply, err := session.AdviseSupervisoryRaw(context.Background(), 12, 34)
|
||||
if err != nil {
|
||||
t.Fatalf("AdviseSupervisoryRaw() error = %v", err)
|
||||
}
|
||||
if reply.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY {
|
||||
t.Fatalf("reply kind = %s", reply.GetKind())
|
||||
}
|
||||
cmd := fake.invokeRequest.GetCommand()
|
||||
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY {
|
||||
t.Fatalf("command kind = %s", cmd.GetKind())
|
||||
}
|
||||
if cmd.GetAdviseSupervisory().GetServerHandle() != 12 || cmd.GetAdviseSupervisory().GetItemHandle() != 34 {
|
||||
t.Fatalf("advise-supervisory handles = (%d, %d), want (12, 34)",
|
||||
cmd.GetAdviseSupervisory().GetServerHandle(), cmd.GetAdviseSupervisory().GetItemHandle())
|
||||
}
|
||||
|
||||
// The error-returning wrapper drops the reply but must not error on success.
|
||||
if err := session.AdviseSupervisory(context.Background(), 12, 34); err != nil {
|
||||
t.Fatalf("AdviseSupervisory() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate pins MXAccess
|
||||
// parity: WriteSecured issued without a preceding AuthenticateUser is rejected
|
||||
// natively, and the client surfaces that failure as a typed MxAccessError rather
|
||||
// than pre-validating or reordering. The write value is also kept out of the
|
||||
// surfaced error text.
|
||||
func TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate(t *testing.T) {
|
||||
hresult := int32(-2147024891) // E_ACCESSDENIED
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED,
|
||||
Hresult: &hresult,
|
||||
DiagnosticMessage: "WriteSecured requires a prior AuthenticateUser",
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
|
||||
Message: "MXAccess failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
securedValue := "supersecret-payload"
|
||||
err := session.WriteSecured(context.Background(), 12, 34, 0, 0, StringValue(securedValue))
|
||||
|
||||
var mxErr *MxAccessError
|
||||
if !errors.As(err, &mxErr) {
|
||||
t.Fatalf("error %T does not support errors.As(*MxAccessError); err = %v", err, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), securedValue) {
|
||||
t.Fatalf("surfaced error leaked the secured payload: %q", err.Error())
|
||||
}
|
||||
|
||||
// The command must carry the secured fields verbatim (parity: unaltered).
|
||||
cmd := fake.invokeRequest.GetCommand()
|
||||
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED {
|
||||
t.Fatalf("command kind = %s", cmd.GetKind())
|
||||
}
|
||||
if cmd.GetWriteSecured().GetValue().GetStringValue() != securedValue {
|
||||
t.Fatalf("wire value = %q, want %q", cmd.GetWriteSecured().GetValue().GetStringValue(), securedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteSecuredRejectsNilValueWithoutRoundTrip pins the client-side required
|
||||
// guard, which never echoes the (absent) value.
|
||||
func TestWriteSecuredRejectsNilValue(t *testing.T) {
|
||||
fake := &fakeGatewayServer{}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
if err := session.WriteSecured(context.Background(), 1, 2, 0, 0, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "write-secured value is required") {
|
||||
t.Fatalf("WriteSecured(nil value) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticateUserReturnsUserIDOnHappyPath pins the typed helper unpacking of
|
||||
// AuthenticateUserReply.user_id and that the credential is carried on the wire
|
||||
// but never surfaced.
|
||||
func TestAuthenticateUserReturnsUserIDOnHappyPath(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||
Payload: &pb.MxCommandReply_AuthenticateUser{
|
||||
AuthenticateUser: &pb.AuthenticateUserReply{UserId: 4242},
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
userID, err := session.AuthenticateUser(context.Background(), 12, "operator", "hunter2-password")
|
||||
if err != nil {
|
||||
t.Fatalf("AuthenticateUser() error = %v", err)
|
||||
}
|
||||
if userID != 4242 {
|
||||
t.Fatalf("user id = %d, want 4242", userID)
|
||||
}
|
||||
|
||||
cmd := fake.invokeRequest.GetCommand()
|
||||
if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER {
|
||||
t.Fatalf("command kind = %s", cmd.GetKind())
|
||||
}
|
||||
if cmd.GetAuthenticateUser().GetVerifyUser() != "operator" {
|
||||
t.Fatalf("verify user = %q, want operator", cmd.GetAuthenticateUser().GetVerifyUser())
|
||||
}
|
||||
if cmd.GetAuthenticateUser().GetVerifyUserPassword() != "hunter2-password" {
|
||||
t.Fatalf("password not carried to the wire verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticateUserScrubsCredentialFromSurfacedError proves the redaction
|
||||
// seam: even when the gateway diagnostic message echoes the credential back, the
|
||||
// surfaced error redacts it while the typed MxAccessError remains reachable via
|
||||
// errors.As.
|
||||
func TestAuthenticateUserScrubsCredentialFromSurfacedError(t *testing.T) {
|
||||
password := "hunter2-password"
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
DiagnosticMessage: "authentication failed for password " + password,
|
||||
// Message is intentionally left empty so MxAccessError.Error() falls
|
||||
// through to the diagnostic message — the free-text field that could
|
||||
// otherwise echo the credential back to a caller's log.
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
_, err := session.AuthenticateUser(context.Background(), 12, "operator", password)
|
||||
if err == nil {
|
||||
t.Fatal("AuthenticateUser() returned no error on native failure")
|
||||
}
|
||||
if strings.Contains(err.Error(), password) {
|
||||
t.Fatalf("surfaced error leaked the credential: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), redactedSecretMarker) {
|
||||
t.Fatalf("surfaced error missing redaction marker: %q", err.Error())
|
||||
}
|
||||
var mxErr *MxAccessError
|
||||
if !errors.As(err, &mxErr) {
|
||||
t.Fatalf("redaction wrapper broke errors.As(*MxAccessError); err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthenticateUserRequiresVerifyUser pins the client-side required guard.
|
||||
func TestAuthenticateUserRequiresVerifyUser(t *testing.T) {
|
||||
fake := &fakeGatewayServer{}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
if _, err := session.AuthenticateUser(context.Background(), 12, "", "pw"); err == nil ||
|
||||
!strings.Contains(err.Error(), "verify user is required") {
|
||||
t.Fatalf("AuthenticateUser(empty user) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuspendActivateReturnStatus covers two Phase 2 helpers that unpack an
|
||||
// MxStatusProxy from their dedicated reply arms.
|
||||
func TestSuspendActivateReturnStatus(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND,
|
||||
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
|
||||
Payload: &pb.MxCommandReply_Suspend{
|
||||
Suspend: &pb.SuspendReply{Status: &pb.MxStatusProxy{Success: 1, DiagnosticText: "suspended"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
status, err := session.Suspend(context.Background(), 12, 34)
|
||||
if err != nil {
|
||||
t.Fatalf("Suspend() error = %v", err)
|
||||
}
|
||||
if status.GetDiagnosticText() != "suspended" {
|
||||
t.Fatalf("status diagnostic = %q, want suspended", status.GetDiagnosticText())
|
||||
}
|
||||
if fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle() != 34 {
|
||||
t.Fatalf("suspend item handle = %d, want 34", fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle())
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,12 @@ type (
|
||||
MxCommand = pb.MxCommand
|
||||
// MxEvent is one ordered event delivered on a session event stream.
|
||||
MxEvent = pb.MxEvent
|
||||
// ReplayGap is the gateway sentinel payload signalling that a resumed event
|
||||
// stream skipped past the oldest event still retained in the replay ring.
|
||||
// RequestedAfterSequence is the after_worker_sequence the client resumed
|
||||
// from; OldestAvailableSequence is the oldest sequence the gateway can still
|
||||
// replay. See EventResult.ReplayGap for consumption guidance.
|
||||
ReplayGap = pb.ReplayGap
|
||||
// MxValue is the protobuf representation of an MXAccess value.
|
||||
MxValue = pb.MxValue
|
||||
// Value is an alias for MxValue retained for symmetry with other clients.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package mxgateway
|
||||
|
||||
const (
|
||||
// ClientVersion identifies this Go client scaffold before package releases
|
||||
// assign semantic versions.
|
||||
ClientVersion = "0.1.0-dev"
|
||||
// ClientVersion is the released semantic version of this Go client module.
|
||||
// Keep it in sync with the module tag applied by scripts/tag-go-module.ps1.
|
||||
ClientVersion = "0.1.2"
|
||||
|
||||
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
|
||||
// in the shared .NET contracts.
|
||||
|
||||
@@ -31,8 +31,8 @@ Alternative Maven layout is acceptable if the repo standardizes on Maven.
|
||||
|
||||
Target Java:
|
||||
|
||||
- Java 21 recommended.
|
||||
- The Gradle scaffold uses the Java 21 toolchain for compilation and tests.
|
||||
- Java 17 required (retargeted from 21 for Ignition 8.3 compatibility).
|
||||
- The Gradle scaffold uses the Java 17 toolchain for compilation and tests.
|
||||
|
||||
Expected dependencies:
|
||||
|
||||
|
||||
+74
-16
@@ -76,7 +76,40 @@ data-bearing MXAccess failure.
|
||||
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
|
||||
cancels the underlying gRPC stream. Canceling or timing out a Java client call
|
||||
only stops the client from waiting; it does not abort an in-flight MXAccess COM
|
||||
call on the worker STA.
|
||||
call on the worker STA. It is a **single-consumer** surface: drive
|
||||
`hasNext()`/`next()` (or `nextItem()`) from one thread only.
|
||||
|
||||
### Reconnect-replay gap signal
|
||||
|
||||
When you resume a stream with `streamEventsAfter(afterWorkerSequence)` and the
|
||||
requested cursor predates the oldest event the gateway still retains, the
|
||||
gateway emits a single **replay-gap sentinel** at the head of the stream: an
|
||||
`MxEvent` with its `replay_gap` field set, `family` unspecified, and the body
|
||||
oneof unset. It means "you missed events — discard cached state and
|
||||
re-snapshot": the events in the open interval `(requested_after_sequence,
|
||||
oldest_available_sequence)` were evicted and cannot be replayed. The gateway
|
||||
never synthesizes this signal from anything else, and the client never swallows
|
||||
it.
|
||||
|
||||
Use `nextItem()` to branch on it as a distinct typed item; `next()` still
|
||||
returns the sentinel as a plain `MxEvent` (test `event.hasReplayGap()`). After a
|
||||
gap, re-snapshot, then resume without another gap by requesting
|
||||
`oldestAvailableSequence - 1` as the next `afterWorkerSequence`:
|
||||
|
||||
```java
|
||||
try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) {
|
||||
while (events.hasNext()) {
|
||||
MxEventStreamItem item = events.nextItem();
|
||||
if (item.isReplayGap()) {
|
||||
long resumeFrom = item.replayGap().getOldestAvailableSequence() - 1;
|
||||
// discard cached per-item state, re-snapshot, then resume from resumeFrom
|
||||
continue;
|
||||
}
|
||||
MxEvent event = item.event();
|
||||
// normal event handling
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
|
||||
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
|
||||
@@ -89,30 +122,52 @@ ack target). Close the subscription to cancel the underlying gRPC stream.
|
||||
These are MXAccess parity behaviors that surprise new callers. The gateway
|
||||
forwards them unchanged — it does not paper over them.
|
||||
|
||||
### Typed single-item command helpers
|
||||
|
||||
`MxGatewaySession` exposes typed helpers for the parity-critical single-item
|
||||
commands, so you do not need to build raw `MxCommand` messages:
|
||||
|
||||
- `adviseSupervisory(serverHandle, itemHandle)` (and `adviseSupervisoryRaw`)
|
||||
- `writeSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value)`
|
||||
and `writeSecured2(..., timestampValue)` (plus `*Raw` variants)
|
||||
- `authenticateUser(serverHandle, verifyUser, verifyUserPassword)` → user id
|
||||
- `archestrAUserToId(serverHandle, userIdGuid)` → user id
|
||||
- `addBufferedItem(serverHandle, itemDefinition, itemContext)` → item handle
|
||||
- `setBufferedUpdateInterval(serverHandle, updateIntervalMs)`
|
||||
- `suspend(serverHandle, itemHandle)` / `activate(serverHandle, itemHandle)` →
|
||||
the reply's `MxStatusProxy`
|
||||
|
||||
All of them run the same MXAccess reply validation as the bulk helpers (protocol
|
||||
status plus HRESULT/`MxStatusProxy` check) via the shared `invoke` path, so an
|
||||
MXAccess COM-side failure surfaces as `MxAccessException`.
|
||||
|
||||
**Secret redaction.** Credentials passed to `authenticateUser` (and the
|
||||
credential-sensitive values passed to `writeSecured`/`writeSecured2`) travel
|
||||
only in the request. They never appear in logs, exception messages, or
|
||||
`toString()`: gateway status text is scrubbed through `MxGatewaySecrets`, and
|
||||
MXAccess failures carry only the reply (never the request). Do not log the
|
||||
credentials yourself.
|
||||
|
||||
### Attributing a write to a user without `authenticateUser`
|
||||
|
||||
MXAccess only stamps a plain `write`/`write2` with a Galaxy user id when the
|
||||
item carries an active *supervisory* advise. If you are **not** using the
|
||||
verified/secured path (`authenticateUser` → `writeSecured`/`writeSecured2`) but
|
||||
still need the write attributed to a user id, you must first advise the item
|
||||
supervisory and then pass that user id on the write. Without the supervisory
|
||||
advise the `userId` on a plain write is ignored.
|
||||
|
||||
The session exposes `advise`/`unAdvise` but not supervisory advise, so send it
|
||||
through the generic command channel:
|
||||
still need the write attributed to a user id, first advise the item supervisory
|
||||
and then pass that user id on the write. Without the supervisory advise the
|
||||
`userId` on a plain write is ignored.
|
||||
|
||||
```java
|
||||
session.invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
|
||||
.setAdviseSupervisory(AdviseSupervisoryCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle))
|
||||
.build());
|
||||
|
||||
session.adviseSupervisory(serverHandle, itemHandle);
|
||||
session.write(serverHandle, itemHandle, value, userId);
|
||||
```
|
||||
|
||||
The CLI exposes the same command as `advise-supervisory`, and `write` /
|
||||
**MXAccess parity:** `writeSecured` failing before a prior `authenticateUser` +
|
||||
`adviseSupervisory`, or before a value-bearing body, is correct behavior — the
|
||||
native failure is surfaced, not papered over.
|
||||
|
||||
The CLI exposes `advise-supervisory`, `write-secured`, and `authenticate-user`
|
||||
(credential via `--password` or `--password-env`, never echoed), and `write` /
|
||||
`write2` take `--user-id`.
|
||||
|
||||
### Array writes replace the whole array
|
||||
@@ -324,6 +379,9 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="register --endpoint localhost:5000 -
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="advise-supervisory --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --verify-user operator --password-env MXGATEWAY_VERIFY_PASSWORD --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="write-secured --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --current-user-id 100 --verifier-user-id 100 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
|
||||
@@ -351,7 +409,7 @@ Run the Java checks from `clients/java`:
|
||||
gradle test
|
||||
```
|
||||
|
||||
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC
|
||||
The build uses the Java 17 Gradle toolchain, compiles generated protobuf/gRPC
|
||||
code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures,
|
||||
in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
|
||||
|
||||
|
||||
+119
-7
@@ -155,6 +155,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
|
||||
commandLine.addSubcommand(
|
||||
"advise-supervisory", new AdviseSupervisoryCommand(clientFactory));
|
||||
commandLine.addSubcommand("write-secured", new WriteSecuredCommand(clientFactory));
|
||||
commandLine.addSubcommand("authenticate-user", new AuthenticateUserCommand(clientFactory));
|
||||
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
|
||||
commandLine.addSubcommand("read-bulk", new ReadBulkCommand(clientFactory));
|
||||
@@ -1074,6 +1076,106 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
}
|
||||
}
|
||||
|
||||
@Command(
|
||||
name = "write-secured",
|
||||
description = "Invokes MXAccess WriteSecured (verified single-item write).")
|
||||
static final class WriteSecuredCommand extends GatewayCommand {
|
||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||
String sessionId;
|
||||
|
||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
||||
int serverHandle;
|
||||
|
||||
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
|
||||
int itemHandle;
|
||||
|
||||
@Option(names = "--type", defaultValue = "string", description = "Value type.")
|
||||
String type;
|
||||
|
||||
@Option(names = "--value", required = true, description = "Value text (credential-sensitive; never echoed).")
|
||||
String value;
|
||||
|
||||
@Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
|
||||
int currentUserId;
|
||||
|
||||
@Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
|
||||
int verifierUserId;
|
||||
|
||||
WriteSecuredCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
// The secured write value is credential-sensitive: it goes only
|
||||
// into the request. The reply printed below never carries it.
|
||||
MxCommandReply reply = client.session(sessionId)
|
||||
.writeSecuredRaw(
|
||||
serverHandle, itemHandle, currentUserId, verifierUserId, parseValue(type, value));
|
||||
writeOutput("write-secured", common, json, reply, () -> reply.getKind().name());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(
|
||||
name = "authenticate-user",
|
||||
description = "Invokes MXAccess AuthenticateUser and prints the resolved user id.")
|
||||
static final class AuthenticateUserCommand extends GatewayCommand {
|
||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||
String sessionId;
|
||||
|
||||
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
|
||||
int serverHandle;
|
||||
|
||||
@Option(names = "--verify-user", required = true, description = "Galaxy user name to authenticate.")
|
||||
String verifyUser;
|
||||
|
||||
@Option(
|
||||
names = "--password",
|
||||
description = "Galaxy user password (credential; prefer --password-env). Never echoed.")
|
||||
String password = "";
|
||||
|
||||
@Option(
|
||||
names = "--password-env",
|
||||
defaultValue = "MXGATEWAY_VERIFY_PASSWORD",
|
||||
description = "Environment variable holding the password when --password is omitted.")
|
||||
String passwordEnv;
|
||||
|
||||
AuthenticateUserCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
// Resolve the credential from the flag or environment. It flows only
|
||||
// into the request; it is never written to output, logs, or errors.
|
||||
String resolvedPassword = password == null || password.isBlank()
|
||||
? System.getenv(passwordEnv)
|
||||
: password;
|
||||
if (resolvedPassword == null) {
|
||||
resolvedPassword = "";
|
||||
}
|
||||
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
|
||||
int userId = client.session(sessionId)
|
||||
.authenticateUser(serverHandle, verifyUser, resolvedPassword);
|
||||
PrintWriter out = common.spec.commandLine().getOut();
|
||||
if (json) {
|
||||
Map<String, Object> output = new LinkedHashMap<>();
|
||||
output.put("command", "authenticate-user");
|
||||
output.put("options", common.redactedJsonMap());
|
||||
output.put("verifyUser", verifyUser);
|
||||
output.put("userId", userId);
|
||||
out.println(jsonObject(output));
|
||||
} else {
|
||||
out.println(userId);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.")
|
||||
static final class SubscribeBulkCommand extends GatewayCommand {
|
||||
@Option(names = "--session-id", required = true, description = "Gateway session id.")
|
||||
@@ -1864,6 +1966,11 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
|
||||
MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId);
|
||||
|
||||
MxCommandReply writeSecuredRaw(
|
||||
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value);
|
||||
|
||||
int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword);
|
||||
|
||||
List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items);
|
||||
|
||||
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
|
||||
@@ -1982,13 +2089,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
|
||||
@Override
|
||||
public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
|
||||
return session.invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
|
||||
.setAdviseSupervisory(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AdviseSupervisoryCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle))
|
||||
.build());
|
||||
return session.adviseSupervisoryRaw(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1996,6 +2097,17 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
return session.writeRaw(serverHandle, itemHandle, value, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MxCommandReply writeSecuredRaw(
|
||||
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
|
||||
return session.writeSecuredRaw(serverHandle, itemHandle, currentUserId, verifierUserId, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
|
||||
return session.authenticateUser(serverHandle, verifyUser, verifyUserPassword);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
|
||||
return session.subscribeBulk(serverHandle, items);
|
||||
|
||||
+67
@@ -168,6 +168,49 @@ final class MxGatewayCliTests {
|
||||
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeSecuredCommandForwardsUserIdsAndValue() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"write-secured",
|
||||
"--session-id", "session-cli",
|
||||
"--server-handle", "12",
|
||||
"--item-handle", "34",
|
||||
"--type", "int32",
|
||||
"--value", "77",
|
||||
"--current-user-id", "100",
|
||||
"--verifier-user-id", "200",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals(77, factory.client.session.lastWriteSecuredValue.getInt32Value());
|
||||
assertEquals(100, factory.client.session.lastWriteSecuredCurrentUserId);
|
||||
assertEquals(200, factory.client.session.lastWriteSecuredVerifierUserId);
|
||||
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE_SECURED\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateUserCommandForwardsCredentialAndPrintsUserIdWithoutEchoingPassword() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
CliRun run = execute(
|
||||
factory,
|
||||
"authenticate-user",
|
||||
"--session-id", "session-cli",
|
||||
"--server-handle", "3",
|
||||
"--verify-user", "operator",
|
||||
"--password", "super-secret-pw",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
// The credential reaches the session (request) but never the output.
|
||||
assertEquals("operator", factory.client.session.lastAuthenticateUser);
|
||||
assertEquals("super-secret-pw", factory.client.session.lastAuthenticatePassword);
|
||||
assertTrue(run.output().contains("\"userId\":4242"), run.output());
|
||||
assertFalse(run.output().contains("super-secret-pw"), "password must never be echoed");
|
||||
assertFalse(run.errors().contains("super-secret-pw"), "password must never be echoed to stderr");
|
||||
}
|
||||
|
||||
// ---- ping subcommand (D4) ----
|
||||
|
||||
@Test
|
||||
@@ -1257,6 +1300,11 @@ final class MxGatewayCliTests {
|
||||
private boolean adviseCalled;
|
||||
private boolean adviseSupervisoryCalled;
|
||||
private MxValue lastWriteValue;
|
||||
private MxValue lastWriteSecuredValue;
|
||||
private int lastWriteSecuredCurrentUserId;
|
||||
private int lastWriteSecuredVerifierUserId;
|
||||
private String lastAuthenticateUser;
|
||||
private String lastAuthenticatePassword;
|
||||
private String lastPingMessage;
|
||||
private long lastReadBulkTimeoutMs;
|
||||
private List<String> lastReadBulkItems;
|
||||
@@ -1341,6 +1389,25 @@ final class MxGatewayCliTests {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MxCommandReply writeSecuredRaw(
|
||||
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
|
||||
lastWriteSecuredValue = value;
|
||||
lastWriteSecuredCurrentUserId = currentUserId;
|
||||
lastWriteSecuredVerifierUserId = verifierUserId;
|
||||
return MxCommandReply.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED)
|
||||
.setProtocolStatus(ok())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
|
||||
lastAuthenticateUser = verifyUser;
|
||||
lastAuthenticatePassword = verifyUserPassword;
|
||||
return 4242;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
|
||||
List<SubscribeResult> results = new ArrayList<>();
|
||||
|
||||
@@ -57,3 +57,35 @@ protobuf {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IPC-09 guard: the generated Java tree is tracked (generatedFilesBaseDir above points at the
|
||||
// committed src/main/generated). `gradle build` regenerates it, so an un-regenerated .proto change,
|
||||
// or a plugin/protobuf version bump, silently drifts the committed output. checkGeneratedClean
|
||||
// fails when the regenerated tree differs from what is committed.
|
||||
//
|
||||
// Caveat (repo memory project_java_generated_churn): the protobuf gradle plugin also rewrites
|
||||
// MxaccessGateway.java with a spurious protobuf-runtime-version delta on every build even when no
|
||||
// .proto changed. CI reverts that one file (git checkout) before invoking this task; locally, do the
|
||||
// same when you did not touch a .proto. See docs/GatewayTesting.md "Continuous Integration".
|
||||
tasks.register('checkGeneratedClean') {
|
||||
group = 'verification'
|
||||
description = 'Fails if the committed generated Java tree differs from a fresh regeneration.'
|
||||
dependsOn 'generateProto'
|
||||
doLast {
|
||||
def generatedDir = 'clients/java/src/main/generated'
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
def result = exec {
|
||||
workingDir = rootProject.projectDir.parentFile.parentFile
|
||||
commandLine 'git', 'status', '--porcelain', '--', generatedDir
|
||||
standardOutput = stdout
|
||||
ignoreExitValue = true
|
||||
}
|
||||
def dirty = stdout.toString().trim()
|
||||
if (!dirty.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"Generated Java is stale or churned:\n${dirty}\n" +
|
||||
"Regenerate and commit after a .proto change, or 'git checkout' the spurious " +
|
||||
"MxaccessGateway.java protobuf-version churn when no .proto changed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -21,6 +21,24 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
* stream cancels the underlying gRPC call. If the queue overflows the call is
|
||||
* cancelled and a follow-up call to {@link #next()} throws
|
||||
* {@link MxGatewayException}.
|
||||
*
|
||||
* <p><strong>Single consumer.</strong> This stream is not safe to drain from
|
||||
* more than one thread. Interleave {@link #hasNext()}/{@link #next()} (or
|
||||
* {@link #nextItem()}) from a single consumer only; concurrent drains race on
|
||||
* the internal cursor.
|
||||
*
|
||||
* <p><strong>Reconnect-replay gap.</strong> When the stream was resumed via
|
||||
* {@code StreamEventsRequest.after_worker_sequence} and the requested cursor
|
||||
* predates the oldest event the gateway still retains, the gateway emits a
|
||||
* single gap sentinel {@link MxEvent} at the head of the stream with its
|
||||
* {@code replay_gap} field set (family unspecified, body oneof unset). It is a
|
||||
* distinct, non-terminal signal, forwarded verbatim — never synthesized and
|
||||
* never swallowed. {@link #next()} returns it as a normal {@link MxEvent}
|
||||
* (callers can test {@code event.hasReplayGap()}); {@link #nextItem()} wraps it
|
||||
* in an {@link MxEventStreamItem} whose {@link MxEventStreamItem#isReplayGap()}
|
||||
* is {@code true}. On a gap the consumer must discard cached per-item state and
|
||||
* re-snapshot, then resume without a further gap by requesting
|
||||
* {@code oldest_available_sequence - 1} as the next {@code after_worker_sequence}.
|
||||
*/
|
||||
public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
private static final Object END = new Object();
|
||||
@@ -91,6 +109,24 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
|
||||
return (MxEvent) value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the next stream element as a typed {@link MxEventStreamItem} so a
|
||||
* consumer can branch on the reconnect-replay gap sentinel via
|
||||
* {@link MxEventStreamItem#isReplayGap()} without inspecting the raw event.
|
||||
*
|
||||
* <p>Equivalent to wrapping {@link #next()}; the gap sentinel is surfaced as
|
||||
* a distinct typed item rather than being swallowed, and normal events are
|
||||
* returned unchanged on {@link MxEventStreamItem#event()}. Do not mix
|
||||
* {@link #next()} and {@code nextItem()} on the same element — each call
|
||||
* advances the single shared cursor.
|
||||
*
|
||||
* @return the next stream item
|
||||
* @throws NoSuchElementException if the stream is exhausted
|
||||
*/
|
||||
public MxEventStreamItem nextItem() {
|
||||
return new MxEventStreamItem(next());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import java.util.Objects;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
|
||||
|
||||
/**
|
||||
* Typed view over a single item drained from an {@link MxEventStream}.
|
||||
*
|
||||
* <p>A {@code StreamEvents} stream resumed via {@code after_worker_sequence}
|
||||
* may begin with a gateway reconnect-replay <em>gap sentinel</em>: a single
|
||||
* {@link MxEvent} whose {@code replay_gap} field is set, whose
|
||||
* {@code family} is {@code MX_EVENT_FAMILY_UNSPECIFIED}, and whose {@code body}
|
||||
* oneof is unset. It means the requested resume cursor predates the oldest
|
||||
* event the gateway still retains, so the events in the open interval
|
||||
* {@code (requested_after_sequence, oldest_available_sequence)} were evicted and
|
||||
* cannot be replayed.
|
||||
*
|
||||
* <p>This wrapper lets a consumer branch on that sentinel without inspecting
|
||||
* the raw event: {@link #isReplayGap()} is {@code true} only for the sentinel,
|
||||
* and {@link #replayGap()} exposes the typed {@link ReplayGap} cursors. Normal
|
||||
* MXAccess events return {@code false} from {@link #isReplayGap()} and carry
|
||||
* their payload on {@link #event()} exactly as before.
|
||||
*
|
||||
* <p>The gateway never synthesizes an {@code OperationComplete} or any other
|
||||
* event from the gap; the sentinel is the gateway's own forwarded signal and is
|
||||
* surfaced here untouched. On receiving a gap the consumer must discard any
|
||||
* cached per-item state and re-snapshot, then resume without incurring another
|
||||
* gap by requesting {@code oldest_available_sequence - 1} as the new
|
||||
* {@code after_worker_sequence} cursor.
|
||||
*
|
||||
* @param event the raw event; for a gap sentinel this is the sentinel event
|
||||
* itself (family unspecified, body unset, {@code replay_gap} set)
|
||||
*/
|
||||
public record MxEventStreamItem(MxEvent event) {
|
||||
/**
|
||||
* Creates a stream-item view over the supplied event.
|
||||
*
|
||||
* @param event the raw event; must not be {@code null}
|
||||
*/
|
||||
public MxEventStreamItem {
|
||||
Objects.requireNonNull(event, "event");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this item is the reconnect-replay gap sentinel rather
|
||||
* than a normal MXAccess event. Detected via the generated
|
||||
* {@code MxEvent.hasReplayGap()} presence flag.
|
||||
*
|
||||
* @return {@code true} for the gap sentinel, {@code false} for normal events
|
||||
*/
|
||||
public boolean isReplayGap() {
|
||||
return event.hasReplayGap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the typed reconnect-replay gap descriptor when this item is the
|
||||
* gap sentinel.
|
||||
*
|
||||
* @return the {@link ReplayGap} carrying {@code requested_after_sequence}
|
||||
* and {@code oldest_available_sequence}
|
||||
* @throws IllegalStateException if this item is a normal event (check
|
||||
* {@link #isReplayGap()} first)
|
||||
*/
|
||||
public ReplayGap replayGap() {
|
||||
if (!event.hasReplayGap()) {
|
||||
throw new IllegalStateException("stream item is not a replay-gap sentinel");
|
||||
}
|
||||
return event.getReplayGap();
|
||||
}
|
||||
}
|
||||
+289
@@ -7,11 +7,16 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ActivateCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddBufferedItemCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItem2Command;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseSupervisoryCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ArchestrAUserToIdCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AuthenticateUserCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
@@ -23,15 +28,18 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxSparseArray;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SetBufferedUpdateIntervalCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SuspendCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
|
||||
@@ -42,6 +50,8 @@ import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2Command;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand;
|
||||
@@ -697,6 +707,285 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code AdviseSupervisory} so the item accepts
|
||||
* supervisory-attributed writes. Required before a plain {@link #write}
|
||||
* can stamp a Galaxy user id without the verified/secured path.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemHandle the {@code ItemHandle} to advise supervisory
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public void adviseSupervisory(int serverHandle, int itemHandle) {
|
||||
adviseSupervisoryRaw(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code AdviseSupervisory} and returns the raw reply.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemHandle the {@code ItemHandle} to advise supervisory
|
||||
* @return the raw command reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
|
||||
.setAdviseSupervisory(AdviseSupervisoryCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle))
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code WriteSecured} — a verified write gated by a
|
||||
* previously authenticated Galaxy user.
|
||||
*
|
||||
* <p><strong>MXAccess parity:</strong> the native call fails if the caller
|
||||
* has not first {@link #authenticateUser authenticated} and
|
||||
* {@link #adviseSupervisory advised supervisory}, or if the value body is
|
||||
* absent; that native failure is surfaced (as {@link MxAccessException}),
|
||||
* not papered over.
|
||||
*
|
||||
* <p><strong>Secret handling:</strong> {@code value} may carry a
|
||||
* credential-sensitive payload. It is placed only in the request and never
|
||||
* appears in any surfaced error (exceptions carry the reply, not the
|
||||
* request), so callers must likewise avoid logging it.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemHandle the {@code ItemHandle} to write
|
||||
* @param currentUserId the authenticated (current) Galaxy user id
|
||||
* @param verifierUserId the verifier Galaxy user id (second-signature); use
|
||||
* the same value as {@code currentUserId} when no separate verifier applies
|
||||
* @param value the credential-sensitive value to write
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public void writeSecured(
|
||||
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
|
||||
writeSecuredRaw(serverHandle, itemHandle, currentUserId, verifierUserId, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code WriteSecured} and returns the raw reply.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemHandle the {@code ItemHandle} to write
|
||||
* @param currentUserId the authenticated (current) Galaxy user id
|
||||
* @param verifierUserId the verifier Galaxy user id
|
||||
* @param value the credential-sensitive value to write
|
||||
* @return the raw command reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public MxCommandReply writeSecuredRaw(
|
||||
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED)
|
||||
.setWriteSecured(WriteSecuredCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle)
|
||||
.setCurrentUserId(currentUserId)
|
||||
.setVerifierUserId(verifierUserId)
|
||||
.setValue(value))
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code WriteSecured2} — a verified, explicitly
|
||||
* timestamped write. Parity and secret-handling notes mirror
|
||||
* {@link #writeSecured}.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemHandle the {@code ItemHandle} to write
|
||||
* @param currentUserId the authenticated (current) Galaxy user id
|
||||
* @param verifierUserId the verifier Galaxy user id
|
||||
* @param value the credential-sensitive value to write
|
||||
* @param timestampValue the timestamp value to associate with the write
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public void writeSecured2(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
MxValue value,
|
||||
MxValue timestampValue) {
|
||||
writeSecured2Raw(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestampValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code WriteSecured2} and returns the raw reply.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemHandle the {@code ItemHandle} to write
|
||||
* @param currentUserId the authenticated (current) Galaxy user id
|
||||
* @param verifierUserId the verifier Galaxy user id
|
||||
* @param value the credential-sensitive value to write
|
||||
* @param timestampValue the timestamp value to associate with the write
|
||||
* @return the raw command reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public MxCommandReply writeSecured2Raw(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
MxValue value,
|
||||
MxValue timestampValue) {
|
||||
return invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2)
|
||||
.setWriteSecured2(WriteSecured2Command.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle)
|
||||
.setCurrentUserId(currentUserId)
|
||||
.setVerifierUserId(verifierUserId)
|
||||
.setValue(value)
|
||||
.setTimestampValue(timestampValue))
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code AuthenticateUser} and returns the resolved
|
||||
* Galaxy user id used to attribute subsequent secured writes.
|
||||
*
|
||||
* <p><strong>Secret handling:</strong> {@code verifyUserPassword} is a raw
|
||||
* MXAccess credential. It is placed only in the request and never appears in
|
||||
* any surfaced error, log, or {@code toString()} (exceptions carry the
|
||||
* reply, not the request). Callers must not log it either.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} for the session
|
||||
* @param verifyUser the Galaxy user name to authenticate
|
||||
* @param verifyUserPassword the user's credential; never logged or surfaced
|
||||
* @return the authenticated Galaxy user id
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess rejects the credential
|
||||
*/
|
||||
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_AUTHENTICATE_USER)
|
||||
.setAuthenticateUser(AuthenticateUserCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setVerifyUser(verifyUser)
|
||||
.setVerifyUserPassword(verifyUserPassword))
|
||||
.build());
|
||||
if (reply.hasAuthenticateUser()) {
|
||||
return reply.getAuthenticateUser().getUserId();
|
||||
}
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code ArchestrAUserToId}, resolving a Galaxy user GUID
|
||||
* to its integer user id.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} for the session
|
||||
* @param userIdGuid the Galaxy user GUID string
|
||||
* @return the resolved Galaxy user id
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public int archestrAUserToId(int serverHandle, String userIdGuid) {
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID)
|
||||
.setArchestraUserToId(ArchestrAUserToIdCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setUserIdGuid(userIdGuid))
|
||||
.build());
|
||||
if (reply.hasArchestraUserToId()) {
|
||||
return reply.getArchestraUserToId().getUserId();
|
||||
}
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code AddBufferedItem} and returns the new item handle.
|
||||
* The buffered add family delivers coalesced {@code OnBufferedDataChange}
|
||||
* updates on the interval set by {@link #setBufferedUpdateInterval}.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemDefinition the MXAccess item definition (tag reference)
|
||||
* @param itemContext the MXAccess item context (e.g. galaxy/object scope)
|
||||
* @return the {@code ItemHandle} assigned by MXAccess
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public int addBufferedItem(int serverHandle, String itemDefinition, String itemContext) {
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_BUFFERED_ITEM)
|
||||
.setAddBufferedItem(AddBufferedItemCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemDefinition(itemDefinition)
|
||||
.setItemContext(itemContext))
|
||||
.build());
|
||||
if (reply.hasAddBufferedItem()) {
|
||||
return reply.getAddBufferedItem().getItemHandle();
|
||||
}
|
||||
return reply.getReturnValue().getInt32Value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code SetBufferedUpdateInterval}, controlling how often
|
||||
* the worker coalesces buffered updates for the given server handle.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} to configure
|
||||
* @param updateIntervalMilliseconds the buffered update interval in milliseconds
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public void setBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) {
|
||||
invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL)
|
||||
.setSetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setUpdateIntervalMilliseconds(updateIntervalMilliseconds))
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code Suspend} on an item and returns the reply's
|
||||
* {@link MxStatusProxy}.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemHandle the {@code ItemHandle} to suspend
|
||||
* @return the {@code MxStatusProxy} carried by the suspend reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public MxStatusProxy suspend(int serverHandle, int itemHandle) {
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_SUSPEND)
|
||||
.setSuspend(SuspendCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle))
|
||||
.build());
|
||||
return reply.getSuspend().getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code Activate} on a previously suspended item and
|
||||
* returns the reply's {@link MxStatusProxy}.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the item
|
||||
* @param itemHandle the {@code ItemHandle} to activate
|
||||
* @return the {@code MxStatusProxy} carried by the activate reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
* @throws MxAccessException when MXAccess reports a COM-side failure
|
||||
*/
|
||||
public MxStatusProxy activate(int serverHandle, int itemHandle) {
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_ACTIVATE)
|
||||
.setActivate(ActivateCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.setItemHandle(itemHandle))
|
||||
.build());
|
||||
return reply.getActivate().getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to gateway events for this session starting from the
|
||||
* beginning of the worker event log.
|
||||
|
||||
+230
@@ -1,6 +1,7 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -30,6 +31,7 @@ import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AuthenticateUserReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
@@ -39,6 +41,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxEventFamily;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
@@ -47,6 +50,7 @@ import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||
@@ -509,6 +513,232 @@ final class MxGatewayClientSessionTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamEventsSurfacesReplayGapSentinelAsTypedItem() throws Exception {
|
||||
// CLI-15: a resumed stream whose cursor predates the retained window
|
||||
// begins with the gateway's replay-gap sentinel. It must surface as a
|
||||
// distinct typed item, and normal events must be unaffected.
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
|
||||
responseObserver.onNext(MxEvent.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setReplayGap(ReplayGap.newBuilder()
|
||||
.setRequestedAfterSequence(5)
|
||||
.setOldestAvailableSequence(9))
|
||||
.build());
|
||||
responseObserver.onNext(MxEvent.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setFamily(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE)
|
||||
.setWorkerSequence(9)
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5));
|
||||
MxEventStream events =
|
||||
MxGatewaySession.forSessionId(client, "resume-session").streamEventsAfter(5)) {
|
||||
assertTrue(events.hasNext());
|
||||
MxEventStreamItem gap = events.nextItem();
|
||||
assertTrue(gap.isReplayGap(), "head sentinel must classify as a replay gap");
|
||||
assertEquals(5, gap.replayGap().getRequestedAfterSequence());
|
||||
assertEquals(9, gap.replayGap().getOldestAvailableSequence());
|
||||
// Sentinel carries no MXAccess payload.
|
||||
assertEquals(MxEventFamily.MX_EVENT_FAMILY_UNSPECIFIED, gap.event().getFamily());
|
||||
|
||||
assertTrue(events.hasNext());
|
||||
MxEventStreamItem normal = events.nextItem();
|
||||
assertFalse(normal.isReplayGap(), "normal event must not classify as a replay gap");
|
||||
assertEquals(9, normal.event().getWorkerSequence());
|
||||
assertEquals(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE, normal.event().getFamily());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void adviseSupervisoryBuildsSupervisoryCommand() throws Exception {
|
||||
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
|
||||
TestGatewayService service = okInvokeService(commandRequest);
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "advise-super-session");
|
||||
|
||||
session.adviseSupervisory(12, 34);
|
||||
|
||||
assertEquals(
|
||||
MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
commandRequest.get().getCommand().getKind());
|
||||
assertEquals(12, commandRequest.get().getCommand().getAdviseSupervisory().getServerHandle());
|
||||
assertEquals(34, commandRequest.get().getCommand().getAdviseSupervisory().getItemHandle());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeSecuredSurfacesNativeMxAccessFailure() throws Exception {
|
||||
// CLI-04 parity: WriteSecured before authenticate/advise fails natively;
|
||||
// the client surfaces the failure rather than papering over it.
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
|
||||
.setMessage("WriteSecured rejected: user not authenticated."))
|
||||
.setHresult(-2147220992)
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "secured-session");
|
||||
|
||||
MxAccessException error = assertThrows(
|
||||
MxAccessException.class,
|
||||
() -> session.writeSecured(1, 2, 100, 100, MxValues.stringValue("secret-payload")));
|
||||
|
||||
assertEquals(-2147220992, error.reply().getHresult());
|
||||
// The credential-bearing value lives only in the request, so it must
|
||||
// not appear in any surfaced error text.
|
||||
assertFalse(String.valueOf(error.getMessage()).contains("secret-payload"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeSecuredScriptedSuccessSendsSecuredCommand() throws Exception {
|
||||
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
|
||||
TestGatewayService service = okInvokeService(commandRequest);
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "secured-ok-session");
|
||||
|
||||
session.writeSecured(7, 8, 100, 200, MxValues.int32Value(42));
|
||||
|
||||
var command = commandRequest.get().getCommand();
|
||||
assertEquals(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED, command.getKind());
|
||||
assertEquals(7, command.getWriteSecured().getServerHandle());
|
||||
assertEquals(8, command.getWriteSecured().getItemHandle());
|
||||
assertEquals(100, command.getWriteSecured().getCurrentUserId());
|
||||
assertEquals(200, command.getWriteSecured().getVerifierUserId());
|
||||
assertEquals(42, command.getWriteSecured().getValue().getInt32Value());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateUserReturnsUserIdAndForwardsCredential() throws Exception {
|
||||
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
commandRequest.set(request);
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.setAuthenticateUser(AuthenticateUserReply.newBuilder().setUserId(4242))
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "auth-session");
|
||||
|
||||
int userId = session.authenticateUser(3, "operator", "super-secret-pw");
|
||||
|
||||
assertEquals(4242, userId);
|
||||
// The credential is forwarded in the request only.
|
||||
assertEquals(
|
||||
"super-secret-pw",
|
||||
commandRequest.get().getCommand().getAuthenticateUser().getVerifyUserPassword());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateUserFailureKeepsCredentialOutOfSurfacedError() throws Exception {
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
|
||||
.setMessage("AuthenticateUser rejected the credential."))
|
||||
.setHresult(-2147220992)
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "auth-fail-session");
|
||||
|
||||
MxAccessException error = assertThrows(
|
||||
MxAccessException.class,
|
||||
() -> session.authenticateUser(3, "operator", "super-secret-pw"));
|
||||
|
||||
// The password must never reach the exception message or toString().
|
||||
assertFalse(String.valueOf(error.getMessage()).contains("super-secret-pw"));
|
||||
assertFalse(error.toString().contains("super-secret-pw"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void suspendAndActivateReturnStatusProxy() throws Exception {
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
MxCommandReply.Builder reply = MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok());
|
||||
if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_SUSPEND) {
|
||||
reply.setSuspend(mxaccess_gateway.v1.MxaccessGateway.SuspendReply.newBuilder()
|
||||
.setStatus(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
|
||||
.setSuccess(1)));
|
||||
} else if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_ACTIVATE) {
|
||||
reply.setActivate(mxaccess_gateway.v1.MxaccessGateway.ActivateReply.newBuilder()
|
||||
.setStatus(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
|
||||
.setSuccess(1)));
|
||||
}
|
||||
responseObserver.onNext(reply.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "suspend-session");
|
||||
|
||||
assertTrue(MxStatuses.succeeded(session.suspend(1, 2)));
|
||||
assertTrue(MxStatuses.succeeded(session.activate(1, 2)));
|
||||
}
|
||||
}
|
||||
|
||||
private static TestGatewayService okInvokeService(AtomicReference<MxCommandRequest> commandRequest) {
|
||||
return new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
commandRequest.set(request);
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ProtocolStatus ok() {
|
||||
return ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||
|
||||
Binary file not shown.
+61
-11
@@ -105,6 +105,40 @@ terminate the stream.
|
||||
Canceling a Python task cancels the client-side gRPC call or stream wait. It
|
||||
does not abort an in-flight MXAccess COM call inside the worker process.
|
||||
|
||||
### Event streaming and reconnect gaps
|
||||
|
||||
`Session.stream_events()` yields an async iterator whose items are either a
|
||||
normal `MxEvent` or a `ReplayGap`. Track the `worker_sequence` of the last event
|
||||
you processed and pass it back as `after_worker_sequence` to resume after a
|
||||
disconnect:
|
||||
|
||||
```python
|
||||
from zb_mom_ww_mxgateway import ReplayGap
|
||||
|
||||
cursor = 0
|
||||
async for item in session.stream_events(after_worker_sequence=cursor):
|
||||
if isinstance(item, ReplayGap):
|
||||
# The gateway dropped events between item.requested_after_sequence and
|
||||
# item.oldest_available_sequence — they are gone from the replay ring.
|
||||
# Discard local tag/alarm state and re-snapshot (e.g. read_bulk /
|
||||
# query_active_alarms), then resume without another gap:
|
||||
cursor = item.resume_after_worker_sequence # oldest_available_sequence - 1
|
||||
continue
|
||||
# Normal MXAccess event.
|
||||
cursor = item.worker_sequence
|
||||
handle(item)
|
||||
```
|
||||
|
||||
`ReplayGap` is the gateway's reconnect-replay gap sentinel made typed and
|
||||
observable. It is delivered only at the head of a stream resumed with a non-zero
|
||||
`after_worker_sequence` when the requested cursor predates the oldest retained
|
||||
event. It is a **non-terminal** signal — the stream continues with normal events
|
||||
after it — and it is never yielded as an `MxEvent`, so it can never be mistaken
|
||||
for a real MXAccess event. The client does not synthesize or swallow it; the
|
||||
gateway only sets it on `StreamEvents` results (never on a fresh stream or a
|
||||
`DrainEvents` reply). `GatewayClient.stream_events_raw` remains the raw protobuf
|
||||
stream (the sentinel arrives there as an `MxEvent` with `replay_gap` set).
|
||||
|
||||
## Write Semantics And Common Pitfalls
|
||||
|
||||
These are MXAccess parity behaviors that surprise new callers. The gateway
|
||||
@@ -119,19 +153,11 @@ but still need the write attributed to a user id, you must first advise the
|
||||
item supervisory and then pass that user id on the write. Without the
|
||||
supervisory advise the `user_id` on a plain write is ignored.
|
||||
|
||||
The session exposes `advise`/`unadvise` but not supervisory advise, so send it
|
||||
through the generic command channel:
|
||||
The session exposes a typed `advise_supervisory` helper alongside
|
||||
`advise`/`unadvise`:
|
||||
|
||||
```python
|
||||
await session.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
advise_supervisory=pb.AdviseSupervisoryCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
),
|
||||
)
|
||||
)
|
||||
await session.advise_supervisory(server_handle, item_handle)
|
||||
|
||||
await session.write(server_handle, item_handle, value, user_id=user_id)
|
||||
```
|
||||
@@ -139,6 +165,30 @@ await session.write(server_handle, item_handle, value, user_id=user_id)
|
||||
The CLI exposes the same command as `advise-supervisory`, and `write` /
|
||||
`write2` take `--user-id`.
|
||||
|
||||
For the verified/secured path, `authenticate_user`, `write_secured`,
|
||||
`write_secured2`, and `archestra_user_to_id` are typed session helpers too. The
|
||||
credential passed to `authenticate_user` and the values written by
|
||||
`write_secured`/`write_secured2` are treated as secrets: they are never logged
|
||||
and are scrubbed from any surfaced error message. MXAccess parity is preserved —
|
||||
a `write_secured` that fails because no prior `authenticate_user` +
|
||||
`advise_supervisory` established a supervisory context surfaces the native
|
||||
failure as `MxAccessError` rather than being silently "fixed":
|
||||
|
||||
```python
|
||||
user_id = await session.authenticate_user(server_handle, "operator", password)
|
||||
await session.advise_supervisory(server_handle, item_handle)
|
||||
await session.write_secured(
|
||||
server_handle,
|
||||
item_handle,
|
||||
value,
|
||||
current_user_id=user_id,
|
||||
verifier_user_id=user_id,
|
||||
)
|
||||
```
|
||||
|
||||
The CLI mirrors these as `authenticate-user` (credential via `--password` or,
|
||||
preferably, `--password-env`) and `write-secured`.
|
||||
|
||||
### Array writes replace the whole array
|
||||
|
||||
A write to an array attribute **replaces the entire array**; it is not an
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Pinned generator baseline. The committed Python bindings are stamped with
|
||||
# GRPC_GENERATED_VERSION = 1.80.0 and "Protobuf Python Version: 6.31.1". A newer grpcio-tools
|
||||
# stamps a higher GRPC_GENERATED_VERSION than the pinned grpcio runtime (pyproject.toml:
|
||||
# grpcio>=1.80,<2), which makes _pb2_grpc import raise at runtime and breaks pytest. Regeneration
|
||||
# MUST use this exact grpcio-tools version. See docs/ClientProtoGeneration.md and the repo memory
|
||||
# project_python_client_regen_pin.
|
||||
$PinnedGrpcioToolsVersion = '1.80.0'
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||
$protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
|
||||
$outputRoot = Join-Path $PSScriptRoot 'src\zb_mom_ww_mxgateway\generated'
|
||||
$python = 'C:\Users\dohertj2\AppData\Local\Programs\Python\Python312\python.exe'
|
||||
|
||||
if (-not (Test-Path $python)) {
|
||||
throw "Python was not found at $python. See docs/ToolchainLinks.md."
|
||||
function Resolve-Python {
|
||||
# Prefer python on PATH (python on Windows, python3 on Linux/macOS), then the documented
|
||||
# Windows install location. Avoids the previous hard-coded per-machine path.
|
||||
foreach ($name in @('python', 'python3', 'python.exe')) {
|
||||
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $cmd) {
|
||||
return $cmd.Source
|
||||
}
|
||||
}
|
||||
|
||||
if ($env:LOCALAPPDATA) {
|
||||
$documentedPath = Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\python.exe'
|
||||
if (Test-Path $documentedPath) {
|
||||
return $documentedPath
|
||||
}
|
||||
}
|
||||
|
||||
throw 'Could not find python on PATH. See docs/ToolchainLinks.md.'
|
||||
}
|
||||
|
||||
function Assert-GrpcioToolsVersion {
|
||||
param([string]$Python)
|
||||
|
||||
$version = (& $Python -c 'import grpc_tools; from importlib.metadata import version; print(version("grpcio-tools"))').Trim()
|
||||
if ($version -ne $PinnedGrpcioToolsVersion) {
|
||||
throw "grpcio-tools $version is installed, but regeneration is pinned to $PinnedGrpcioToolsVersion. " +
|
||||
"Install the pin (python -m pip install 'grpcio-tools==$PinnedGrpcioToolsVersion') before regenerating, " +
|
||||
"or the generated bindings will stamp a GRPC_GENERATED_VERSION the pinned grpcio runtime rejects."
|
||||
}
|
||||
}
|
||||
|
||||
$python = Resolve-Python
|
||||
Assert-GrpcioToolsVersion -Python $python
|
||||
|
||||
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
|
||||
Get-ChildItem -Path (Join-Path $outputRoot '*_pb2.py') -File | Remove-Item
|
||||
Get-ChildItem -Path (Join-Path $outputRoot '*_pb2_grpc.py') -File | Remove-Item
|
||||
@@ -21,3 +58,7 @@ Get-ChildItem -Path (Join-Path $outputRoot '*_pb2_grpc.py') -File | Remove-Item
|
||||
mxaccess_gateway.proto `
|
||||
mxaccess_worker.proto `
|
||||
galaxy_repository.proto
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "grpc_tools.protoc failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
# Runtime range for local dev, but REGENERATION is pinned to grpcio-tools==1.80.0
|
||||
# (protobuf 6.31.1): a newer version stamps a GRPC_GENERATED_VERSION the pinned grpcio
|
||||
# runtime rejects and breaks pytest. generate-proto.ps1 asserts the exact pin.
|
||||
"grpcio-tools>=1.80,<2",
|
||||
"pytest>=9,<10",
|
||||
"pytest-asyncio>=1.3,<2",
|
||||
|
||||
@@ -9,6 +9,7 @@ from .generated.galaxy_repository_pb2 import (
|
||||
GalaxyObject,
|
||||
WatchDeployEventsRequest,
|
||||
)
|
||||
from .events import ReplayGap
|
||||
from .errors import (
|
||||
MxAccessError,
|
||||
MxGatewayAuthenticationError,
|
||||
@@ -43,6 +44,7 @@ __all__ = [
|
||||
"MxGatewayTransportError",
|
||||
"MxGatewayWorkerError",
|
||||
"MxValueView",
|
||||
"ReplayGap",
|
||||
"Session",
|
||||
"WatchDeployEventsRequest",
|
||||
"__version__",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Typed event-stream signals for the MXAccess Gateway Python client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayGap:
|
||||
"""Reconnect-replay gap signal surfaced on a resumed event stream.
|
||||
|
||||
The gateway emits this at the head of a stream resumed with
|
||||
:meth:`Session.stream_events`'s ``after_worker_sequence`` cursor when the
|
||||
requested sequence predates the oldest event still retained in the gateway's
|
||||
replay ring. That means the events between ``requested_after_sequence`` and
|
||||
``oldest_available_sequence`` were dropped from the ring and can no longer be
|
||||
replayed — the client has an unrecoverable hole in its event history.
|
||||
|
||||
``ReplayGap`` is a *non-terminal, observable* signal: the stream keeps
|
||||
delivering normal :class:`~zb_mom_ww_mxgateway.generated.mxaccess_gateway_pb2.MxEvent`
|
||||
values after it. :meth:`Session.stream_events` yields it as a distinct type
|
||||
(never as an ``MxEvent``) so a consumer can branch on
|
||||
``isinstance(item, ReplayGap)`` and never mistake a gap for a real MXAccess
|
||||
event. The client neither synthesizes nor swallows the gateway's sentinel —
|
||||
it only makes that sentinel typed and observable.
|
||||
|
||||
On seeing a gap the consumer must discard any locally cached tag/alarm state
|
||||
and re-snapshot (for example via :meth:`Session.read_bulk` or
|
||||
:meth:`~zb_mom_ww_mxgateway.GatewayClient.query_active_alarms`). To resume
|
||||
the stream without provoking another gap, reconnect with
|
||||
``after_worker_sequence = gap.resume_after_worker_sequence`` (that is,
|
||||
``oldest_available_sequence - 1``) so the next replayed event is the oldest
|
||||
the gateway still retains.
|
||||
|
||||
The gateway sets this only on ``StreamEvents`` results — never on a normal
|
||||
(non-resumed) stream and never on a ``DrainEvents`` reply.
|
||||
"""
|
||||
|
||||
requested_after_sequence: int
|
||||
"""The ``after_worker_sequence`` cursor the resumed stream was opened with."""
|
||||
|
||||
oldest_available_sequence: int
|
||||
"""Oldest worker sequence the gateway can still replay."""
|
||||
|
||||
@classmethod
|
||||
def from_proto(cls, gap: pb.ReplayGap) -> "ReplayGap":
|
||||
"""Build a :class:`ReplayGap` from the generated ``ReplayGap`` message."""
|
||||
return cls(
|
||||
requested_after_sequence=gap.requested_after_sequence,
|
||||
oldest_available_sequence=gap.oldest_available_sequence,
|
||||
)
|
||||
|
||||
@property
|
||||
def resume_after_worker_sequence(self) -> int:
|
||||
"""``after_worker_sequence`` to resume the stream without another gap.
|
||||
|
||||
Equal to ``oldest_available_sequence - 1`` so the next event the gateway
|
||||
replays is ``oldest_available_sequence`` — the oldest it still retains.
|
||||
Clamped at ``0`` so it is never negative.
|
||||
"""
|
||||
return max(self.oldest_available_sequence - 1, 0)
|
||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
|
||||
from .errors import ensure_mxaccess_success
|
||||
from .auth import redact_secret
|
||||
from .errors import MxGatewayError, ensure_mxaccess_success
|
||||
from .events import ReplayGap
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .values import MxValueInput, to_mx_value
|
||||
|
||||
@@ -568,18 +570,304 @@ class Session:
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def _invoke_redacted(
|
||||
self,
|
||||
command: pb.MxCommand,
|
||||
*,
|
||||
correlation_id: str,
|
||||
secrets: Sequence[str | None],
|
||||
) -> pb.MxCommandReply:
|
||||
"""Invoke a command whose request carries credential-sensitive data.
|
||||
|
||||
Runs the same gateway + MXAccess validation as :meth:`invoke`, but scrubs
|
||||
the supplied secret substrings from any surfaced error message before it
|
||||
propagates. MXAccess parity is preserved — the native failure is still
|
||||
raised as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError`; only the
|
||||
credential text is removed from the message so it can never reach logs.
|
||||
"""
|
||||
|
||||
try:
|
||||
return await self.invoke(command, correlation_id=correlation_id)
|
||||
except MxGatewayError as error:
|
||||
_redact_error(error, secrets)
|
||||
raise
|
||||
|
||||
async def advise_supervisory(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
"""Invoke MXAccess `AdviseSupervisory` for an `ItemHandle`.
|
||||
|
||||
Supervisory advise is the prerequisite for user-attributed and secured
|
||||
writes: it must be established (typically after :meth:`authenticate_user`)
|
||||
before a ``user_id``-bearing :meth:`write` or a :meth:`write_secured`
|
||||
takes effect.
|
||||
"""
|
||||
await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
advise_supervisory=pb.AdviseSupervisoryCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def write_secured(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
value: MxValueInput,
|
||||
*,
|
||||
current_user_id: int = 0,
|
||||
verifier_user_id: int = 0,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
"""Invoke MXAccess `WriteSecured` — a signed/verified write.
|
||||
|
||||
The written *value* is credential-sensitive and is scrubbed from any
|
||||
surfaced error message (never logged). MXAccess parity is the contract:
|
||||
``WriteSecured`` failing before a prior :meth:`authenticate_user` +
|
||||
:meth:`advise_supervisory`, or before a value-bearing body, is the native
|
||||
behaviour and is surfaced as-is — it is not "fixed".
|
||||
"""
|
||||
await self._invoke_redacted(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
|
||||
write_secured=pb.WriteSecuredCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
current_user_id=current_user_id,
|
||||
verifier_user_id=verifier_user_id,
|
||||
value=to_mx_value(value),
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
secrets=_value_secrets(value),
|
||||
)
|
||||
|
||||
async def write_secured2(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
value: MxValueInput,
|
||||
timestamp_value: MxValueInput,
|
||||
*,
|
||||
current_user_id: int = 0,
|
||||
verifier_user_id: int = 0,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
"""Invoke MXAccess `WriteSecured2` — a signed/verified, timestamped write.
|
||||
|
||||
Like :meth:`write_secured` but also stamps a client-supplied timestamp.
|
||||
The written *value* is credential-sensitive and is scrubbed from any
|
||||
surfaced error message. Native pre-condition failures are surfaced as-is.
|
||||
"""
|
||||
await self._invoke_redacted(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2,
|
||||
write_secured2=pb.WriteSecured2Command(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
current_user_id=current_user_id,
|
||||
verifier_user_id=verifier_user_id,
|
||||
value=to_mx_value(value),
|
||||
timestamp_value=to_mx_value(timestamp_value),
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
secrets=_value_secrets(value),
|
||||
)
|
||||
|
||||
async def authenticate_user(
|
||||
self,
|
||||
server_handle: int,
|
||||
verify_user: str,
|
||||
verify_user_password: str,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> int:
|
||||
"""Invoke MXAccess `AuthenticateUser` and return the resolved Galaxy user id.
|
||||
|
||||
*verify_user_password* is a raw MXAccess credential: it is never logged
|
||||
and is scrubbed from any surfaced error message. A native authentication
|
||||
failure is surfaced as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError`
|
||||
with the credential removed.
|
||||
"""
|
||||
reply = await self._invoke_redacted(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
authenticate_user=pb.AuthenticateUserCommand(
|
||||
server_handle=server_handle,
|
||||
verify_user=verify_user,
|
||||
verify_user_password=verify_user_password,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
secrets=[verify_user_password],
|
||||
)
|
||||
return reply.authenticate_user.user_id
|
||||
|
||||
async def archestra_user_to_id(
|
||||
self,
|
||||
server_handle: int,
|
||||
user_id_guid: str,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> int:
|
||||
"""Invoke MXAccess `ArchestrAUserToId` and return the resolved user id."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID,
|
||||
archestra_user_to_id=pb.ArchestrAUserToIdCommand(
|
||||
server_handle=server_handle,
|
||||
user_id_guid=user_id_guid,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.archestra_user_to_id.user_id
|
||||
|
||||
async def add_buffered_item(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_definition: str,
|
||||
item_context: str = "",
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> int:
|
||||
"""Invoke MXAccess `AddBufferedItem` and return the new `ItemHandle`."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ADD_BUFFERED_ITEM,
|
||||
add_buffered_item=pb.AddBufferedItemCommand(
|
||||
server_handle=server_handle,
|
||||
item_definition=item_definition,
|
||||
item_context=item_context,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.add_buffered_item.item_handle
|
||||
|
||||
async def set_buffered_update_interval(
|
||||
self,
|
||||
server_handle: int,
|
||||
update_interval_milliseconds: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> None:
|
||||
"""Invoke MXAccess `SetBufferedUpdateInterval` for the server handle."""
|
||||
await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL,
|
||||
set_buffered_update_interval=pb.SetBufferedUpdateIntervalCommand(
|
||||
server_handle=server_handle,
|
||||
update_interval_milliseconds=update_interval_milliseconds,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def suspend(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> pb.MxStatusProxy:
|
||||
"""Invoke MXAccess `Suspend` for an `ItemHandle` and return its status."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_SUSPEND,
|
||||
suspend=pb.SuspendCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.suspend.status
|
||||
|
||||
async def activate(
|
||||
self,
|
||||
server_handle: int,
|
||||
item_handle: int,
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> pb.MxStatusProxy:
|
||||
"""Invoke MXAccess `Activate` for a suspended `ItemHandle` and return its status."""
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ACTIVATE,
|
||||
activate=pb.ActivateCommand(
|
||||
server_handle=server_handle,
|
||||
item_handle=item_handle,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return reply.activate.status
|
||||
|
||||
def stream_events(
|
||||
self,
|
||||
*,
|
||||
after_worker_sequence: int = 0,
|
||||
) -> AsyncIterator[pb.MxEvent]:
|
||||
"""Return an async iterator of `MxEvent` messages for this session."""
|
||||
return self.client.stream_events_raw(
|
||||
) -> AsyncIterator[pb.MxEvent | ReplayGap]:
|
||||
"""Return an async iterator over this session's `MxEvent` stream.
|
||||
|
||||
Each yielded item is either a normal :class:`~...MxEvent` or a
|
||||
:class:`ReplayGap`. Branch on ``isinstance(item, ReplayGap)`` — a gap is
|
||||
never delivered as an ``MxEvent`` so it cannot be mistaken for a real
|
||||
MXAccess event.
|
||||
|
||||
Pass a non-zero *after_worker_sequence* to resume a previously observed
|
||||
stream. If that cursor predates the oldest event the gateway still
|
||||
retains in its replay ring, the stream opens with a single
|
||||
:class:`ReplayGap` sentinel (events in the gap were dropped and cannot be
|
||||
replayed), then continues with normal events. On a gap, discard locally
|
||||
cached state, re-snapshot, and — to resume without another gap —
|
||||
reconnect with ``after_worker_sequence = gap.resume_after_worker_sequence``.
|
||||
See :class:`ReplayGap` for the full semantics. The underlying protobuf
|
||||
stream is available raw via ``GatewayClient.stream_events_raw``.
|
||||
"""
|
||||
raw = self.client.stream_events_raw(
|
||||
pb.StreamEventsRequest(
|
||||
session_id=self.session_id,
|
||||
after_worker_sequence=after_worker_sequence,
|
||||
),
|
||||
)
|
||||
return _surface_replay_gaps(raw)
|
||||
|
||||
|
||||
async def _surface_replay_gaps(
|
||||
raw: AsyncIterator[pb.MxEvent],
|
||||
) -> AsyncIterator[pb.MxEvent | ReplayGap]:
|
||||
"""Map the gateway's ``replay_gap`` sentinel event to a typed :class:`ReplayGap`.
|
||||
|
||||
Normal events pass through unchanged. The sentinel (``replay_gap`` set,
|
||||
``family`` unspecified, body unset) is converted to a distinct
|
||||
:class:`ReplayGap` so a consumer can branch on it without inspecting proto
|
||||
presence, and is never yielded as an ``MxEvent``. The sentinel is forwarded
|
||||
faithfully — it is neither dropped nor turned into a normal event.
|
||||
|
||||
Closing this generator (``aclose``) propagates to *raw* so the underlying
|
||||
gRPC call is cancelled, preserving the raw stream's cancel-on-stop contract.
|
||||
"""
|
||||
try:
|
||||
async for event in raw:
|
||||
if event.HasField("replay_gap"):
|
||||
yield ReplayGap.from_proto(event.replay_gap)
|
||||
else:
|
||||
yield event
|
||||
finally:
|
||||
aclose = getattr(raw, "aclose", None)
|
||||
if aclose is not None:
|
||||
await aclose()
|
||||
|
||||
|
||||
def _ensure_bulk_size(name: str, count: int) -> None:
|
||||
@@ -587,4 +875,39 @@ def _ensure_bulk_size(name: str, count: int) -> None:
|
||||
raise ValueError(f"{name} bulk commands are limited to {MAX_BULK_ITEMS} item(s)")
|
||||
|
||||
|
||||
def _value_secrets(value: MxValueInput) -> list[str]:
|
||||
"""Return the redaction candidate strings for a credential-sensitive write value.
|
||||
|
||||
Secured-write payloads may carry a password or other secret. Only textual
|
||||
values can appear verbatim in a surfaced error, so a string value (or a
|
||||
UTF-8-decodable ``bytes`` value) is returned for scrubbing; other value
|
||||
kinds have no verbatim text form to leak.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return [value] if value else []
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
decoded = value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return []
|
||||
return [decoded] if decoded else []
|
||||
return []
|
||||
|
||||
|
||||
def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
|
||||
"""Scrub secret substrings from a raised error's message in place.
|
||||
|
||||
Rewrites ``error.args[0]`` (the message returned by ``str(error)``) through
|
||||
the shared :func:`~zb_mom_ww_mxgateway.auth.redact_secret` seam so credential
|
||||
text can never reach logs or be re-raised to a caller. The
|
||||
``protocol_status`` / ``raw_reply`` context is left untouched — those hold the
|
||||
gateway's own fields, which never echo the client-supplied secret.
|
||||
"""
|
||||
scrubbed = [secret for secret in secrets if secret]
|
||||
if not scrubbed:
|
||||
return
|
||||
if error.args and isinstance(error.args[0], str):
|
||||
error.args = (redact_secret(error.args[0], scrubbed), *error.args[1:])
|
||||
|
||||
|
||||
from .client import GatewayClient # noqa: E402
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Package version information."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.2"
|
||||
|
||||
@@ -294,6 +294,56 @@ def advise_supervisory(**kwargs: Any) -> None:
|
||||
)
|
||||
|
||||
|
||||
@main.command("write-secured")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
|
||||
@click.option("--type", "value_type", default="string", show_default=True)
|
||||
@click.option("--value", required=True, help="Value to write (credential-sensitive; never logged).")
|
||||
@click.option("--current-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def write_secured(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess WriteSecured — a signed/verified write (credential-sensitive)."""
|
||||
|
||||
_run(
|
||||
_write_secured(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs) + [kwargs.get("value")],
|
||||
)
|
||||
|
||||
|
||||
@main.command("authenticate-user")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
|
||||
@click.option("--verify-user", required=True, help="MXAccess user name to authenticate.")
|
||||
@click.option(
|
||||
"--password",
|
||||
default=None,
|
||||
help="User password. Prefer --password-env so the secret is not visible on the command line.",
|
||||
)
|
||||
@click.option(
|
||||
"--password-env",
|
||||
default=None,
|
||||
help="Environment variable holding the user password.",
|
||||
)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def authenticate_user(**kwargs: Any) -> None:
|
||||
"""Invoke MXAccess AuthenticateUser — resolve a Galaxy user id (credential-sensitive)."""
|
||||
|
||||
password = _resolve_password(kwargs)
|
||||
kwargs["password"] = password
|
||||
_run(
|
||||
_authenticate_user(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs) + [password],
|
||||
)
|
||||
|
||||
|
||||
@main.command("subscribe-bulk")
|
||||
@gateway_options
|
||||
@click.option("--session-id", required=True, help="Gateway session id.")
|
||||
@@ -745,19 +795,58 @@ async def _advise(**kwargs: Any) -> dict[str, Any]:
|
||||
async def _advise_supervisory(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
await session.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
|
||||
advise_supervisory=pb.AdviseSupervisoryCommand(
|
||||
server_handle=kwargs["server_handle"],
|
||||
item_handle=kwargs["item_handle"],
|
||||
),
|
||||
),
|
||||
await session.advise_supervisory(
|
||||
kwargs["server_handle"],
|
||||
kwargs["item_handle"],
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _write_secured(**kwargs: Any) -> dict[str, Any]:
|
||||
value = _parse_value(kwargs["value"], kwargs["value_type"])
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
await session.write_secured(
|
||||
kwargs["server_handle"],
|
||||
kwargs["item_handle"],
|
||||
value,
|
||||
current_user_id=kwargs["current_user_id"],
|
||||
verifier_user_id=kwargs["verifier_user_id"],
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
user_id = await session.authenticate_user(
|
||||
kwargs["server_handle"],
|
||||
kwargs["verify_user"],
|
||||
kwargs["password"],
|
||||
correlation_id=kwargs["correlation_id"],
|
||||
)
|
||||
return {"userId": user_id}
|
||||
|
||||
|
||||
def _resolve_password(kwargs: dict[str, Any]) -> str:
|
||||
"""Resolve the authenticate-user password from --password or --password-env.
|
||||
|
||||
Prefers the explicit flag, then falls back to the named environment
|
||||
variable. The resolved secret is never echoed; callers pass it into the
|
||||
``secrets`` redaction list so it cannot leak through a surfaced error.
|
||||
"""
|
||||
|
||||
password = kwargs.get("password")
|
||||
if not password:
|
||||
env_name = kwargs.get("password_env")
|
||||
password = os.environ.get(env_name) if env_name else None
|
||||
if not password:
|
||||
raise click.UsageError("a password is required via --password or --password-env")
|
||||
return password
|
||||
|
||||
|
||||
async def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = _session(client, kwargs["session_id"])
|
||||
|
||||
@@ -645,3 +645,175 @@ def test_galaxy_browse_help_shows_parent_gobject_id() -> None:
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "--parent-gobject-id" in result.output
|
||||
|
||||
|
||||
class _FakeInvokeClient:
|
||||
"""Async-context-manager fake whose invoke_raw returns a scripted reply.
|
||||
|
||||
Satisfies the session-backed CLI command bodies (register / write-secured /
|
||||
authenticate-user) which build a Session over this client and call through to
|
||||
``invoke_raw``. Records the last command so tests can assert credentials are
|
||||
carried on the wire but never echoed to stdout.
|
||||
"""
|
||||
|
||||
def __init__(self, reply) -> None:
|
||||
self._reply = reply
|
||||
self.last_request = None
|
||||
|
||||
async def __aenter__(self) -> "_FakeInvokeClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: object) -> None:
|
||||
return None
|
||||
|
||||
async def invoke_raw(self, request):
|
||||
self.last_request = request
|
||||
return self._reply
|
||||
|
||||
|
||||
def test_authenticate_user_command_returns_user_id_without_echoing_password(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
reply = pb.MxCommandReply(
|
||||
session_id="s1",
|
||||
kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
authenticate_user=pb.AuthenticateUserReply(user_id=42),
|
||||
)
|
||||
fake = _FakeInvokeClient(reply)
|
||||
|
||||
async def fake_connect(options, **_kwargs):
|
||||
return fake
|
||||
|
||||
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"authenticate-user",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"s1",
|
||||
"--server-handle",
|
||||
"3",
|
||||
"--verify-user",
|
||||
"operator",
|
||||
"--password",
|
||||
"cli-secret-pw",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output)["userId"] == 42
|
||||
assert "cli-secret-pw" not in result.output
|
||||
# Credential is carried on the wire, not echoed.
|
||||
assert fake.last_request.command.authenticate_user.verify_user_password == "cli-secret-pw"
|
||||
|
||||
|
||||
def test_authenticate_user_reads_password_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
reply = pb.MxCommandReply(
|
||||
session_id="s1",
|
||||
kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
authenticate_user=pb.AuthenticateUserReply(user_id=7),
|
||||
)
|
||||
fake = _FakeInvokeClient(reply)
|
||||
|
||||
async def fake_connect(options, **_kwargs):
|
||||
return fake
|
||||
|
||||
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
|
||||
monkeypatch.setenv("MXGW_TEST_PW", "env-secret-pw")
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"authenticate-user",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"s1",
|
||||
"--server-handle",
|
||||
"3",
|
||||
"--verify-user",
|
||||
"operator",
|
||||
"--password-env",
|
||||
"MXGW_TEST_PW",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "env-secret-pw" not in result.output
|
||||
assert fake.last_request.command.authenticate_user.verify_user_password == "env-secret-pw"
|
||||
|
||||
|
||||
def test_authenticate_user_requires_a_password() -> None:
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"authenticate-user",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"s1",
|
||||
"--server-handle",
|
||||
"3",
|
||||
"--verify-user",
|
||||
"operator",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "password is required" in result.output
|
||||
|
||||
|
||||
def test_write_secured_command_does_not_echo_value_on_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
reply = pb.MxCommandReply(
|
||||
session_id="s1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
|
||||
protocol_status=pb.ProtocolStatus(
|
||||
code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
|
||||
message="WriteSecured rejected value cli-secret-value",
|
||||
),
|
||||
hresult=-1,
|
||||
)
|
||||
fake = _FakeInvokeClient(reply)
|
||||
|
||||
async def fake_connect(options, **_kwargs):
|
||||
return fake
|
||||
|
||||
monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"write-secured",
|
||||
"--plaintext",
|
||||
"--session-id",
|
||||
"s1",
|
||||
"--server-handle",
|
||||
"3",
|
||||
"--item-handle",
|
||||
"4",
|
||||
"--value",
|
||||
"cli-secret-value",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "cli-secret-value" not in result.output
|
||||
|
||||
|
||||
def test_write_secured_and_authenticate_user_commands_are_registered() -> None:
|
||||
names = set(main.commands)
|
||||
assert {"write-secured", "authenticate-user"} <= names
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for the typed ReplayGap signal on Session.stream_events (CLI-15)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from zb_mom_ww_mxgateway import ReplayGap, Session
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Minimal client stub exposing only what Session.stream_events needs."""
|
||||
|
||||
def __init__(self, events: list[pb.MxEvent]) -> None:
|
||||
self._events = events
|
||||
self.last_request: pb.StreamEventsRequest | None = None
|
||||
|
||||
def stream_events_raw(
|
||||
self,
|
||||
request: pb.StreamEventsRequest,
|
||||
) -> AsyncIterator[pb.MxEvent]:
|
||||
self.last_request = request
|
||||
|
||||
async def _gen() -> AsyncIterator[pb.MxEvent]:
|
||||
for event in self._events:
|
||||
yield event
|
||||
|
||||
return _gen()
|
||||
|
||||
|
||||
def _gap_sentinel(*, requested: int, oldest: int) -> pb.MxEvent:
|
||||
return pb.MxEvent(
|
||||
session_id="session-1",
|
||||
family=pb.MX_EVENT_FAMILY_UNSPECIFIED,
|
||||
replay_gap=pb.ReplayGap(
|
||||
requested_after_sequence=requested,
|
||||
oldest_available_sequence=oldest,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _normal_event(worker_sequence: int) -> pb.MxEvent:
|
||||
return pb.MxEvent(
|
||||
session_id="session-1",
|
||||
worker_sequence=worker_sequence,
|
||||
family=pb.MX_EVENT_FAMILY_ON_DATA_CHANGE,
|
||||
)
|
||||
|
||||
|
||||
def _session(events: list[pb.MxEvent]) -> tuple[Session, _FakeClient]:
|
||||
client = _FakeClient(events)
|
||||
session = Session(client=client, session_id="session-1") # type: ignore[arg-type]
|
||||
return session, client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_gap_sentinel_surfaces_as_typed_signal() -> None:
|
||||
session, _client = _session(
|
||||
[_gap_sentinel(requested=5, oldest=10), _normal_event(10)],
|
||||
)
|
||||
|
||||
items = [item async for item in session.stream_events(after_worker_sequence=5)]
|
||||
|
||||
assert isinstance(items[0], ReplayGap)
|
||||
assert items[0].requested_after_sequence == 5
|
||||
assert items[0].oldest_available_sequence == 10
|
||||
# Resume cursor is oldest_available_sequence - 1 so the next replayed event
|
||||
# is the oldest the gateway still retains.
|
||||
assert items[0].resume_after_worker_sequence == 9
|
||||
|
||||
# Normal events after the sentinel pass through unchanged as MxEvent.
|
||||
assert isinstance(items[1], pb.MxEvent)
|
||||
assert not items[1].HasField("replay_gap")
|
||||
assert items[1].worker_sequence == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_events_are_unaffected() -> None:
|
||||
session, _client = _session([_normal_event(1), _normal_event(2)])
|
||||
|
||||
items = [item async for item in session.stream_events()]
|
||||
|
||||
assert all(isinstance(item, pb.MxEvent) for item in items)
|
||||
assert [item.worker_sequence for item in items] == [1, 2]
|
||||
assert not any(isinstance(item, ReplayGap) for item in items)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_events_forwards_resume_cursor() -> None:
|
||||
session, client = _session([])
|
||||
|
||||
async for _ in session.stream_events(after_worker_sequence=42):
|
||||
pass
|
||||
|
||||
assert client.last_request is not None
|
||||
assert client.last_request.after_worker_sequence == 42
|
||||
assert client.last_request.session_id == "session-1"
|
||||
|
||||
|
||||
def test_replay_gap_resume_cursor_never_negative() -> None:
|
||||
gap = ReplayGap.from_proto(
|
||||
pb.ReplayGap(requested_after_sequence=0, oldest_available_sequence=0),
|
||||
)
|
||||
assert gap.resume_after_worker_sequence == 0
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests for the typed single-item command helpers (CLI-04).
|
||||
|
||||
Covers the parity-critical MXAccess commands promoted from raw ``Invoke`` to
|
||||
typed async session helpers: ``advise_supervisory``, ``write_secured`` /
|
||||
``write_secured2``, ``authenticate_user``, ``archestra_user_to_id``, and the
|
||||
buffered/suspend/activate family. The credential-redaction contract for the
|
||||
secured/auth helpers is asserted explicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient, MxAccessError
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
|
||||
|
||||
class FakeUnary:
|
||||
"""Records requests and pops scripted replies, matching the client's call shape."""
|
||||
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
self.requests: list[Any] = []
|
||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: Any,
|
||||
*,
|
||||
metadata: tuple[tuple[str, str], ...],
|
||||
) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
class FakeGatewayStub:
|
||||
"""Minimal stub: a fixed open-session reply plus a scriptable invoke queue."""
|
||||
|
||||
def __init__(self, invoke_replies: list[Any]) -> None:
|
||||
self.open_session = FakeUnary(
|
||||
[
|
||||
pb.OpenSessionReply(
|
||||
session_id="session-1",
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
),
|
||||
],
|
||||
)
|
||||
self.invoke = FakeUnary(invoke_replies)
|
||||
self.OpenSession = self.open_session
|
||||
self.Invoke = self.invoke
|
||||
|
||||
|
||||
async def _session_with(invoke_replies: list[Any]):
|
||||
stub = FakeGatewayStub(invoke_replies)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
return session, stub
|
||||
|
||||
|
||||
def _ok(kind: "pb.MxCommandKind.ValueType", **payload: Any) -> pb.MxCommandReply:
|
||||
return pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=kind,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
**payload,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advise_supervisory_sends_typed_command() -> None:
|
||||
session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY)])
|
||||
|
||||
await session.advise_supervisory(12, 34)
|
||||
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY
|
||||
assert command.advise_supervisory.server_handle == 12
|
||||
assert command.advise_supervisory.item_handle == 34
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_returns_user_id_and_sends_credentials() -> None:
|
||||
session, stub = await _session_with(
|
||||
[_ok(pb.MX_COMMAND_KIND_AUTHENTICATE_USER, authenticate_user=pb.AuthenticateUserReply(user_id=77))],
|
||||
)
|
||||
|
||||
user_id = await session.authenticate_user(12, "operator", "s3cr3t-pw")
|
||||
|
||||
assert user_id == 77
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_AUTHENTICATE_USER
|
||||
assert command.authenticate_user.verify_user == "operator"
|
||||
# The credential is carried on the wire (redaction is about logs/errors, not the RPC).
|
||||
assert command.authenticate_user.verify_user_password == "s3cr3t-pw"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_scrubs_credential_from_surfaced_error() -> None:
|
||||
password = "super-secret-pw"
|
||||
failure = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
|
||||
protocol_status=pb.ProtocolStatus(
|
||||
code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
|
||||
# Simulate a gateway that unwisely echoed the credential back in the message.
|
||||
message=f"authentication failed for password {password}",
|
||||
),
|
||||
hresult=-1,
|
||||
)
|
||||
session, _ = await _session_with([failure])
|
||||
|
||||
with pytest.raises(MxAccessError) as captured:
|
||||
await session.authenticate_user(12, "operator", password)
|
||||
|
||||
assert password not in str(captured.value)
|
||||
assert "[redacted]" in str(captured.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_secured_surfaces_native_failure_without_prior_authenticate() -> None:
|
||||
"""Parity: WriteSecured failing before authenticate/advise-supervisory is surfaced as-is."""
|
||||
secret_value = "priv-payload"
|
||||
failure = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
|
||||
protocol_status=pb.ProtocolStatus(
|
||||
code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
|
||||
message=f"WriteSecured rejected value {secret_value}",
|
||||
),
|
||||
hresult=-2147217407,
|
||||
)
|
||||
session, stub = await _session_with([failure])
|
||||
|
||||
with pytest.raises(MxAccessError) as captured:
|
||||
await session.write_secured(12, 34, secret_value, current_user_id=5, verifier_user_id=6)
|
||||
|
||||
# Native failure is surfaced (not "fixed") and the raw reply is preserved...
|
||||
assert captured.value.raw_reply is failure
|
||||
# ...but the credential-sensitive value is scrubbed from the surfaced message.
|
||||
assert secret_value not in str(captured.value)
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_WRITE_SECURED
|
||||
assert command.write_secured.current_user_id == 5
|
||||
assert command.write_secured.verifier_user_id == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_secured2_sends_value_and_timestamp() -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_WRITE_SECURED2)])
|
||||
|
||||
stamp = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
||||
await session.write_secured2(12, 34, 42, stamp, current_user_id=5, verifier_user_id=6)
|
||||
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_WRITE_SECURED2
|
||||
assert command.write_secured2.value.int32_value == 42
|
||||
assert command.write_secured2.HasField("timestamp_value")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archestra_user_to_id_returns_user_id() -> None:
|
||||
session, stub = await _session_with(
|
||||
[_ok(pb.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID, archestra_user_to_id=pb.ArchestrAUserToIdReply(user_id=9))],
|
||||
)
|
||||
|
||||
user_id = await session.archestra_user_to_id(12, "guid-123")
|
||||
|
||||
assert user_id == 9
|
||||
assert stub.invoke.requests[0].command.archestra_user_to_id.user_id_guid == "guid-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_buffered_item_returns_item_handle() -> None:
|
||||
session, stub = await _session_with(
|
||||
[_ok(pb.MX_COMMAND_KIND_ADD_BUFFERED_ITEM, add_buffered_item=pb.AddBufferedItemReply(item_handle=55))],
|
||||
)
|
||||
|
||||
item_handle = await session.add_buffered_item(12, "Object.Attribute", "ctx")
|
||||
|
||||
assert item_handle == 55
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.add_buffered_item.item_definition == "Object.Attribute"
|
||||
assert command.add_buffered_item.item_context == "ctx"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_buffered_update_interval_sends_command() -> None:
|
||||
session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL)])
|
||||
|
||||
await session.set_buffered_update_interval(12, 250)
|
||||
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL
|
||||
assert command.set_buffered_update_interval.update_interval_milliseconds == 250
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suspend_and_activate_return_status() -> None:
|
||||
session, _ = await _session_with(
|
||||
[
|
||||
_ok(
|
||||
pb.MX_COMMAND_KIND_SUSPEND,
|
||||
suspend=pb.SuspendReply(status=pb.MxStatusProxy(category=pb.MX_STATUS_CATEGORY_OK)),
|
||||
),
|
||||
],
|
||||
)
|
||||
status = await session.suspend(12, 34)
|
||||
assert status.category == pb.MX_STATUS_CATEGORY_OK
|
||||
|
||||
session, _ = await _session_with(
|
||||
[
|
||||
_ok(
|
||||
pb.MX_COMMAND_KIND_ACTIVATE,
|
||||
activate=pb.ActivateReply(status=pb.MxStatusProxy(category=pb.MX_STATUS_CATEGORY_OK)),
|
||||
),
|
||||
],
|
||||
)
|
||||
status = await session.activate(12, 34)
|
||||
assert status.category == pb.MX_STATUS_CATEGORY_OK
|
||||
@@ -13,6 +13,11 @@ keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||
categories = ["api-bindings", "asynchronous"]
|
||||
publish = ["dohertj2-gitea"]
|
||||
build = "build.rs"
|
||||
# Ship the vendored `.proto` files so `build.rs` can regenerate the tonic/prost
|
||||
# bindings from the packaged tarball — a consumer building the published crate
|
||||
# has no access to the in-repo Contracts protos. See CLI-02. Cargo.toml, the
|
||||
# README, and the license are included automatically.
|
||||
include = ["src/**/*.rs", "protos/*.proto", "build.rs", "README.md"]
|
||||
|
||||
[workspace]
|
||||
members = ["crates/mxgw-cli"]
|
||||
|
||||
+63
-12
@@ -137,6 +137,47 @@ redaction. Per-item bulk failures are reported inside each result entry
|
||||
`invoke_raw` / `client.invoke_raw` escape hatch performs neither check and
|
||||
returns the unvalidated reply.
|
||||
|
||||
## Event Streaming And Reconnect-Replay Gaps
|
||||
|
||||
`session.events()` / `session.events_after(after_worker_sequence)` (and the
|
||||
lower-level `client.stream_events`) return an `EventStream` that yields
|
||||
`EventItem` values, not bare `MxEvent`s:
|
||||
|
||||
```rust
|
||||
use zb_mom_ww_mxgateway_client::EventItem;
|
||||
|
||||
let mut stream = session.events_after(cursor).await?;
|
||||
while let Some(item) = stream.next().await {
|
||||
match item? {
|
||||
EventItem::Event(event) => { /* apply the MXAccess change */ }
|
||||
EventItem::ReplayGap(gap) => {
|
||||
// Recent history was evicted — discard local state and re-snapshot,
|
||||
// then resume without provoking another gap:
|
||||
let resume = gap.oldest_available_sequence.saturating_sub(1);
|
||||
stream = session.events_after(resume).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Almost every item is a normal `EventItem::Event`. `EventItem::ReplayGap` is a
|
||||
faithful, typed surfacing of the gateway's reconnect-replay gap sentinel — the
|
||||
client does not synthesize it. The gateway emits the sentinel at most once, at
|
||||
the head of a stream **resumed** via `events_after` (`after_worker_sequence`)
|
||||
when the requested sequence is older than the oldest event still retained in the
|
||||
session's replay ring: events in the open interval
|
||||
`(requested_after_sequence, oldest_available_sequence)` were evicted and cannot
|
||||
be replayed. A `ReplayGap` therefore means "you missed events — discard any
|
||||
local state and re-snapshot." To resume without a second gap, reconnect with
|
||||
`events_after(gap.oldest_available_sequence - 1)`, which replays starting at the
|
||||
first still-retained event. A stream opened from the beginning
|
||||
(`session.events()` / `events_after(0)`) never produces a `ReplayGap`.
|
||||
|
||||
`EventItem` provides `as_event()`, `into_event()`, and `replay_gap()` accessors
|
||||
for callers that prefer not to `match`. The `mxgw-cli stream-events` subcommand
|
||||
renders the sentinel as a distinct `REPLAY_GAP …` line (or a `replayGap` JSON
|
||||
object under `--json` / `--jsonl`).
|
||||
|
||||
## Write Semantics And Common Pitfalls
|
||||
|
||||
These are MXAccess parity behaviors that surprise new callers. The gateway
|
||||
@@ -151,26 +192,36 @@ but still need the write attributed to a user id, you must first advise the
|
||||
item supervisory and then pass that user id on the write. Without the
|
||||
supervisory advise the `user_id` on a plain write is ignored.
|
||||
|
||||
The session exposes `advise`/`un_advise` but not supervisory advise, so send it
|
||||
through the generic command channel:
|
||||
The session exposes a typed `advise_supervisory` helper alongside
|
||||
`advise`/`un_advise`:
|
||||
|
||||
```rust
|
||||
session
|
||||
.invoke(
|
||||
MxCommandKind::AdviseSupervisory,
|
||||
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
|
||||
server_handle,
|
||||
item_handle,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
session.advise_supervisory(server_handle, item_handle).await?;
|
||||
session.write(server_handle, item_handle, value, user_id).await?;
|
||||
```
|
||||
|
||||
The CLI exposes the same command as `advise-supervisory`, and `write` /
|
||||
`write2` take `--user-id`.
|
||||
|
||||
### Verified / secured writes and user resolution
|
||||
|
||||
The verified path has typed session helpers too: `authenticate_user` (returns
|
||||
the resolved MXAccess user id), `archestra_user_to_id`, and
|
||||
`write_secured` / `write_secured2`. MXAccess parity is preserved — a
|
||||
`write_secured` issued before the required `authenticate_user` +
|
||||
`advise_supervisory` (or before a value-bearing body) fails natively and the
|
||||
failure surfaces as `Error::MxAccess`; it is not smoothed over. Credentials
|
||||
passed to `authenticate_user` (and secured write payloads) are placed only on
|
||||
the wire — the client never logs them and never embeds them in an `Error`'s
|
||||
`Display`/`Debug`; the only error text that can surface (from `tonic::Status`
|
||||
messages and reply diagnostics) is scrubbed by the credential-redaction seam.
|
||||
The CLI mirrors these as `authenticate-user` (password via `--password` or the
|
||||
`--password-env` env var, never echoed) and `write-secured`.
|
||||
|
||||
The remaining single-item command helpers round out MXAccess parity:
|
||||
`unregister`, `suspend` / `activate` (each returns the operation's
|
||||
`MxStatus`), `add_buffered_item`, and `set_buffered_update_interval`.
|
||||
|
||||
### Array writes replace the whole array
|
||||
|
||||
A write to an array attribute **replaces the entire array**; it is not an
|
||||
|
||||
+17
-3
@@ -6,11 +6,25 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
configure_protoc();
|
||||
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
|
||||
let repo_root = manifest_dir
|
||||
|
||||
// Resolve the proto source directory. In-repo builds prefer the canonical
|
||||
// Contracts protos (two levels above clients/rust) so local `.proto` edits
|
||||
// are picked up live. When that path is absent — e.g. a consumer building
|
||||
// the crate unpacked from a published tarball — fall back to the vendored
|
||||
// copies shipped in `clients/rust/protos/` (see build.rs staleness note and
|
||||
// CLI-02 in archreview/remediation/50-clients.md). Vendored copies are
|
||||
// build inputs only; the canonical source remains in Contracts and must be
|
||||
// refreshed here whenever the `.proto` files change.
|
||||
let repo_proto_root = manifest_dir
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.ok_or("clients/rust must live two levels below the repository root")?;
|
||||
let proto_root = repo_root.join("src/ZB.MOM.WW.MxGateway.Contracts/Protos");
|
||||
.map(|repo_root| repo_root.join("src/ZB.MOM.WW.MxGateway.Contracts/Protos"));
|
||||
let vendored_proto_root = manifest_dir.join("protos");
|
||||
let proto_root = match repo_proto_root {
|
||||
Some(path) if path.is_dir() => path,
|
||||
_ => vendored_proto_root,
|
||||
};
|
||||
|
||||
let gateway_proto = proto_root.join("mxaccess_gateway.proto");
|
||||
let worker_proto = proto_root.join("mxaccess_worker.proto");
|
||||
let galaxy_proto = proto_root.join("galaxy_repository.proto");
|
||||
|
||||
@@ -21,15 +21,14 @@ use serde_json::Value;
|
||||
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode};
|
||||
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
|
||||
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||
alarm_feed_message, AcknowledgeAlarmRequest, AdviseSupervisoryCommand, AlarmFeedMessage,
|
||||
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily,
|
||||
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamAlarmsRequest,
|
||||
StreamEventsRequest, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry,
|
||||
WriteSecuredBulkEntry,
|
||||
alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
|
||||
MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue,
|
||||
OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry,
|
||||
WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
||||
};
|
||||
use zb_mom_ww_mxgateway_client::{
|
||||
next_correlation_id, ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue,
|
||||
MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
|
||||
next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
|
||||
MxValue, MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
|
||||
};
|
||||
|
||||
const MAX_AGGREGATE_EVENTS: usize = 10_000;
|
||||
@@ -118,6 +117,67 @@ enum Command {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Release a `ServerHandle` (and the items advised under it) via
|
||||
/// MXAccess `Unregister`.
|
||||
Unregister {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
session_id: String,
|
||||
#[arg(long)]
|
||||
server_handle: i32,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Resolve an MXAccess user id from a credential via `AuthenticateUser`.
|
||||
/// The password is read from `--password` or, if omitted, from the
|
||||
/// environment variable named by `--password-env`; it is never echoed to
|
||||
/// stdout/stderr.
|
||||
AuthenticateUser {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
session_id: String,
|
||||
#[arg(long)]
|
||||
server_handle: i32,
|
||||
#[arg(long)]
|
||||
verify_user: String,
|
||||
/// Verifier password. Prefer `--password-env` so the secret never
|
||||
/// appears in the process command line.
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
/// Name of the environment variable holding the verifier password.
|
||||
/// Used only when `--password` is not supplied.
|
||||
#[arg(long, default_value = "MXGATEWAY_VERIFY_PASSWORD")]
|
||||
password_env: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Single credential-verified write via MXAccess `WriteSecured`.
|
||||
///
|
||||
/// Parity note: this fails natively unless the session has first run
|
||||
/// `authenticate-user` + `advise-supervisory` and the item carries a
|
||||
/// value-bearing body — the native failure is surfaced, not hidden.
|
||||
WriteSecured {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
#[arg(long)]
|
||||
session_id: String,
|
||||
#[arg(long)]
|
||||
server_handle: i32,
|
||||
#[arg(long)]
|
||||
item_handle: i32,
|
||||
#[arg(long, value_enum)]
|
||||
value_type: CliValueType,
|
||||
#[arg(long)]
|
||||
value: String,
|
||||
#[arg(long, default_value_t = 0)]
|
||||
current_user_id: i32,
|
||||
#[arg(long, default_value_t = 0)]
|
||||
verifier_user_id: i32,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
SubscribeBulk {
|
||||
#[command(flatten)]
|
||||
connection: ConnectionArgs,
|
||||
@@ -669,18 +729,69 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
} => {
|
||||
let session = session_for(connection, session_id).await?;
|
||||
session
|
||||
.invoke(
|
||||
MxCommandKind::AdviseSupervisory,
|
||||
zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::AdviseSupervisory(
|
||||
AdviseSupervisoryCommand {
|
||||
server_handle,
|
||||
item_handle,
|
||||
},
|
||||
),
|
||||
)
|
||||
.advise_supervisory(server_handle, item_handle)
|
||||
.await?;
|
||||
print_ok("advise-supervisory", json);
|
||||
}
|
||||
Command::Unregister {
|
||||
connection,
|
||||
session_id,
|
||||
server_handle,
|
||||
json,
|
||||
} => {
|
||||
let session = session_for(connection, session_id).await?;
|
||||
session.unregister(server_handle).await?;
|
||||
print_ok("unregister", json);
|
||||
}
|
||||
Command::AuthenticateUser {
|
||||
connection,
|
||||
session_id,
|
||||
server_handle,
|
||||
verify_user,
|
||||
password,
|
||||
password_env,
|
||||
json,
|
||||
} => {
|
||||
// Resolve the credential from --password or the named env var.
|
||||
// The password is passed straight to the typed helper and is never
|
||||
// echoed to stdout/stderr or embedded in an error message.
|
||||
let verify_user_password = password
|
||||
.or_else(|| env::var(&password_env).ok())
|
||||
.ok_or_else(|| Error::InvalidArgument {
|
||||
name: "password".to_owned(),
|
||||
detail: format!(
|
||||
"supply --password or set the environment variable `{password_env}`"
|
||||
),
|
||||
})?;
|
||||
let session = session_for(connection, session_id).await?;
|
||||
let user_id = session
|
||||
.authenticate_user(server_handle, &verify_user, &verify_user_password)
|
||||
.await?;
|
||||
print_handle("userId", user_id, json);
|
||||
}
|
||||
Command::WriteSecured {
|
||||
connection,
|
||||
session_id,
|
||||
server_handle,
|
||||
item_handle,
|
||||
value_type,
|
||||
value,
|
||||
current_user_id,
|
||||
verifier_user_id,
|
||||
json,
|
||||
} => {
|
||||
let session = session_for(connection, session_id).await?;
|
||||
session
|
||||
.write_secured(
|
||||
server_handle,
|
||||
item_handle,
|
||||
current_user_id,
|
||||
verifier_user_id,
|
||||
parse_value(value_type, &value)?,
|
||||
)
|
||||
.await?;
|
||||
print_ok("write-secured", json);
|
||||
}
|
||||
Command::SubscribeBulk {
|
||||
connection,
|
||||
session_id,
|
||||
@@ -886,17 +997,43 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
let mut events: Vec<Value> = Vec::new();
|
||||
let mut event_count = 0usize;
|
||||
while event_count < max_events {
|
||||
let Some(event) = stream.next().await else {
|
||||
let Some(item) = stream.next().await else {
|
||||
break;
|
||||
};
|
||||
let event = event?;
|
||||
let item = item?;
|
||||
event_count += 1;
|
||||
if jsonl {
|
||||
println!("{}", event_to_json(&event));
|
||||
} else if json {
|
||||
events.push(event_to_json(&event));
|
||||
} else {
|
||||
println!("{} {}", event.worker_sequence, event.family);
|
||||
match item {
|
||||
EventItem::Event(event) => {
|
||||
if jsonl {
|
||||
println!("{}", event_to_json(&event));
|
||||
} else if json {
|
||||
events.push(event_to_json(&event));
|
||||
} else {
|
||||
println!("{} {}", event.worker_sequence, event.family);
|
||||
}
|
||||
}
|
||||
// Reconnect-replay gap sentinel: recent history was evicted
|
||||
// before this resumed stream could replay it. Render it as a
|
||||
// distinct row so the caller can re-snapshot and resume with
|
||||
// `oldest_available_sequence - 1`.
|
||||
EventItem::ReplayGap(gap) => {
|
||||
let value = json!({
|
||||
"replayGap": {
|
||||
"requestedAfterSequence": gap.requested_after_sequence,
|
||||
"oldestAvailableSequence": gap.oldest_available_sequence,
|
||||
}
|
||||
});
|
||||
if jsonl {
|
||||
println!("{value}");
|
||||
} else if json {
|
||||
events.push(value);
|
||||
} else {
|
||||
println!(
|
||||
"REPLAY_GAP requested_after={} oldest_available={}",
|
||||
gap.requested_after_sequence, gap.oldest_available_sequence
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if json {
|
||||
@@ -2463,6 +2600,59 @@ mod tests {
|
||||
assert_eq!(value["workerProtocolVersion"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_authenticate_user_command_with_password_env() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"authenticate-user",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--server-handle",
|
||||
"7",
|
||||
"--verify-user",
|
||||
"verifier",
|
||||
"--password-env",
|
||||
"MY_PW_VAR",
|
||||
]);
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_write_secured_command() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"write-secured",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--server-handle",
|
||||
"12",
|
||||
"--item-handle",
|
||||
"34",
|
||||
"--value-type",
|
||||
"int32",
|
||||
"--value",
|
||||
"5",
|
||||
"--current-user-id",
|
||||
"1",
|
||||
"--verifier-user-id",
|
||||
"2",
|
||||
]);
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_unregister_command() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"unregister",
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--server-handle",
|
||||
"12",
|
||||
]);
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_stream_alarms_command() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package galaxy_repository.v1;
|
||||
|
||||
option csharp_namespace = "ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
|
||||
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
// additively only. Never renumber or repurpose an existing field number or
|
||||
// enum value. When a field or enum value is removed, add a `reserved` range
|
||||
// (and `reserved` name) covering it in the same change so a future editor
|
||||
// cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
// declarations today because no field or enum value has ever been removed.
|
||||
|
||||
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
// database). Lets clients enumerate the deployed object hierarchy and each
|
||||
// object's dynamic attributes so they know what tag references to subscribe
|
||||
// to via the MxAccessGateway service.
|
||||
service GalaxyRepository {
|
||||
rpc TestConnection(TestConnectionRequest) returns (TestConnectionReply);
|
||||
rpc GetLastDeployTime(GetLastDeployTimeRequest) returns (GetLastDeployTimeReply);
|
||||
rpc DiscoverHierarchy(DiscoverHierarchyRequest) returns (DiscoverHierarchyReply);
|
||||
|
||||
// Server-stream of deploy events. The server emits the current state immediately
|
||||
// on subscribe (so clients can bootstrap their cache without waiting for the next
|
||||
// deploy), then emits one event each time the gateway's hierarchy cache observes
|
||||
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
|
||||
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||
// older events because the client was too slow.
|
||||
rpc WatchDeployEvents(WatchDeployEventsRequest) returns (stream DeployEvent);
|
||||
|
||||
// Returns the direct children of a parent object (or the root objects when
|
||||
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||
// one level at a time instead of paging the full hierarchy. Filters mirror
|
||||
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||
rpc BrowseChildren(BrowseChildrenRequest) returns (BrowseChildrenReply);
|
||||
}
|
||||
|
||||
message TestConnectionRequest {}
|
||||
|
||||
message TestConnectionReply {
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
message GetLastDeployTimeRequest {}
|
||||
|
||||
message GetLastDeployTimeReply {
|
||||
bool present = 1;
|
||||
google.protobuf.Timestamp time_of_last_deploy = 2;
|
||||
}
|
||||
|
||||
message DiscoverHierarchyRequest {
|
||||
// Maximum number of objects to return. The server applies its default when
|
||||
// unset and rejects non-positive values.
|
||||
int32 page_size = 1;
|
||||
// Opaque token returned by a previous DiscoverHierarchy response.
|
||||
string page_token = 2;
|
||||
// Optional. When set, return only this object and its descendants.
|
||||
// Empty = full hierarchy.
|
||||
oneof root {
|
||||
int32 root_gobject_id = 3;
|
||||
string root_tag_name = 4;
|
||||
string root_contained_path = 5;
|
||||
}
|
||||
// Optional. Cap on descendant depth from root. Zero returns only the root.
|
||||
// Unset means unlimited depth.
|
||||
google.protobuf.Int32Value max_depth = 6;
|
||||
// Optional object category id filters.
|
||||
repeated int32 category_ids = 7;
|
||||
// Optional case-insensitive substring filters against template names.
|
||||
repeated string template_chain_contains = 8;
|
||||
// Optional anchored, case-insensitive glob over object tag_name.
|
||||
string tag_name_glob = 9;
|
||||
// Optional. Unset or true includes attributes. False returns object skeletons.
|
||||
optional bool include_attributes = 10;
|
||||
// Optional. Return only objects with at least one alarm-bearing attribute.
|
||||
bool alarm_bearing_only = 11;
|
||||
// Optional. Return only objects with at least one historized attribute.
|
||||
bool historized_only = 12;
|
||||
}
|
||||
|
||||
message DiscoverHierarchyReply {
|
||||
repeated GalaxyObject objects = 1;
|
||||
// Non-empty when another page is available.
|
||||
string next_page_token = 2;
|
||||
// Total number of objects in the cached hierarchy at the time of the call.
|
||||
int32 total_object_count = 3;
|
||||
}
|
||||
|
||||
message WatchDeployEventsRequest {
|
||||
// Optional. When set, the bootstrap event is suppressed if the cached deploy
|
||||
// time matches this value. Future events are still emitted normally.
|
||||
google.protobuf.Timestamp last_seen_deploy_time = 1;
|
||||
}
|
||||
|
||||
message DeployEvent {
|
||||
// Monotonically increasing per server start. Gaps indicate dropped events.
|
||||
uint64 sequence = 1;
|
||||
// Server wall-clock when the cache observed the deploy.
|
||||
google.protobuf.Timestamp observed_at = 2;
|
||||
// Galaxy.time_of_last_deploy. Absent only when the Galaxy table reports null.
|
||||
google.protobuf.Timestamp time_of_last_deploy = 3;
|
||||
bool time_of_last_deploy_present = 4;
|
||||
int32 object_count = 5;
|
||||
int32 attribute_count = 6;
|
||||
}
|
||||
|
||||
message GalaxyObject {
|
||||
int32 gobject_id = 1;
|
||||
string tag_name = 2;
|
||||
string contained_name = 3;
|
||||
string browse_name = 4;
|
||||
int32 parent_gobject_id = 5;
|
||||
bool is_area = 6;
|
||||
int32 category_id = 7;
|
||||
int32 hosted_by_gobject_id = 8;
|
||||
repeated string template_chain = 9;
|
||||
repeated GalaxyAttribute attributes = 10;
|
||||
}
|
||||
|
||||
message GalaxyAttribute {
|
||||
string attribute_name = 1;
|
||||
string full_tag_reference = 2;
|
||||
// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||
// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||
// type enumeration is distinct from MXAccess's wire data-type enum and
|
||||
// the two must not be cast or compared. The GalaxyRepository service is
|
||||
// metadata-only and deliberately does not share types with
|
||||
// mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||
int32 mx_data_type = 3;
|
||||
// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
string data_type_name = 4;
|
||||
bool is_array = 5;
|
||||
int32 array_dimension = 6;
|
||||
bool array_dimension_present = 7;
|
||||
// Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||
// Galaxy-specific; not mapped to any gateway enum. See
|
||||
// docs/GalaxyRepository.md.
|
||||
int32 mx_attribute_category = 8;
|
||||
// Raw Galaxy SQL security-classification identifier, passed through
|
||||
// unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||
// docs/GalaxyRepository.md.
|
||||
int32 security_classification = 9;
|
||||
bool is_historized = 10;
|
||||
bool is_alarm = 11;
|
||||
}
|
||||
|
||||
message BrowseChildrenRequest {
|
||||
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
|
||||
oneof parent {
|
||||
int32 parent_gobject_id = 1;
|
||||
string parent_tag_name = 2;
|
||||
string parent_contained_path = 3;
|
||||
}
|
||||
|
||||
// Maximum number of direct children to return. Server default 500; cap 5000.
|
||||
int32 page_size = 4;
|
||||
// Opaque token returned by a previous BrowseChildren response. Bound to the
|
||||
// cache sequence, parent selector, and the filter set; a mismatch returns
|
||||
// InvalidArgument.
|
||||
string page_token = 5;
|
||||
|
||||
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
|
||||
repeated int32 category_ids = 6;
|
||||
repeated string template_chain_contains = 7;
|
||||
string tag_name_glob = 8;
|
||||
optional bool include_attributes = 9;
|
||||
bool alarm_bearing_only = 10;
|
||||
bool historized_only = 11;
|
||||
}
|
||||
|
||||
message BrowseChildrenReply {
|
||||
// Direct children matching the filter, sorted areas-first then by
|
||||
// case-insensitive display name (same order as the dashboard tree).
|
||||
repeated GalaxyObject children = 1;
|
||||
// Non-empty when another page of siblings is available.
|
||||
string next_page_token = 2;
|
||||
// Total matching direct children of the parent (post-filter).
|
||||
int32 total_child_count = 3;
|
||||
// Parallel array, indexed with `children`. True when the child has at least
|
||||
// one matching descendant under the same filter set. Lets a UI choose
|
||||
// whether to draw an expand triangle without an extra round trip.
|
||||
repeated bool child_has_children = 4;
|
||||
// Cache sequence this reply was projected from. Clients may pass it back as
|
||||
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
|
||||
uint64 cache_sequence = 5;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mxaccess_worker.v1;
|
||||
|
||||
option csharp_namespace = "ZB.MOM.WW.MxGateway.Contracts.Proto";
|
||||
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "mxaccess_gateway.proto";
|
||||
|
||||
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
// additively only. Never renumber or repurpose an existing field number or
|
||||
// enum value. When a field or enum value is removed, add a `reserved` range
|
||||
// (and `reserved` name) covering it in the same change so a future editor
|
||||
// cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
// declarations today because no field or enum value has ever been removed.
|
||||
|
||||
// Gateway-to-worker IPC envelope. Named-pipe framing prepends a little-endian
|
||||
// uint32 payload length to this protobuf payload.
|
||||
message WorkerEnvelope {
|
||||
uint32 protocol_version = 1;
|
||||
string session_id = 2;
|
||||
uint64 sequence = 3;
|
||||
string correlation_id = 4;
|
||||
|
||||
oneof body {
|
||||
GatewayHello gateway_hello = 10;
|
||||
WorkerHello worker_hello = 11;
|
||||
WorkerReady worker_ready = 12;
|
||||
WorkerCommand worker_command = 13;
|
||||
WorkerCommandReply worker_command_reply = 14;
|
||||
WorkerCancel worker_cancel = 15;
|
||||
WorkerShutdown worker_shutdown = 16;
|
||||
WorkerShutdownAck worker_shutdown_ack = 17;
|
||||
WorkerEvent worker_event = 18;
|
||||
WorkerHeartbeat worker_heartbeat = 19;
|
||||
WorkerFault worker_fault = 20;
|
||||
}
|
||||
}
|
||||
|
||||
message GatewayHello {
|
||||
uint32 supported_protocol_version = 1;
|
||||
string nonce = 2;
|
||||
string gateway_version = 3;
|
||||
}
|
||||
|
||||
message WorkerHello {
|
||||
uint32 protocol_version = 1;
|
||||
string nonce = 2;
|
||||
int32 worker_process_id = 3;
|
||||
string worker_version = 4;
|
||||
}
|
||||
|
||||
message WorkerReady {
|
||||
int32 worker_process_id = 1;
|
||||
string mxaccess_progid = 2;
|
||||
string mxaccess_clsid = 3;
|
||||
google.protobuf.Timestamp ready_timestamp = 4;
|
||||
}
|
||||
|
||||
message WorkerCommand {
|
||||
mxaccess_gateway.v1.MxCommand command = 1;
|
||||
google.protobuf.Timestamp enqueue_timestamp = 2;
|
||||
}
|
||||
|
||||
message WorkerCommandReply {
|
||||
mxaccess_gateway.v1.MxCommandReply reply = 1;
|
||||
google.protobuf.Timestamp completed_timestamp = 2;
|
||||
}
|
||||
|
||||
message WorkerCancel {
|
||||
string reason = 1;
|
||||
}
|
||||
|
||||
message WorkerShutdown {
|
||||
google.protobuf.Duration grace_period = 1;
|
||||
string reason = 2;
|
||||
}
|
||||
|
||||
message WorkerShutdownAck {
|
||||
mxaccess_gateway.v1.ProtocolStatus status = 1;
|
||||
}
|
||||
|
||||
message WorkerEvent {
|
||||
mxaccess_gateway.v1.MxEvent event = 1;
|
||||
}
|
||||
|
||||
message WorkerHeartbeat {
|
||||
int32 worker_process_id = 1;
|
||||
WorkerState state = 2;
|
||||
google.protobuf.Timestamp last_sta_activity_timestamp = 3;
|
||||
uint32 pending_command_count = 4;
|
||||
uint32 outbound_event_queue_depth = 5;
|
||||
uint64 last_event_sequence = 6;
|
||||
string current_command_correlation_id = 7;
|
||||
}
|
||||
|
||||
message WorkerFault {
|
||||
WorkerFaultCategory category = 1;
|
||||
string command_method = 2;
|
||||
optional int32 hresult = 3;
|
||||
string exception_type = 4;
|
||||
string diagnostic_message = 5;
|
||||
mxaccess_gateway.v1.ProtocolStatus protocol_status = 6;
|
||||
}
|
||||
|
||||
enum WorkerState {
|
||||
WORKER_STATE_UNSPECIFIED = 0;
|
||||
WORKER_STATE_STARTING = 1;
|
||||
WORKER_STATE_HANDSHAKING = 2;
|
||||
WORKER_STATE_INITIALIZING_STA = 3;
|
||||
WORKER_STATE_READY = 4;
|
||||
WORKER_STATE_EXECUTING_COMMAND = 5;
|
||||
WORKER_STATE_SHUTTING_DOWN = 6;
|
||||
WORKER_STATE_STOPPED = 7;
|
||||
WORKER_STATE_FAULTED = 8;
|
||||
}
|
||||
|
||||
enum WorkerFaultCategory {
|
||||
WORKER_FAULT_CATEGORY_UNSPECIFIED = 0;
|
||||
WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS = 1;
|
||||
WORKER_FAULT_CATEGORY_GATEWAY_AUTHENTICATION_FAILED = 2;
|
||||
WORKER_FAULT_CATEGORY_PROTOCOL_MISMATCH = 3;
|
||||
WORKER_FAULT_CATEGORY_PROTOCOL_VIOLATION = 4;
|
||||
WORKER_FAULT_CATEGORY_PIPE_DISCONNECTED = 5;
|
||||
WORKER_FAULT_CATEGORY_MXACCESS_CREATION_FAILED = 6;
|
||||
WORKER_FAULT_CATEGORY_MXACCESS_COMMAND_FAILED = 7;
|
||||
WORKER_FAULT_CATEGORY_MXACCESS_EVENT_CONVERSION_FAILED = 8;
|
||||
WORKER_FAULT_CATEGORY_STA_HUNG = 9;
|
||||
WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW = 10;
|
||||
WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT = 11;
|
||||
}
|
||||
+121
-8
@@ -18,7 +18,7 @@ use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGa
|
||||
use crate::generated::mxaccess_gateway::v1::{
|
||||
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AlarmFeedMessage,
|
||||
CloseSessionReply, CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent,
|
||||
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
|
||||
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, ReplayGap, StreamAlarmsRequest,
|
||||
StreamEventsRequest,
|
||||
};
|
||||
use crate::options::{build_tls_config, ClientOptions};
|
||||
@@ -28,11 +28,120 @@ use crate::session::Session;
|
||||
/// [`GatewayClient`] uses internally.
|
||||
pub type RawGatewayClient = MxAccessGatewayClient<InterceptedService<Channel, AuthInterceptor>>;
|
||||
|
||||
/// Pinned, boxed [`MxEvent`] stream returned by
|
||||
/// [`GatewayClient::stream_events`]. Errors are pre-mapped from
|
||||
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call.
|
||||
/// One item yielded by the per-session event stream returned by
|
||||
/// [`GatewayClient::stream_events`].
|
||||
///
|
||||
/// Almost every item is an ordinary MXAccess event ([`EventItem::Event`]).
|
||||
/// The one exception is the reconnect-replay gap sentinel
|
||||
/// ([`EventItem::ReplayGap`]): the gateway emits it at most once, at the head
|
||||
/// of a stream that was *resumed* via
|
||||
/// [`Session::events_after`](crate::session::Session::events_after)
|
||||
/// (`StreamEventsRequest.after_worker_sequence`) when the requested sequence is
|
||||
/// older than the oldest event still retained in the session replay ring — i.e.
|
||||
/// events were evicted and cannot be replayed.
|
||||
///
|
||||
/// The client does **not** synthesize this signal: it faithfully forwards the
|
||||
/// gateway's sentinel `MxEvent` (whose `replay_gap` field is set), and only
|
||||
/// makes it a distinct, typed variant so consumers can `match` on it instead of
|
||||
/// inspecting a field on a value that otherwise looks like a normal event.
|
||||
///
|
||||
/// # Reacting to a gap
|
||||
///
|
||||
/// A [`EventItem::ReplayGap`] means "you missed events — discard any local
|
||||
/// state and re-snapshot." The events in the open interval
|
||||
/// `(requested_after_sequence, oldest_available_sequence)` are gone. To resume
|
||||
/// the stream without provoking another gap, reconnect with
|
||||
/// [`Session::events_after`](crate::session::Session::events_after) passing
|
||||
/// `oldest_available_sequence - 1`, which replays starting at the first still
|
||||
/// retained event (`oldest_available_sequence`):
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use zb_mom_ww_mxgateway_client::{EventItem, Session};
|
||||
/// # use futures_util::StreamExt;
|
||||
/// # async fn run(session: Session, cursor: u64) -> Result<(), zb_mom_ww_mxgateway_client::Error> {
|
||||
/// let mut stream = session.events_after(cursor).await?;
|
||||
/// while let Some(item) = stream.next().await {
|
||||
/// match item? {
|
||||
/// EventItem::Event(event) => {
|
||||
/// let _ = event; // apply the change
|
||||
/// }
|
||||
/// EventItem::ReplayGap(gap) => {
|
||||
/// // Local state is stale — re-snapshot, then resume without a gap.
|
||||
/// let resume_cursor = gap.oldest_available_sequence.saturating_sub(1);
|
||||
/// stream = session.events_after(resume_cursor).await?;
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
// The `Event` variant is the hot path (nearly every stream item) and is the
|
||||
// large one; the rare `ReplayGap` sentinel is small. Boxing `Event` to equalize
|
||||
// the variants would add a heap allocation to every streamed event — a
|
||||
// regression versus the prior `Result<MxEvent, Error>` surface, which already
|
||||
// moved `MxEvent` by value. Keep the common path allocation-free.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum EventItem {
|
||||
/// A normal MXAccess event forwarded from the worker.
|
||||
Event(MxEvent),
|
||||
/// The reconnect-replay gap sentinel — recent event history was evicted
|
||||
/// before this resumed stream could replay it. See [`EventItem`] for how
|
||||
/// to react.
|
||||
ReplayGap(ReplayGap),
|
||||
}
|
||||
|
||||
impl EventItem {
|
||||
/// Classify an incoming `MxEvent` into the typed stream item.
|
||||
///
|
||||
/// A present `replay_gap` promotes the event to [`EventItem::ReplayGap`];
|
||||
/// otherwise it is an ordinary [`EventItem::Event`]. The sentinel is never
|
||||
/// dropped and never surfaced as a normal event.
|
||||
fn from_event(mut event: MxEvent) -> Self {
|
||||
match event.replay_gap.take() {
|
||||
Some(gap) => EventItem::ReplayGap(gap),
|
||||
None => EventItem::Event(event),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the inner [`MxEvent`] when this item is a normal event, or
|
||||
/// `None` when it is the [`EventItem::ReplayGap`] sentinel.
|
||||
#[must_use]
|
||||
pub fn as_event(&self) -> Option<&MxEvent> {
|
||||
match self {
|
||||
EventItem::Event(event) => Some(event),
|
||||
EventItem::ReplayGap(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the [`ReplayGap`] when this item is the reconnect-replay gap
|
||||
/// sentinel, or `None` for a normal event.
|
||||
#[must_use]
|
||||
pub fn replay_gap(&self) -> Option<&ReplayGap> {
|
||||
match self {
|
||||
EventItem::ReplayGap(gap) => Some(gap),
|
||||
EventItem::Event(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the item and return the inner [`MxEvent`] when it is a normal
|
||||
/// event, or `None` for the [`EventItem::ReplayGap`] sentinel.
|
||||
#[must_use]
|
||||
pub fn into_event(self) -> Option<MxEvent> {
|
||||
match self {
|
||||
EventItem::Event(event) => Some(event),
|
||||
EventItem::ReplayGap(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pinned, boxed [`EventItem`] stream returned by
|
||||
/// [`GatewayClient::stream_events`]. Each item is either a normal
|
||||
/// [`EventItem::Event`] or the [`EventItem::ReplayGap`] reconnect-replay
|
||||
/// sentinel. Errors are pre-mapped from `tonic::Status` to [`Error`]; dropping
|
||||
/// the stream cancels the call.
|
||||
pub type EventStream =
|
||||
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<MxEvent, Error>> + Send + 'static>>;
|
||||
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<EventItem, Error>> + Send + 'static>>;
|
||||
|
||||
/// Pinned, boxed [`ActiveAlarmSnapshot`] stream returned by
|
||||
/// [`GatewayClient::query_active_alarms`]. Errors are pre-mapped from
|
||||
@@ -190,8 +299,12 @@ impl GatewayClient {
|
||||
|
||||
/// Open the server-streaming `StreamEvents` RPC.
|
||||
///
|
||||
/// The returned [`EventStream`] yields `MxEvent` messages as the worker
|
||||
/// produces them. Dropping the stream cancels the gRPC call cooperatively.
|
||||
/// The returned [`EventStream`] yields [`EventItem`] values as the worker
|
||||
/// produces them: ordinary MXAccess events as [`EventItem::Event`], and the
|
||||
/// gateway's reconnect-replay gap sentinel — set only on resumed streams
|
||||
/// whose requested sequence predates the retained replay history — as
|
||||
/// [`EventItem::ReplayGap`]. Dropping the stream cancels the gRPC call
|
||||
/// cooperatively.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -201,7 +314,7 @@ impl GatewayClient {
|
||||
let mut client = self.inner.clone();
|
||||
let response = client.stream_events(self.stream_request(request)).await?;
|
||||
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
|
||||
result.map_err(Error::from)
|
||||
result.map(EventItem::from_event).map_err(Error::from)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
|
||||
@@ -24,12 +24,14 @@ pub mod version;
|
||||
#[doc(inline)]
|
||||
pub use auth::{ApiKey, AuthInterceptor};
|
||||
#[doc(inline)]
|
||||
pub use client::{AlarmFeedStream, EventStream, GatewayClient};
|
||||
pub use client::{AlarmFeedStream, EventItem, EventStream, GatewayClient};
|
||||
#[doc(inline)]
|
||||
pub use error::{CommandError, Error, MxAccessError};
|
||||
#[doc(inline)]
|
||||
pub use galaxy::{DeployEventStream, GalaxyClient};
|
||||
#[doc(inline)]
|
||||
pub use generated::mxaccess_gateway::v1::ReplayGap;
|
||||
#[doc(inline)]
|
||||
pub use options::ClientOptions;
|
||||
#[doc(inline)]
|
||||
pub use session::{next_correlation_id, Session};
|
||||
|
||||
+373
-9
@@ -15,16 +15,19 @@ use crate::error::{ensure_protocol_success, Error};
|
||||
use crate::generated::mxaccess_gateway::v1::mx_command::Payload;
|
||||
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
|
||||
use crate::generated::mxaccess_gateway::v1::{
|
||||
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand,
|
||||
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply,
|
||||
MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue,
|
||||
OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand,
|
||||
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand,
|
||||
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry,
|
||||
Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand,
|
||||
WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry,
|
||||
ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
|
||||
AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
|
||||
AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
|
||||
MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
|
||||
MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
|
||||
RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
|
||||
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
|
||||
UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, Write2BulkCommand,
|
||||
Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand,
|
||||
WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
|
||||
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand,
|
||||
};
|
||||
use crate::value::MxValue;
|
||||
use crate::value::{MxStatus, MxValue};
|
||||
|
||||
const MAX_BULK_ITEMS: usize = 1_000;
|
||||
|
||||
@@ -129,6 +132,25 @@ impl Session {
|
||||
register_server_handle(&reply)
|
||||
}
|
||||
|
||||
/// Run MXAccess `Unregister` to release the given `ServerHandle` and the
|
||||
/// items advised under it. Mirrors [`Session::register`]; the worker
|
||||
/// returns no payload, so the call resolves to `()` on success.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Command`] if the worker reports a non-OK protocol
|
||||
/// status and [`Error::MxAccess`] if MXAccess itself rejects the
|
||||
/// unregister (negative `hresult` / non-success status), plus the usual
|
||||
/// transport/status errors.
|
||||
pub async fn unregister(&self, server_handle: i32) -> Result<(), Error> {
|
||||
self.invoke(
|
||||
MxCommandKind::Unregister,
|
||||
Payload::Unregister(UnregisterCommand { server_handle }),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run MXAccess `AddItem` against `server_handle` and return the
|
||||
/// assigned `ItemHandle`.
|
||||
///
|
||||
@@ -230,6 +252,133 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run MXAccess `AdviseSupervisory` to start supervisory-mode change
|
||||
/// notifications for the given item. Mirrors [`Session::advise`]; the
|
||||
/// worker returns no payload, so the call resolves to `()` on success.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Command`] for a non-OK protocol status and
|
||||
/// [`Error::MxAccess`] when MXAccess reports a negative `hresult` /
|
||||
/// non-success status, plus the usual transport/status errors.
|
||||
pub async fn advise_supervisory(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
item_handle: i32,
|
||||
) -> Result<(), Error> {
|
||||
self.invoke(
|
||||
MxCommandKind::AdviseSupervisory,
|
||||
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
|
||||
server_handle,
|
||||
item_handle,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run MXAccess `Suspend` on the given item and return the native
|
||||
/// `MXSTATUS_PROXY` the worker reports for the operation.
|
||||
///
|
||||
/// A top-level MXAccess failure (negative `hresult` or a non-success
|
||||
/// top-level status) still surfaces as [`Error::MxAccess`] via the shared
|
||||
/// reply validation; the returned [`MxStatus`] is the per-operation status
|
||||
/// carried in the `SuspendReply` payload.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Command`] for a non-OK protocol status,
|
||||
/// [`Error::MxAccess`] on an MXAccess-level failure, and
|
||||
/// [`Error::MalformedReply`] if the OK reply lacks the `Suspend` payload,
|
||||
/// plus the usual transport/status errors.
|
||||
pub async fn suspend(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::Suspend,
|
||||
Payload::Suspend(SuspendCommand {
|
||||
server_handle,
|
||||
item_handle,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
suspend_status(reply)
|
||||
}
|
||||
|
||||
/// Run MXAccess `Activate` on the given item and return the native
|
||||
/// `MXSTATUS_PROXY` the worker reports for the operation. See
|
||||
/// [`Session::suspend`] for the status/error contract.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::suspend`] (with the `Activate` payload).
|
||||
pub async fn activate(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::Activate,
|
||||
Payload::Activate(ActivateCommand {
|
||||
server_handle,
|
||||
item_handle,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
activate_status(reply)
|
||||
}
|
||||
|
||||
/// Run MXAccess `AddBufferedItem` against `server_handle` and return the
|
||||
/// assigned `ItemHandle`. Mirrors [`Session::add_item2`] — the buffered
|
||||
/// item carries a caller-supplied context string.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Command`]/[`Error::MxAccess`] when the worker or
|
||||
/// MXAccess rejects the item, [`Error::MalformedReply`] if the OK reply
|
||||
/// lacks the item handle, plus the usual transport/status errors.
|
||||
pub async fn add_buffered_item(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
item_definition: &str,
|
||||
item_context: &str,
|
||||
) -> Result<i32, Error> {
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::AddBufferedItem,
|
||||
Payload::AddBufferedItem(AddBufferedItemCommand {
|
||||
server_handle,
|
||||
item_definition: item_definition.to_owned(),
|
||||
item_context: item_context.to_owned(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
add_buffered_item_handle(&reply)
|
||||
}
|
||||
|
||||
/// Run MXAccess `SetBufferedUpdateInterval` for `server_handle`. The
|
||||
/// worker returns no payload, so the call resolves to `()` on success.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
|
||||
/// status or an MXAccess-level failure, plus the usual transport/status
|
||||
/// errors.
|
||||
pub async fn set_buffered_update_interval(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
update_interval_milliseconds: i32,
|
||||
) -> Result<(), Error> {
|
||||
self.invoke(
|
||||
MxCommandKind::SetBufferedUpdateInterval,
|
||||
Payload::SetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand {
|
||||
server_handle,
|
||||
update_interval_milliseconds,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bulk variant of [`Session::add_item`]. Each tag address yields one
|
||||
/// `SubscribeResult` in the returned vector.
|
||||
///
|
||||
@@ -628,8 +777,151 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run MXAccess `WriteSecured` (single credential-verified write, no
|
||||
/// caller-supplied timestamp).
|
||||
///
|
||||
/// **MXAccess parity:** `WriteSecured` failing before a prior
|
||||
/// [`Session::authenticate_user`] + [`Session::advise_supervisory`], or
|
||||
/// before a value-bearing body, is the native contract, not a client bug —
|
||||
/// the failure surfaces as [`Error::MxAccess`] (negative `hresult`) and is
|
||||
/// **not** smoothed over. The `value` is credential-sensitive: it is placed
|
||||
/// only in the wire command and is never logged or embedded in an error
|
||||
/// message.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Command`] for a non-OK protocol status,
|
||||
/// [`Error::MxAccess`] when MXAccess rejects the secured write, plus the
|
||||
/// usual transport/status errors.
|
||||
pub async fn write_secured(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
item_handle: i32,
|
||||
current_user_id: i32,
|
||||
verifier_user_id: i32,
|
||||
value: MxValue,
|
||||
) -> Result<(), Error> {
|
||||
self.invoke(
|
||||
MxCommandKind::WriteSecured,
|
||||
Payload::WriteSecured(WriteSecuredCommand {
|
||||
server_handle,
|
||||
item_handle,
|
||||
current_user_id,
|
||||
verifier_user_id,
|
||||
value: Some(value.into_proto()),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run MXAccess `WriteSecured2` (credential-verified write with a
|
||||
/// caller-supplied timestamp). See [`Session::write_secured`] for the
|
||||
/// parity and credential-handling contract.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::write_secured`].
|
||||
pub async fn write_secured2(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
item_handle: i32,
|
||||
current_user_id: i32,
|
||||
verifier_user_id: i32,
|
||||
value: MxValue,
|
||||
timestamp_value: MxValue,
|
||||
) -> Result<(), Error> {
|
||||
self.invoke(
|
||||
MxCommandKind::WriteSecured2,
|
||||
Payload::WriteSecured2(WriteSecured2Command {
|
||||
server_handle,
|
||||
item_handle,
|
||||
current_user_id,
|
||||
verifier_user_id,
|
||||
value: Some(value.into_proto()),
|
||||
timestamp_value: Some(timestamp_value.into_proto()),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run MXAccess `AuthenticateUser` and return the resolved MXAccess user
|
||||
/// id.
|
||||
///
|
||||
/// **Credential handling:** `verify_user_password` is a raw MXAccess
|
||||
/// credential. It is placed only in the wire command; this helper never
|
||||
/// logs it and never embeds it in an [`Error`] — the only error text that
|
||||
/// can surface comes from `tonic::Status` messages and the reply's
|
||||
/// diagnostic fields, both of which are scrubbed by the client's
|
||||
/// credential-redaction seam (see [`crate::error`]). The reply itself
|
||||
/// carries no echo of the credential.
|
||||
///
|
||||
/// **MXAccess parity:** a failed authentication is a native outcome, not a
|
||||
/// client error to paper over — it surfaces as [`Error::MxAccess`]
|
||||
/// (negative `hresult`) with the credential absent from the message.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Command`] for a non-OK protocol status,
|
||||
/// [`Error::MxAccess`] when MXAccess rejects the credential,
|
||||
/// [`Error::MalformedReply`] if the OK reply lacks the user id, plus the
|
||||
/// usual transport/status errors.
|
||||
pub async fn authenticate_user(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
verify_user: &str,
|
||||
verify_user_password: &str,
|
||||
) -> Result<i32, Error> {
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::AuthenticateUser,
|
||||
Payload::AuthenticateUser(AuthenticateUserCommand {
|
||||
server_handle,
|
||||
verify_user: verify_user.to_owned(),
|
||||
verify_user_password: verify_user_password.to_owned(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
authenticate_user_id(&reply)
|
||||
}
|
||||
|
||||
/// Run MXAccess `ArchestrAUserToId` to resolve an ArchestrA user GUID to
|
||||
/// its MXAccess user id.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
|
||||
/// status or MXAccess-level failure, [`Error::MalformedReply`] if the OK
|
||||
/// reply lacks the user id, plus the usual transport/status errors.
|
||||
pub async fn archestra_user_to_id(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
user_id_guid: &str,
|
||||
) -> Result<i32, Error> {
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::ArchestraUserToId,
|
||||
Payload::ArchestraUserToId(ArchestrAUserToIdCommand {
|
||||
server_handle,
|
||||
user_id_guid: user_id_guid.to_owned(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
archestra_user_id(&reply)
|
||||
}
|
||||
|
||||
/// Open the per-session event stream from the beginning.
|
||||
///
|
||||
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
|
||||
/// values — normal MXAccess events as
|
||||
/// [`EventItem::Event`](crate::EventItem::Event). A stream opened from the
|
||||
/// beginning never produces a
|
||||
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap); that sentinel
|
||||
/// only appears on a resumed stream (see [`Session::events_after`]).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the `tonic::Status` mapped through [`Error::from`] when the
|
||||
@@ -642,6 +934,15 @@ impl Session {
|
||||
/// `worker_sequence` is greater than `after_worker_sequence`. Pass `0`
|
||||
/// to receive every buffered event.
|
||||
///
|
||||
/// If `after_worker_sequence` predates the oldest event still retained in
|
||||
/// the gateway's replay ring, the stream opens with a single
|
||||
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap) sentinel: recent
|
||||
/// history was evicted and cannot be replayed, so the caller must discard
|
||||
/// any local state and re-snapshot. To resume without provoking another
|
||||
/// gap, call this method again with
|
||||
/// `gap.oldest_available_sequence - 1`. See
|
||||
/// [`EventItem`](crate::EventItem) for the full contract.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::events`].
|
||||
@@ -753,6 +1054,69 @@ fn add_item2_handle(reply: &MxCommandReply) -> Result<i32, Error> {
|
||||
}
|
||||
}
|
||||
|
||||
fn add_buffered_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
|
||||
match reply.payload.as_ref() {
|
||||
Some(mx_command_reply::Payload::AddBufferedItem(add_buffered)) => {
|
||||
Ok(add_buffered.item_handle)
|
||||
}
|
||||
_ => reply
|
||||
.return_value
|
||||
.as_ref()
|
||||
.and_then(int32_reply_value)
|
||||
.ok_or_else(|| Error::MalformedReply {
|
||||
detail:
|
||||
"add_buffered_item reply lacked an item_handle payload or int32 return_value"
|
||||
.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
|
||||
match reply.payload.as_ref() {
|
||||
Some(mx_command_reply::Payload::AuthenticateUser(authenticate)) => Ok(authenticate.user_id),
|
||||
_ => Err(Error::MalformedReply {
|
||||
detail: "authenticate_user reply lacked an AuthenticateUser payload".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn archestra_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
|
||||
match reply.payload.as_ref() {
|
||||
Some(mx_command_reply::Payload::ArchestraUserToId(archestra)) => Ok(archestra.user_id),
|
||||
_ => Err(Error::MalformedReply {
|
||||
detail: "archestra_user_to_id reply lacked an ArchestraUserToId payload".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn suspend_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
|
||||
match reply.payload {
|
||||
Some(mx_command_reply::Payload::Suspend(suspend)) => suspend
|
||||
.status
|
||||
.map(MxStatus::from_proto)
|
||||
.ok_or_else(|| Error::MalformedReply {
|
||||
detail: "suspend reply payload lacked a status entry".to_owned(),
|
||||
}),
|
||||
_ => Err(Error::MalformedReply {
|
||||
detail: "suspend reply did not carry a Suspend payload".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
|
||||
match reply.payload {
|
||||
Some(mx_command_reply::Payload::Activate(activate)) => activate
|
||||
.status
|
||||
.map(MxStatus::from_proto)
|
||||
.ok_or_else(|| Error::MalformedReply {
|
||||
detail: "activate reply payload lacked a status entry".to_owned(),
|
||||
}),
|
||||
_ => Err(Error::MalformedReply {
|
||||
detail: "activate reply did not carry an Activate payload".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
enum BulkReplyKind {
|
||||
AddItem,
|
||||
AdviseItem,
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! The protocol versions track the values the gateway and worker negotiate on
|
||||
//! `OpenSession` and let test harnesses cross-check the wire contract.
|
||||
|
||||
/// Semantic version of this Rust client crate. Mirrors `Cargo.toml`.
|
||||
pub const CLIENT_VERSION: &str = "0.1.0-dev";
|
||||
/// Semantic version of this Rust client crate. Sourced from `Cargo.toml` at
|
||||
/// compile time so the two cannot drift.
|
||||
pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Public gateway gRPC protocol version this client targets.
|
||||
pub const GATEWAY_PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
@@ -23,17 +23,18 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind;
|
||||
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||
alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot,
|
||||
AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind,
|
||||
BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult,
|
||||
CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent,
|
||||
MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource,
|
||||
MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
|
||||
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, SessionState, StreamAlarmsRequest,
|
||||
StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry, WriteCommand,
|
||||
WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
||||
AuthenticateUserCommand, AuthenticateUserReply, BulkReadReply, BulkReadResult,
|
||||
BulkSubscribeReply, BulkWriteReply, BulkWriteResult, CloseSessionReply, CloseSessionRequest,
|
||||
MxCommandKind, MxCommandReply, MxDataType, MxEvent, MxEventFamily, MxSparseArray,
|
||||
MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
|
||||
OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
|
||||
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState,
|
||||
StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry,
|
||||
WriteCommand, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
|
||||
};
|
||||
use zb_mom_ww_mxgateway_client::{
|
||||
next_correlation_id, ApiKey, ClientOptions, CommandError, Error, GatewayClient, MxStatus,
|
||||
MxValue as ClientMxValue, MxValueProjection,
|
||||
next_correlation_id, ApiKey, ClientOptions, CommandError, Error, EventItem, GatewayClient,
|
||||
MxStatus, MxValue as ClientMxValue, MxValueProjection,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -128,8 +129,28 @@ async fn event_stream_preserves_order_and_drop_cancels_server_stream() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stream.next().await.unwrap().unwrap().worker_sequence, 1);
|
||||
assert_eq!(stream.next().await.unwrap().unwrap().worker_sequence, 2);
|
||||
assert_eq!(
|
||||
stream
|
||||
.next()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_event()
|
||||
.unwrap()
|
||||
.worker_sequence,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
stream
|
||||
.next()
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.as_event()
|
||||
.unwrap()
|
||||
.worker_sequence,
|
||||
2
|
||||
);
|
||||
|
||||
drop(stream);
|
||||
for _ in 0..20 {
|
||||
@@ -142,6 +163,55 @@ async fn event_stream_preserves_order_and_drop_cancels_server_stream() {
|
||||
assert!(state.stream_dropped.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_gap_sentinel_surfaces_as_typed_event_item() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
// Script a resumed stream: the reconnect-replay gap sentinel at the head
|
||||
// (family UNSPECIFIED, no body, `replay_gap` set) followed by a normal
|
||||
// event. The client must promote the sentinel to `EventItem::ReplayGap`
|
||||
// and leave the following event as a normal `EventItem::Event`.
|
||||
*state.stream_events_script.lock().await = Some(vec![
|
||||
MxEvent {
|
||||
replay_gap: Some(ReplayGap {
|
||||
requested_after_sequence: 5,
|
||||
oldest_available_sequence: 42,
|
||||
}),
|
||||
..MxEvent::default()
|
||||
},
|
||||
event(42),
|
||||
]);
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut stream = client
|
||||
.stream_events(StreamEventsRequest {
|
||||
session_id: "session-fixture".to_owned(),
|
||||
after_worker_sequence: 5,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// First item is the typed gap sentinel, not a normal event.
|
||||
let first = stream.next().await.unwrap().unwrap();
|
||||
match &first {
|
||||
EventItem::ReplayGap(gap) => {
|
||||
assert_eq!(gap.requested_after_sequence, 5);
|
||||
assert_eq!(gap.oldest_available_sequence, 42);
|
||||
}
|
||||
EventItem::Event(_) => panic!("expected a ReplayGap sentinel, got a normal event"),
|
||||
}
|
||||
// Accessor helpers reflect the variant.
|
||||
assert!(first.as_event().is_none());
|
||||
assert_eq!(first.replay_gap().unwrap().oldest_available_sequence, 42);
|
||||
|
||||
// The normal event that follows is unaffected.
|
||||
let second = stream.next().await.unwrap().unwrap();
|
||||
assert_eq!(second.as_event().unwrap().worker_sequence, 42);
|
||||
assert!(second.replay_gap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledge_alarm_returns_reply_with_native_status() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
@@ -555,6 +625,133 @@ async fn write_secured2_bulk_round_trips_through_the_fake_gateway() {
|
||||
assert_eq!(*last_command, Some(MxCommandKind::WriteSecured2Bulk as i32));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn advise_supervisory_round_trips_and_sends_advise_supervisory_kind() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = client.session("session-fixture");
|
||||
|
||||
session.advise_supervisory(12, 34).await.unwrap();
|
||||
|
||||
let last_command = state.last_command_kind.lock().await;
|
||||
assert_eq!(*last_command, Some(MxCommandKind::AdviseSupervisory as i32));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregister_round_trips_and_sends_unregister_kind() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = client.session("session-fixture");
|
||||
|
||||
session.unregister(12).await.unwrap();
|
||||
|
||||
let last_command = state.last_command_kind.lock().await;
|
||||
assert_eq!(*last_command, Some(MxCommandKind::Unregister as i32));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_secured_surfaces_native_mxaccess_failure_and_redacts_diagnostic() {
|
||||
// MXAccess parity: WriteSecured failing (e.g. before authenticate +
|
||||
// advise-supervisory) is a native outcome, surfaced — not smoothed over.
|
||||
// The scripted reply carries an Ok protocol envelope but a negative
|
||||
// hresult, so this also proves the typed helper runs ensure_mxaccess_success.
|
||||
let state = Arc::new(FakeState::default());
|
||||
*state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure);
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = client.session("session-fixture");
|
||||
|
||||
let error = session
|
||||
.write_secured(12, 34, 0, 0, ClientMxValue::int32(1))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let Error::MxAccess(mx_access) = &error else {
|
||||
panic!("write_secured must surface the native failure as Error::MxAccess: {error:?}");
|
||||
};
|
||||
assert_eq!(mx_access.reply().hresult, Some(-2_147_217_900));
|
||||
let rendered = error.to_string();
|
||||
assert!(rendered.contains("<redacted>"), "diagnostic: {rendered}");
|
||||
assert!(
|
||||
!rendered.contains("leaked_secret"),
|
||||
"credential-shaped diagnostic must be scrubbed: {rendered}"
|
||||
);
|
||||
|
||||
let last_command = state.last_command_kind.lock().await;
|
||||
assert_eq!(*last_command, Some(MxCommandKind::WriteSecured as i32));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticate_user_returns_user_id_and_transmits_credential_on_wire() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = client.session("session-fixture");
|
||||
|
||||
let user_id = session
|
||||
.authenticate_user(7, "verifier", "sup3r-s3cret-pw")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user_id, 4242);
|
||||
let captured = state
|
||||
.last_authenticate_user
|
||||
.lock()
|
||||
.await
|
||||
.take()
|
||||
.expect("fake should have captured an AuthenticateUserCommand");
|
||||
assert_eq!(captured.server_handle, 7);
|
||||
assert_eq!(captured.verify_user, "verifier");
|
||||
// The credential must reach the wire so authentication can succeed...
|
||||
assert_eq!(captured.verify_user_password, "sup3r-s3cret-pw");
|
||||
let last_command = state.last_command_kind.lock().await;
|
||||
assert_eq!(*last_command, Some(MxCommandKind::AuthenticateUser as i32));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticate_user_keeps_credentials_out_of_surfaced_errors() {
|
||||
// ...but a native authentication failure must never leak the credential
|
||||
// into the error's Display or Debug rendering.
|
||||
let state = Arc::new(FakeState::default());
|
||||
*state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure);
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = client.session("session-fixture");
|
||||
|
||||
let password = "unique-credential-9f3b2";
|
||||
let error = session
|
||||
.authenticate_user(7, "verifier", password)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::MxAccess(_)),
|
||||
"native auth failure should surface as Error::MxAccess: {error:?}"
|
||||
);
|
||||
let display = error.to_string();
|
||||
let debug = format!("{error:?}");
|
||||
assert!(
|
||||
!display.contains(password),
|
||||
"credential leaked into Display: {display}"
|
||||
);
|
||||
assert!(
|
||||
!debug.contains(password),
|
||||
"credential leaked into Debug: {debug}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
@@ -663,6 +860,10 @@ struct FakeState {
|
||||
/// Captures the last `WriteCommand` payload received, populated when the
|
||||
/// `WriteOk` override is active. Used by `write_array_elements` e2e test.
|
||||
last_write_command: Mutex<Option<WriteCommand>>,
|
||||
/// Captures the last `AuthenticateUserCommand` payload received, populated
|
||||
/// by the `AuthenticateUser` happy-path handler so a test can confirm the
|
||||
/// credential reaches the wire (but never a surfaced error).
|
||||
last_authenticate_user: Mutex<Option<AuthenticateUserCommand>>,
|
||||
stream_dropped: Arc<AtomicBool>,
|
||||
/// Optional per-test override that pins the fake's `Invoke` handler to
|
||||
/// a specific reply shape (or `Err(Status)`). The default of `None`
|
||||
@@ -672,6 +873,11 @@ struct FakeState {
|
||||
/// handler to emit a synthetic ConditionRefresh -> snapshot_complete
|
||||
/// -> transition sequence.
|
||||
stream_alarms_script: Mutex<Option<Vec<AlarmFeedMessage>>>,
|
||||
/// Optional per-test override that pins the fake's `StreamEvents`
|
||||
/// handler to emit a scripted `MxEvent` sequence (e.g. a `replay_gap`
|
||||
/// sentinel followed by a normal event). When `None`, the handler falls
|
||||
/// back to the default `event(1)` / `event(2)` pair.
|
||||
stream_events_script: Mutex<Option<Vec<MxEvent>>>,
|
||||
}
|
||||
|
||||
/// Per-test override for the fake's `Invoke` handler.
|
||||
@@ -691,6 +897,12 @@ enum InvokeOverride {
|
||||
/// and capture the decoded `WriteCommand` in
|
||||
/// `FakeState::last_write_command` for inspection.
|
||||
WriteOk,
|
||||
/// Reply with an `Ok` protocol envelope but a negative `hresult` and a
|
||||
/// non-success status entry carrying a credential-shaped diagnostic. This
|
||||
/// mimics the worker's COMException path (e.g. `WriteSecured` /
|
||||
/// `AuthenticateUser` rejected by MXAccess) so the client's
|
||||
/// `ensure_mxaccess_success` check is exercised on the typed helper path.
|
||||
MxAccessFailure,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -772,6 +984,27 @@ impl MxAccessGateway for FakeGateway {
|
||||
..MxCommandReply::default()
|
||||
})),
|
||||
InvokeOverride::Unavailable(message) => Err(Status::unavailable(message)),
|
||||
InvokeOverride::MxAccessFailure => Ok(Response::new(MxCommandReply {
|
||||
session_id: request.session_id,
|
||||
correlation_id: "fake-correlation".to_owned(),
|
||||
kind,
|
||||
// Protocol envelope succeeds; MXAccess itself failed.
|
||||
protocol_status: Some(ok_status("command ok")),
|
||||
// 0x80040E14 (a COM failure) as a signed 32-bit value.
|
||||
hresult: Some(-2_147_217_900),
|
||||
statuses: vec![MxStatusProxy {
|
||||
success: 0,
|
||||
category: MxStatusCategory::SecurityError as i32,
|
||||
detected_by: MxStatusSource::RespondingLmx as i32,
|
||||
detail: 123,
|
||||
// A credential-shaped token that must be scrubbed from
|
||||
// any surfaced diagnostic text.
|
||||
diagnostic_text: "denied for mxgw_leaked_secret".to_owned(),
|
||||
..MxStatusProxy::default()
|
||||
}],
|
||||
payload: None,
|
||||
..MxCommandReply::default()
|
||||
})),
|
||||
InvokeOverride::WriteOk => {
|
||||
// Extract and capture the WriteCommand payload so the test
|
||||
// can assert on server_handle, item_handle, user_id, and value.
|
||||
@@ -903,6 +1136,26 @@ impl MxAccessGateway for FakeGateway {
|
||||
)));
|
||||
}
|
||||
|
||||
if kind == MxCommandKind::AuthenticateUser as i32 {
|
||||
// Capture the transmitted command so a test can confirm the
|
||||
// credential reaches the wire but never an error message.
|
||||
if let Some(mx_command::Payload::AuthenticateUser(auth)) =
|
||||
request.command.and_then(|command| command.payload)
|
||||
{
|
||||
*self.state.last_authenticate_user.lock().await = Some(auth);
|
||||
}
|
||||
return Ok(Response::new(MxCommandReply {
|
||||
session_id: request.session_id,
|
||||
correlation_id: "fake-correlation".to_owned(),
|
||||
kind,
|
||||
protocol_status: Some(ok_status("command ok")),
|
||||
payload: Some(mx_command_reply::Payload::AuthenticateUser(
|
||||
AuthenticateUserReply { user_id: 4242 },
|
||||
)),
|
||||
..MxCommandReply::default()
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Response::new(MxCommandReply {
|
||||
session_id: request.session_id,
|
||||
correlation_id: "fake-correlation".to_owned(),
|
||||
@@ -921,9 +1174,12 @@ impl MxAccessGateway for FakeGateway {
|
||||
&self,
|
||||
_request: Request<StreamEventsRequest>,
|
||||
) -> Result<Response<Self::StreamEventsStream>, Status> {
|
||||
let (sender, receiver) = mpsc::channel(4);
|
||||
sender.send(Ok(event(1))).await.unwrap();
|
||||
sender.send(Ok(event(2))).await.unwrap();
|
||||
let script = self.state.stream_events_script.lock().await.take();
|
||||
let events = script.unwrap_or_else(|| vec![event(1), event(2)]);
|
||||
let (sender, receiver) = mpsc::channel(events.len().max(1));
|
||||
for event in events {
|
||||
sender.send(Ok(event)).await.unwrap();
|
||||
}
|
||||
|
||||
Ok(Response::new(DropAwareStream {
|
||||
inner: ReceiverStream::new(receiver),
|
||||
|
||||
+193
-211
@@ -1,200 +1,224 @@
|
||||
# Gateway Authentication
|
||||
|
||||
The gateway authentication subsystem verifies inbound API key credentials against a SQLite-backed key store, hashes secrets with a configurable pepper, and records administrative and verification events to an audit trail.
|
||||
The gateway authenticates inbound gRPC callers with API keys: a bearer token is
|
||||
parsed, its secret is hashed with a peppered HMAC and compared in constant time
|
||||
against a stored hash, and administrative and verification events are recorded to
|
||||
an audit trail.
|
||||
|
||||
The peppered-HMAC pipeline itself — token parsing, secret generation, hashing,
|
||||
constant-time compare, the SQLite schema, the key store, the verifier, and schema
|
||||
migration — lives in the shared **`ZB.MOM.WW.Auth.ApiKeys`** package, of which
|
||||
this gateway is the donor. The gateway does not reimplement or fork those types;
|
||||
it binds the library through `AddZbApiKeyAuth` and layers gateway-specific
|
||||
concerns on top: constraint enforcement, the gRPC authorization interceptor,
|
||||
hot-path decorators, the admin CLI, the dashboard, and a canonical audit store
|
||||
that supersedes the library's own audit table. This document describes the
|
||||
consumer side — the token format, the options the gateway binds, the pieces it
|
||||
adds, and where the library boundary sits. For the library internals (the concrete
|
||||
`ApiKeyVerifier`, the SQLite stores, the schema and migrator), read the
|
||||
`ZB.MOM.WW.Auth.ApiKeys` sources; they are not duplicated in this repository.
|
||||
|
||||
## Token Format
|
||||
|
||||
API keys travel in the HTTP `Authorization` header as a bearer token shaped `mxgw_<keyId>_<secret>`. The `mxgw_` prefix scopes parsing to gateway tokens, the `<keyId>` segment is the public identifier used for lookup, and `<secret>` is the high-entropy portion that the gateway verifies against a stored hash.
|
||||
API keys travel in the HTTP `Authorization` header as a bearer token shaped
|
||||
`mxgw_<keyId>_<secret>`. The `mxgw_` prefix scopes parsing to gateway tokens, the
|
||||
`<keyId>` segment is the public identifier used for lookup, and `<secret>` is the
|
||||
high-entropy portion verified against a stored hash. The prefix and the pepper
|
||||
configuration key the gateway pins are constants on
|
||||
`AuthStoreServiceCollectionExtensions`
|
||||
(`TokenPrefix = "mxgw"`, `PepperSecretName = "MxGateway:ApiKeyPepper"`); they are
|
||||
supplied to the library at registration so the library's parser and pepper
|
||||
provider use the gateway's contract. The library parser rejects a malformed token
|
||||
before any database round-trip, and only a well-formed `mxgw_<keyId>_<secret>`
|
||||
token reaches the store lookup.
|
||||
|
||||
`ApiKeyParser` enforces the format and rejects malformed tokens before any database round-trip:
|
||||
## Secrets And Peppered Hashing
|
||||
|
||||
```csharp
|
||||
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
|
||||
{
|
||||
apiKey = null;
|
||||
New secret material is high-entropy: the library generates 32 random bytes and
|
||||
encodes them URL-safe base64 (no padding) so a secret embeds in a header without
|
||||
escaping. The gateway never persists a plaintext secret — only its hash.
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader)
|
||||
|| !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Secrets are hashed with `HMAC-SHA256` keyed by a server-side **pepper**. The
|
||||
pepper lives outside the database and is resolved from configuration under the
|
||||
`MxGateway:ApiKeyPepper` key (the library's pepper provider reads it). Keeping the
|
||||
pepper out of the SQLite file means an attacker who exfiltrates only the database
|
||||
holds the hashes but lacks the keying material to brute-force candidate secrets,
|
||||
even if the hash algorithm is known.
|
||||
|
||||
string token = authorizationHeader[BearerPrefix.Length..].Trim();
|
||||
|
||||
if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
A successful parse produces a `ParsedApiKey(KeyId, Secret)` record. The `IApiKeyParser` interface exists so verification consumers can be tested without depending on header-format details.
|
||||
|
||||
## Parsing and Secrets
|
||||
|
||||
### Secret generation
|
||||
|
||||
`ApiKeySecretGenerator.Generate()` is the single source of new secret material. It uses 32 bytes from `RandomNumberGenerator.Fill` and encodes with URL-safe base64 (no padding) so secrets can be embedded in headers without escaping:
|
||||
|
||||
```csharp
|
||||
public static string Generate()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
```
|
||||
|
||||
### Peppered hashing
|
||||
|
||||
`ApiKeySecretHasher` (registered behind `IApiKeySecretHasher`) hashes secrets with `HMACSHA256` keyed by a server-side pepper. The pepper lives outside the database and is resolved by `IConfiguration` lookup against the configured `PepperSecretName`:
|
||||
|
||||
```csharp
|
||||
public byte[] HashSecret(string secret)
|
||||
{
|
||||
string pepper = GetPepper();
|
||||
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
|
||||
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
|
||||
using HMACSHA256 hmac = new(pepperBytes);
|
||||
|
||||
return hmac.ComputeHash(secretBytes);
|
||||
}
|
||||
```
|
||||
|
||||
The pepper is intentionally not stored alongside the hash: an attacker who exfiltrates only the SQLite file holds the hashes but lacks the keying material to brute-force candidate secrets, even if the stored hash algorithm and salt scheme are known. If the pepper is missing the hasher throws `ApiKeyPepperUnavailableException`, which the verifier converts to a distinct failure code rather than treating it as a credential mismatch.
|
||||
When the pepper is not configured, the library surfaces the failure as an
|
||||
`InvalidOperationException` whose message reports the pepper is unavailable rather
|
||||
than persisting a key with an unkeyed hash. The dashboard management path
|
||||
(`DashboardApiKeyManagementService`) catches that condition and returns the
|
||||
friendly "API key pepper is not configured." result instead of faulting the Blazor
|
||||
circuit; it currently matches on the message text, so a library wording change
|
||||
would need to be reflected there (a typed pepper-unavailable exception is a pending
|
||||
library improvement).
|
||||
|
||||
## Verification
|
||||
|
||||
`ApiKeyVerifier` (`IApiKeyVerifier`) implements the verification flow:
|
||||
The gateway consumes the library's `IApiKeyVerifier` from
|
||||
`GatewayGrpcAuthorizationInterceptor`. The verifier's flow is:
|
||||
|
||||
1. Parse the `Authorization` header into a `ParsedApiKey`.
|
||||
2. Look up the `ApiKeyRecord` by `KeyId` through `IApiKeyStore.FindByKeyIdAsync`.
|
||||
3. Reject revoked records (`RevokedUtc is not null`).
|
||||
1. Parse the `Authorization` header into the key id and presented secret.
|
||||
2. Look up the stored key record by key id.
|
||||
3. Reject a revoked record, and reject an expired record whose `ExpiresUtc` is in
|
||||
the past. Expiry is opt-in — keys created without an expiry never expire; an
|
||||
expired key fails opaquely, indistinguishable to the client from any other auth
|
||||
failure.
|
||||
4. Hash the presented secret with the configured pepper.
|
||||
5. Compare hashes with `CryptographicOperations.FixedTimeEquals` to avoid timing oracles.
|
||||
6. Record a `LastUsedUtc` timestamp via `MarkKeyUsedAsync` and return an `ApiKeyIdentity`.
|
||||
5. Compare hashes in constant time to avoid a timing oracle.
|
||||
6. Stamp a `LastUsedUtc` timestamp and return a shared `ApiKeyIdentity` carrying
|
||||
the key id, key prefix, display name, scopes, and the opaque constraints JSON.
|
||||
|
||||
```csharp
|
||||
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
|
||||
}
|
||||
A verification failure is opaque to the client: the interceptor returns
|
||||
`Unauthenticated`/`PermissionDenied` without disclosing which check failed, while
|
||||
the failure detail is available server-side for audit.
|
||||
|
||||
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
`GatewayApiKeyIdentityMapper.ToGatewayIdentity` maps the library's shared
|
||||
`ApiKeyIdentity` onto the gateway's own `ApiKeyIdentity`
|
||||
(`Security/Authentication/ApiKeyIdentity.cs`), which exposes the deserialized
|
||||
`ApiKeyConstraints` — parsed from the opaque constraints JSON via
|
||||
`ApiKeyConstraintSerializer` — that the downstream `ConstraintEnforcer` and the
|
||||
request-identity accessor enforce. The gateway identity exposes only non-secret
|
||||
fields (`KeyId`, `KeyPrefix`, `DisplayName`, `Scopes`, `Constraints`).
|
||||
|
||||
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
||||
KeyId: storedKey.KeyId,
|
||||
KeyPrefix: storedKey.KeyPrefix,
|
||||
DisplayName: storedKey.DisplayName,
|
||||
Scopes: storedKey.Scopes,
|
||||
Constraints: storedKey.Constraints));
|
||||
```
|
||||
### Hot-path caching and last-used coalescing
|
||||
|
||||
`ApiKeyVerificationResult` carries either an `ApiKeyIdentity` or a discriminated `ApiKeyVerificationFailure` value. The failure enum distinguishes parse errors, missing pepper, missing or revoked keys, and secret mismatch so the calling middleware can emit precise audit detail without leaking which check failed to the client.
|
||||
Left unmediated, every authenticated gRPC call costs a SQLite read plus a
|
||||
`last_used_utc` **write** (the library verifier couples `MarkUsed` into
|
||||
`VerifyAsync`), which makes the auth store the throughput ceiling on the
|
||||
bulk-read workload. The gateway layers two decorators over the shared library's
|
||||
registrations (in `AuthStoreServiceCollectionExtensions`) — it does not edit the
|
||||
library:
|
||||
|
||||
`ApiKeyIdentity` exposes only non-secret fields (`KeyId`, `KeyPrefix`,
|
||||
`DisplayName`, `Scopes`, and `Constraints`) and is the type downstream
|
||||
authorization code consumes.
|
||||
- **`CachingApiKeyVerifier`** wraps the library `IApiKeyVerifier` with an
|
||||
`IMemoryCache` entry per successful verification, keyed on a SHA-256 hash of the
|
||||
presented token (never the plaintext secret). A cache hit within
|
||||
`MxGateway:Security:ApiKeyVerificationCacheSeconds` (default 15 s) returns the
|
||||
cached result without touching the store, so both the read and the coupled write
|
||||
are skipped. Only successes are cached; failures always reach the inner verifier.
|
||||
On a gateway-initiated revoke/rotate/delete the dashboard admin service calls
|
||||
`IApiKeyCacheInvalidator.Invalidate(keyId)`, evicting the cached entry
|
||||
immediately. The short TTL is the backstop for out-of-band mutations (a direct DB
|
||||
edit, or a revoke run by the separate `apikey` CLI process, whose in-memory cache
|
||||
is not the running gateway's cache).
|
||||
- **`CoalescingMarkApiKeyStore`** wraps the library `IApiKeyStore` and forwards at
|
||||
most one `MarkUsed` write per key per
|
||||
`MxGateway:Security:ApiKeyLastUsedCoalesceSeconds` (default 60 s), so even under a
|
||||
cache miss the `last_used_utc` write is bounded to roughly one per key per minute
|
||||
rather than one per RPC. `last_used_utc` is a coarse staleness hint, not an audit
|
||||
record (audit rows are written separately), so bounded staleness of up to one
|
||||
window is acceptable.
|
||||
|
||||
`GatewayApiKeyIdentityMapper` additionally memoizes the constraints-JSON
|
||||
deserialization by blob, so the per-call parse on the mapped identity collapses to
|
||||
a dictionary lookup. Both windows are configurable and may be set to `0` to disable
|
||||
the respective mechanism; see
|
||||
[GatewayConfiguration](./GatewayConfiguration.md).
|
||||
|
||||
## Storage
|
||||
|
||||
The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data.
|
||||
API-key state lives in a dedicated SQLite database owned by the shared library.
|
||||
SQLite is sufficient because credential volume is small, the gateway runs as a
|
||||
single process, and the file is straightforward to back up and rotate independently
|
||||
of the main application data.
|
||||
|
||||
### Connection factory
|
||||
The database path is `GatewayOptions.Authentication.SqlitePath`. Its code default
|
||||
is derived from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)`
|
||||
(`C:\ProgramData\MxGateway\gateway-auth.db` on Windows,
|
||||
`/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) so the
|
||||
credential store is never written relative to the launch working directory on a
|
||||
non-Windows host. The production hosts pin the explicit Windows path in
|
||||
`appsettings.json`. `GatewayOptionsValidator` rejects a non-rooted (relative)
|
||||
`SqlitePath` so a bad override fails fast at startup rather than scattering the store
|
||||
by launch CWD (SEC-01).
|
||||
|
||||
`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`:
|
||||
|
||||
```csharp
|
||||
SqliteConnectionStringBuilder builder = new()
|
||||
{
|
||||
DataSource = sqlitePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = true,
|
||||
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
|
||||
};
|
||||
```
|
||||
|
||||
Every store opens its connection through `OpenConnectionAsync`, which opens the connection and then applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout`. WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op; `busy_timeout` is per-connection state. Because `MarkKeyUsedAsync` runs on every authenticated request and `SqliteApiKeyAuditStore` appends on every denial, this lets concurrent readers and writers retry briefly instead of surfacing `SQLITE_BUSY` as a hard failure on the request path.
|
||||
|
||||
### Schema
|
||||
|
||||
`SqliteAuthSchema` declares table names and the current schema version as constants. Three tables are involved:
|
||||
|
||||
- `api_keys` stores `key_id`, `key_prefix`, the `secret_hash` blob,
|
||||
`display_name`, serialized `scopes`, optional serialized `constraints`, and
|
||||
the `created_utc`, `last_used_utc`, and `revoked_utc` timestamps.
|
||||
- `api_key_audit` is an append-only log keyed by an autoincrement `audit_id` with `key_id`, `event_type`, `remote_address`, `created_utc`, and `details` columns.
|
||||
- `schema_version` carries a single row whose `version` column is matched against `SqliteAuthSchema.CurrentVersion`.
|
||||
|
||||
### Read paths
|
||||
|
||||
`SqliteApiKeyStore` (`IApiKeyStore`) handles the two reads needed at request time: `FindByKeyIdAsync` returns any record (so revoked keys can be reported distinctly) and `FindActiveByKeyIdAsync` filters to non-revoked rows. `MarkKeyUsedAsync` updates `last_used_utc` only for non-revoked rows so a freshly revoked key cannot have its timestamp refreshed by a racing verification.
|
||||
|
||||
`ApiKeyRecord` is the in-memory projection. `ApiKeyRecordReader.Read` is shared by every read path so column ordering is defined in one place:
|
||||
|
||||
```csharp
|
||||
public static ApiKeyRecord Read(SqliteDataReader reader)
|
||||
{
|
||||
return new ApiKeyRecord(
|
||||
KeyId: reader.GetString(0),
|
||||
KeyPrefix: reader.GetString(1),
|
||||
SecretHash: (byte[])reader["secret_hash"],
|
||||
DisplayName: reader.GetString(3),
|
||||
Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
|
||||
Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)),
|
||||
CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: ReadNullableDateTimeOffset(reader, 7),
|
||||
RevokedUtc: ReadNullableDateTimeOffset(reader, 8));
|
||||
}
|
||||
```
|
||||
|
||||
### Write paths
|
||||
|
||||
`SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable, and `DeleteAsync` permanently removes a row but only when `revoked_utc IS NOT NULL` — active keys are untouched (returns false) so the revoke event lands in the audit log before the row disappears.
|
||||
|
||||
Because `RotateAsync` clears `revoked_utc`, rotating a previously revoked key reactivates it. The dashboard API Keys page therefore offers the Rotate (and Revoke) actions only for keys whose status is `Active`; revoked keys instead show a Delete action that calls `DeleteAsync`, so an operator can permanently remove a revoked row without ever risking un-revocation as a side effect of a rotation.
|
||||
The library owns the SQLite schema and connection factory. The `api_keys` table
|
||||
carries the key id, key prefix, secret-hash blob, display name, serialized scopes,
|
||||
optional serialized constraints, and the `created_utc`, `last_used_utc`,
|
||||
`revoked_utc`, and `expires_utc` timestamps. Because the schema, stores, and migrator
|
||||
belong to `ZB.MOM.WW.Auth.ApiKeys`, this document does not restate their column
|
||||
readers or SQL; consult the library for that detail.
|
||||
|
||||
### Audit trail
|
||||
|
||||
`SqliteApiKeyAuditStore` (`IApiKeyAuditStore`) appends `ApiKeyAuditEntry` values to the `api_key_audit` table and stamps each row with a UTC timestamp inside the store rather than trusting the caller. `ListRecentAsync` returns the most recent rows ordered by `audit_id` descending and projects them into `ApiKeyAuditRecord`. Rows are kept even after the referenced key is revoked because the audit history is the durable record of administrative action; the `key_id` column is nullable to accommodate non-key-scoped events such as `init-db`.
|
||||
The library emits its own API-key audit entries (from the admin verbs — create,
|
||||
revoke, rotate, `init-db`, and constraint denials), but the gateway **overrides**
|
||||
the library's `IApiKeyAuditStore` registration with
|
||||
`CanonicalForwardingApiKeyAuditStore`. That adapter canonicalizes every
|
||||
library-emitted `ApiKeyAuditEntry` onto the gateway's `AuditEvent` shape and routes
|
||||
it through `IAuditWriter` (`CanonicalAuditWriter`) into `SqliteCanonicalAuditStore`,
|
||||
which persists to a single **`audit_event`** table (columns `event_id`,
|
||||
`occurred_at_utc`, `actor`, `action`, `outcome`, `category`, `target`,
|
||||
`source_node`, `correlation_id`, `details_json`). Reads for the dashboard "recent
|
||||
audit" view go back through the same adapter, which maps `audit_event` rows back to
|
||||
`ApiKeyAuditEntry` values so the existing view keeps working unchanged.
|
||||
|
||||
## Migration
|
||||
Consequently the library's own `api_key_audit` table is left in place but
|
||||
**unused** after adoption — nothing writes to it once the override is registered.
|
||||
The canonical `audit_event` table is the single durable record of both API-key
|
||||
administrative actions and the dashboard's own audit vocabulary
|
||||
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`,
|
||||
`dashboard-delete-key`, and the session Close/Kill actions). This is why any prose
|
||||
that describes credential audits as landing in `api_key_audit` is stale: the
|
||||
canonical store is `audit_event`.
|
||||
|
||||
Schema bring-up is centralised behind `IAuthStoreMigrator`. `SqliteAuthStoreMigrator` executes the migration inside a single transaction so a partial failure leaves the database untouched, refuses to start when the on-disk schema version is newer than the binary supports, and idempotently creates the v1 schema:
|
||||
## Registration
|
||||
|
||||
`AuthStoreServiceCollectionExtensions.AddSqliteAuthStore(IConfiguration)` wires the
|
||||
whole subsystem. It does not register the library types directly — it delegates to
|
||||
the shared provider and then layers the gateway concerns:
|
||||
|
||||
```csharp
|
||||
if (existingVersion > SqliteAuthSchema.CurrentVersion)
|
||||
public static IServiceCollection AddSqliteAuthStore(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
throw new AuthStoreMigrationException(
|
||||
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
|
||||
// Pin the gateway's token prefix ("mxgw") and pepper key ("MxGateway:ApiKeyPepper")
|
||||
// as fallback defaults UNDER the supplied configuration, then register the shared
|
||||
// provider: it binds ApiKeyOptions from MxGateway:Authentication and wires the SQLite
|
||||
// stores, the configuration-backed pepper provider, the verifier, the migrator, and
|
||||
// the migration hosted service.
|
||||
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
|
||||
|
||||
// SEC-08 hot-path decorators layered over the library registrations.
|
||||
services.AddMemoryCache();
|
||||
// CoalescingMarkApiKeyStore decorates IApiKeyStore; CachingApiKeyVerifier decorates
|
||||
// IApiKeyVerifier and also serves as IApiKeyCacheInvalidator.
|
||||
|
||||
// Canonical audit: override the library's IApiKeyAuditStore so every API-key audit
|
||||
// event is forwarded through IAuditWriter into the audit_event table.
|
||||
services.AddSingleton<IApiKeyAuditStore, CanonicalForwardingApiKeyAuditStore>();
|
||||
|
||||
// The shared admin command set (ApiKeyAdminCommands) and the gateway CLI runner.
|
||||
services.AddSingleton<ApiKeyAdminCliRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
```
|
||||
|
||||
`AuthStoreMigrationHostedService` runs the migrator at startup, but only when API-key authentication is enabled and `RunMigrationsOnStartup` is true. Operators who manage schema out-of-band can disable the hosted run and use the admin CLI's `init-db` command instead.
|
||||
|
||||
`AuthStoreMigrationException` is a sealed `InvalidOperationException` so it can be caught precisely without swallowing unrelated failures.
|
||||
The decorators wrap the library's last registration for each interface rather than
|
||||
replacing the library types, preserving singleton semantics; the audit override is
|
||||
registered after `AddZbApiKeyAuth` so it wins as the resolved `IApiKeyAuditStore`.
|
||||
|
||||
## Admin CLI
|
||||
|
||||
`ApiKeyAdminCommandLineParser.Parse` recognises a leading `apikey` argument and dispatches to one of the subcommands declared by `ApiKeyAdminCommandKind`. Each parsed invocation produces an `ApiKeyAdminCommand` (or an `ApiKeyAdminParseResult` carrying an error). `ApiKeyAdminCliRunner` then executes the command, runs the migrator first, calls the relevant store method, appends an audit row, and writes either text or JSON output via `ApiKeyAdminOutput`. The returned `ApiKeyAdminListedKey` projection deliberately omits the `secret_hash` so listing a database does not surface hash material.
|
||||
`ApiKeyAdminCommandLineParser.Parse` recognises a leading `apikey` argument and
|
||||
dispatches to one of the subcommands declared by `ApiKeyAdminCommandKind`. Each
|
||||
parsed invocation produces an `ApiKeyAdminCommand` (or an `ApiKeyAdminParseResult`
|
||||
carrying an error). `ApiKeyAdminCliRunner` then runs the migrator, invokes the shared
|
||||
`ApiKeyAdminCommands` verb, and writes text or JSON output via `ApiKeyAdminOutput`.
|
||||
The returned `ApiKeyAdminListedKey` projection deliberately omits the secret hash so
|
||||
listing a database never surfaces hash material.
|
||||
|
||||
The supported subcommands match `ApiKeyAdminCommandKind` exactly:
|
||||
|
||||
| Subcommand | Required options | Behaviour |
|
||||
|------------|------------------|-----------|
|
||||
| `init-db` | none | Runs the migrator and records an audit entry. |
|
||||
| `create-key` | `--key-id`, `--display-name` | Generates a new secret, stores its peppered hash and optional constraints, and prints the assembled `mxgw_<keyId>_<secret>` token. |
|
||||
| `list-keys` | none | Lists every stored key with its scopes, constraints, and revocation state. |
|
||||
| `revoke-key` | `--key-id` | Sets `revoked_utc` if the key is currently active. |
|
||||
| `create-key` | `--key-id`, `--display-name` | Generates a new secret, stores its peppered hash and optional constraints, and prints the assembled `mxgw_<keyId>_<secret>` token. Optional `--expires` sets an expiry (absolute ISO-8601 UTC, or a relative `<N>d`/`<N>h` from now); omit it for a non-expiring key. |
|
||||
| `list-keys` | none | Lists every stored key with its scopes, constraints, revocation state, and expiry (`active`/`expired`/`revoked`). |
|
||||
| `revoke-key` | `--key-id` | Marks the key revoked if it is currently active. |
|
||||
| `rotate-key` | `--key-id` | Replaces the secret hash and prints the new token. |
|
||||
|
||||
Examples:
|
||||
@@ -203,6 +227,8 @@ Examples:
|
||||
mxgateway apikey init-db
|
||||
mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes read,write
|
||||
mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*"
|
||||
mxgateway apikey create-key --key-id ops.temp --display-name "Temp contractor" --scopes invoke:read --expires 90d
|
||||
mxgateway apikey create-key --key-id ops.audit --display-name "Audit window" --scopes metadata:read --expires 2027-01-01T00:00:00Z
|
||||
mxgateway apikey list-keys --json
|
||||
mxgateway apikey revoke-key --key-id ops.alice
|
||||
mxgateway apikey rotate-key --key-id ops.alice
|
||||
@@ -211,68 +237,24 @@ mxgateway apikey rotate-key --key-id ops.alice
|
||||
Constraint flags are optional. `--read-subtree`, `--write-subtree`,
|
||||
`--read-tag-glob`, `--write-tag-glob`, and `--browse-subtree` are repeatable.
|
||||
`--max-write-classification` accepts one integer. `--read-alarm-only` and
|
||||
`--read-historized-only` are boolean flags. Existing rows with null
|
||||
constraints remain fully unconstrained after migration.
|
||||
`--read-historized-only` are boolean flags. Existing rows with null constraints
|
||||
remain fully unconstrained after migration.
|
||||
|
||||
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens so they remain safe to embed in the token format and in URL paths used by administrative tooling.
|
||||
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens
|
||||
so they remain safe to embed in the token format and in URL paths used by
|
||||
administrative tooling.
|
||||
|
||||
The CLI is not the only management surface: the dashboard API Keys page
|
||||
creates, rotates, revokes, and deletes (revoked-only) keys through the same
|
||||
`IApiKeyAdminStore`. Every destructive dashboard action is gated by a
|
||||
confirmation dialog and emits its own audit event
|
||||
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`,
|
||||
`dashboard-delete-key`). See
|
||||
The CLI is not the only management surface: the dashboard API Keys page creates,
|
||||
rotates, revokes, and deletes (revoked-only) keys through the same shared admin
|
||||
command set. Every destructive dashboard action is gated by a confirmation dialog
|
||||
and emits its own audit event (`dashboard-create-key`, `dashboard-rotate-key`,
|
||||
`dashboard-revoke-key`, `dashboard-delete-key`) into the canonical `audit_event`
|
||||
store. The page also surfaces expiry: each row shows an `Expires` column (`Never`
|
||||
when unset) and a status badge that reads `Expired`, `Expiring` (within seven days),
|
||||
`Revoked`, or `Active`. This staleness surfacing is display-only; expiry is set at
|
||||
creation time via `apikey create-key --expires`, not from the dashboard. See
|
||||
[Gateway Dashboard Design](./GatewayDashboardDesign.md#api-keys-page).
|
||||
|
||||
## Scope Serialization
|
||||
|
||||
Scopes are persisted as a single TEXT column rather than a join table because the set is small, never queried by membership at the database level, and changes atomically with the owning row. `ApiKeyScopeSerializer.Serialize` writes a JSON array sorted with `StringComparer.Ordinal` so equivalent scope sets produce byte-identical column values, which makes audit diffing and database comparisons deterministic:
|
||||
|
||||
```csharp
|
||||
public static string Serialize(IReadOnlySet<string> scopes)
|
||||
{
|
||||
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public static IReadOnlySet<string> Deserialize(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
|
||||
|
||||
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
|
||||
}
|
||||
```
|
||||
|
||||
`Deserialize` tolerates an empty column by returning an empty set so older rows or hand-edited records do not crash the verifier.
|
||||
|
||||
## Registration
|
||||
|
||||
`AuthStoreServiceCollectionExtensions.AddSqliteAuthStore` wires every service in this subsystem as a singleton and registers the migration hosted service:
|
||||
|
||||
```csharp
|
||||
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
|
||||
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
|
||||
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
|
||||
services.AddSingleton<ApiKeyAdminCliRunner>();
|
||||
services.AddSingleton<AuthSqliteConnectionFactory>();
|
||||
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
|
||||
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
|
||||
services.AddSingleton<IApiKeyAdminStore, SqliteApiKeyAdminStore>();
|
||||
services.AddSingleton<IApiKeyAuditStore, SqliteApiKeyAuditStore>();
|
||||
services.AddHostedService<AuthStoreMigrationHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
```
|
||||
|
||||
Singletons are safe because each operation opens its own short-lived `SqliteConnection` through the factory; there is no shared mutable state inside the services.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Configuration](./GatewayConfiguration.md)
|
||||
|
||||
@@ -89,6 +89,12 @@ The flow is:
|
||||
|
||||
The status codes are deliberately distinct: `Unauthenticated` signals "we do not know who you are," and `PermissionDenied` signals "we know who you are, but you cannot do this." Treating the two as the same code would make troubleshooting harder for client implementations.
|
||||
|
||||
### Rate limiting the auth surface (SEC-11)
|
||||
|
||||
Before the verification store read, the helper checks a cheap in-process per-peer failure counter (`ApiKeyFailureLimiter`). A peer that has accumulated more than `MxGateway:Security:ApiKeyFailureLimit` failed attempts inside the sliding `ApiKeyFailureWindowSeconds` window is short-circuited with `StatusCode.ResourceExhausted` — so online guessing of API-key secrets cannot spend a SQLite read (and, in a naive design, a cache miss) per attempt. The peer is keyed on the presented key id where the token parses, falling back to the transport peer address; keying on key id throttles a single abusive credential without penalizing co-located clients behind a shared NAT. A successful verification resets the peer's counter. The counter is a bounded LRU (`ApiKeyFailureTrackedPeers`) so it cannot grow without limit. `ResourceExhausted` reveals only that throttling is in effect, not whether any particular secret was valid, preserving the opaque-failure property.
|
||||
|
||||
The dashboard login surface is throttled independently: `POST /auth/login` carries a fixed-window ASP.NET Core rate-limiter policy keyed per remote IP (`MxGateway:Security:LoginRateLimit*`), rejecting a burst with HTTP 429 before the LDAP bind is relayed to the directory. See [GatewayConfiguration](./GatewayConfiguration.md#security-options).
|
||||
|
||||
## Scope Resolution
|
||||
|
||||
`GatewayGrpcScopeResolver` is a stateless singleton that switches on the runtime request type. Top-level RPC requests map directly:
|
||||
@@ -104,6 +110,7 @@ public string ResolveRequiredScope(object request)
|
||||
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
||||
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
|
||||
StreamAlarmsRequest => GatewayScopes.EventsRead,
|
||||
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
|
||||
TestConnectionRequest or
|
||||
GetLastDeployTimeRequest or
|
||||
DiscoverHierarchyRequest or
|
||||
@@ -113,7 +120,7 @@ public string ResolveRequiredScope(object request)
|
||||
}
|
||||
```
|
||||
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` shares the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so it carries `events:read`. Both alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
|
||||
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
|
||||
|
||||
@@ -205,7 +212,7 @@ blocking constraint; secured values and raw credentials are never logged.
|
||||
|----------|-------|--------------|
|
||||
| `SessionOpen` | `session:open` | `OpenSessionRequest` |
|
||||
| `SessionClose` | `session:close` | `CloseSessionRequest` |
|
||||
| `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `MxCommandKind.DrainEvents` |
|
||||
| `EventsRead` | `events:read` | `StreamEventsRequest`, `StreamAlarmsRequest`, `QueryActiveAlarmsRequest`, `MxCommandKind.DrainEvents` |
|
||||
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) |
|
||||
| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` |
|
||||
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` |
|
||||
|
||||
@@ -63,12 +63,30 @@ Goals:
|
||||
|
||||
Non-goals for v1:
|
||||
|
||||
- client-side reconnectable sessions,
|
||||
- client-side event replay,
|
||||
- automatic client-side session reconnection (the library never transparently
|
||||
re-opens a dropped stream on the consumer's behalf — reconnect timing and
|
||||
policy stay an application concern),
|
||||
- client-side event replay buffering (the gateway owns the replay ring; the
|
||||
client does not retain its own event history),
|
||||
- client-side command batching,
|
||||
- synthetic MXAccess events,
|
||||
- hiding MXAccess handles behind opaque client-only handles.
|
||||
|
||||
The gateway's reconnect-replay *protocol* is, however, consumable from every
|
||||
client: each exposes the `after_worker_sequence` resume cursor on
|
||||
`StreamEvents` and surfaces the gateway's `ReplayGap` sentinel as a distinct,
|
||||
typed, non-terminal signal (CLI-15) so a consumer can detect an evicted-history
|
||||
gap and re-snapshot. Per the no-synthesized-events invariant the client never
|
||||
fabricates or swallows the sentinel — it only makes the gateway's own signal
|
||||
observable. The reaction contract is identical across languages: on a gap,
|
||||
discard cached state, re-snapshot, and resume with
|
||||
`after_worker_sequence = oldest_available_sequence - 1`. The per-language
|
||||
surface follows each idiom (.NET `MxEventStreamItem.IsReplayGap` via
|
||||
`StreamEventItemsAsync`; Go `EventResult.ReplayGap`/`IsReplayGap()`; Rust
|
||||
`EventItem::ReplayGap`; Python yields a typed `ReplayGap` from `stream_events`;
|
||||
Java `MxEventStreamItem.isReplayGap()` via `MxEventStream.nextItem()`). Shipped
|
||||
in all five clients.
|
||||
|
||||
## Public Client Concepts
|
||||
|
||||
All languages should expose the same core concepts, using idiomatic naming:
|
||||
@@ -87,6 +105,28 @@ All languages should expose the same core concepts, using idiomatic naming:
|
||||
The gateway session id and MXAccess handles must remain visible. The library may
|
||||
offer helper methods, but it must not invent alternate handle semantics.
|
||||
|
||||
## Typed Command Parity
|
||||
|
||||
Every command kind in the wire contract has a typed single-item session helper,
|
||||
not just a raw-`Invoke` escape hatch (CLI-04). Beyond the register/add/advise/
|
||||
remove/write family, the parity-critical single-item helpers are:
|
||||
`AdviseSupervisory`, `WriteSecured` / `WriteSecured2`, `AuthenticateUser`,
|
||||
`ArchestrAUserToId`, `AddBufferedItem`, `SetBufferedUpdateInterval`, `Suspend`,
|
||||
`Activate`, and `Unregister`. Each is a thin wrapper over the same raw-command
|
||||
machinery the bulk helpers use — it adds no wire surface — and runs the same
|
||||
MXAccess-level reply validation (HRESULT `< 0` + per-item `MxStatusProxy`) as the
|
||||
rest of the client. **MXAccess parity is preserved exactly**: e.g. `WriteSecured`
|
||||
failing before a prior `AuthenticateUser` + `AdviseSupervisory` surfaces the
|
||||
native failure unchanged — the helper does not pre-validate or reorder it.
|
||||
|
||||
**Credential handling:** `AuthenticateUser` credentials and `WriteSecured`
|
||||
secured payloads route through each client's secret-redaction seam so they never
|
||||
reach logs, exception text, or `ToString`/`Debug`/`Display` — the value is carried
|
||||
only on the wire. Each client's test suite asserts a distinctive credential is
|
||||
absent from any surfaced error.
|
||||
|
||||
Shipped in all five clients (.NET / Go / Rust / Python / Java).
|
||||
|
||||
## Shared API Shape
|
||||
|
||||
Each language should support this conceptual API:
|
||||
@@ -407,7 +447,7 @@ The stable client proto manifest defines the generated-code directories:
|
||||
clients/dotnet/generated
|
||||
clients/go/internal/generated
|
||||
clients/rust/src/generated
|
||||
clients/python/src/mxgateway/generated
|
||||
clients/python/src/zb_mom_ww_mxgateway/generated
|
||||
clients/java/src/main/generated
|
||||
```
|
||||
|
||||
|
||||
+12
-12
@@ -48,8 +48,8 @@ dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csp
|
||||
Build and test from the repository root:
|
||||
|
||||
```powershell
|
||||
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln
|
||||
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln --no-build
|
||||
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
|
||||
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
|
||||
```
|
||||
|
||||
Create local package artifacts:
|
||||
@@ -156,8 +156,8 @@ Pop-Location
|
||||
|
||||
## Python
|
||||
|
||||
The Python package is `mxaccess-gateway-client`. Generated modules live under
|
||||
`clients/python/src/mxgateway/generated`.
|
||||
The Python package is `zb-mom-ww-mxaccess-gateway-client`. Generated modules
|
||||
live under `clients/python/src/zb_mom_ww_mxgateway/generated`.
|
||||
|
||||
Regenerate the Python bindings:
|
||||
|
||||
@@ -184,21 +184,21 @@ Push-Location clients/python
|
||||
mxgw-py version --json
|
||||
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||
python -m mxgateway_cli version --json
|
||||
python -m zb_mom_ww_mxgateway_cli version --json
|
||||
Pop-Location
|
||||
```
|
||||
|
||||
## Java
|
||||
|
||||
The Java workspace uses Gradle, Java 21, `mxgateway-client`, and
|
||||
`mxgateway-cli`. The Gradle protobuf plugin writes generated Java protobuf and
|
||||
The Java workspace uses Gradle, Java 17, `zb-mom-ww-mxgateway-client`, and
|
||||
`zb-mom-ww-mxgateway-cli`. The Gradle protobuf plugin writes generated Java protobuf and
|
||||
gRPC sources under `clients/java/src/main/generated`.
|
||||
|
||||
Regenerate Java bindings:
|
||||
|
||||
```powershell
|
||||
Push-Location clients/java
|
||||
gradle :mxgateway-client:generateProto
|
||||
gradle :zb-mom-ww-mxgateway-client:generateProto
|
||||
Pop-Location
|
||||
```
|
||||
|
||||
@@ -214,7 +214,7 @@ Create local library and CLI artifacts:
|
||||
|
||||
```powershell
|
||||
Push-Location clients/java
|
||||
gradle :mxgateway-client:jar :mxgateway-cli:installDist
|
||||
gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
|
||||
Pop-Location
|
||||
```
|
||||
|
||||
@@ -222,9 +222,9 @@ Run the CLI through Gradle:
|
||||
|
||||
```powershell
|
||||
Push-Location clients/java
|
||||
gradle :mxgateway-cli:run --args="version --json"
|
||||
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="version --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||
Pop-Location
|
||||
```
|
||||
|
||||
|
||||
@@ -48,25 +48,55 @@ session.
|
||||
## Descriptor Publishing
|
||||
|
||||
Run this command after changing either source `.proto` file or the client proto
|
||||
manifest:
|
||||
manifest, with the **pinned protoc 34.1** (see [Toolchain Links](./ToolchainLinks.md)):
|
||||
|
||||
```powershell
|
||||
scripts/publish-client-proto-inputs.ps1
|
||||
pwsh -File scripts/publish-client-proto-inputs.ps1
|
||||
```
|
||||
|
||||
The script writes
|
||||
`clients/proto/descriptors/mxaccessgw-client-v1.protoset` with imports and
|
||||
source information included. The descriptor is a generated artifact; do not edit
|
||||
it by hand.
|
||||
it by hand. Generating the committed artifact requires the pinned protoc version
|
||||
so the checked-in bytes are reproducible; the script fails fast on a version
|
||||
mismatch when generating.
|
||||
|
||||
Use the check mode in CI or before committing:
|
||||
|
||||
```powershell
|
||||
scripts/publish-client-proto-inputs.ps1 -Check
|
||||
pwsh -File scripts/publish-client-proto-inputs.ps1 -Check
|
||||
```
|
||||
|
||||
`-Check` rebuilds the descriptor in a temporary path and fails when the checked
|
||||
in descriptor is stale.
|
||||
`-Check` rebuilds the descriptor and fails when the checked-in descriptor is
|
||||
stale. The comparison normalizes both the committed and freshly built descriptor
|
||||
through the same protoc with `source_code_info` stripped, so it is tolerant of
|
||||
protoc-version encoding drift and does not false-fail across protoc releases
|
||||
(it warns, rather than fails, when protoc is off the pin).
|
||||
|
||||
The gateway test project carries an independent, protoc-free freshness guard:
|
||||
`ClientProtoInputTests.Descriptor_ContainsEveryContractMessageAndField` reflects
|
||||
over the in-process contract descriptors and fails if any contract message or
|
||||
field is missing from the committed protoset. This is the primary CI gate for
|
||||
descriptor staleness; a red test means "regenerate and commit the protoset."
|
||||
|
||||
### Pinned generator versions
|
||||
|
||||
Regeneration is reproducible only with the pinned toolchain. Regenerating with a
|
||||
different version silently produces incompatible or noisy output, so the per-client
|
||||
scripts assert the pin and resolve tools from `PATH`:
|
||||
|
||||
| Generator | Pinned version | Guard |
|
||||
|-----------|----------------|-------|
|
||||
| protoc (descriptor set) | 34.1 | version assertion in `scripts/publish-client-proto-inputs.ps1` |
|
||||
| `Grpc.Tools` (C# `Generated/`) | 2.80.0 (contracts csproj) | `scripts/check-codegen.ps1` git-diff of `Generated/` |
|
||||
| `grpcio-tools` (Python) | 1.80.0 (protobuf runtime 6.31.1) | version assertion in `clients/python/generate-proto.ps1` |
|
||||
| protobuf / grpc-java (Java) | `protobufVersion` / `grpcVersion` in `clients/java/build.gradle` | `checkGeneratedClean` gradle task |
|
||||
|
||||
A newer `grpcio-tools` stamps a `GRPC_GENERATED_VERSION` above the pinned grpcio
|
||||
runtime and breaks Python `pytest`; the Java protobuf plugin rewrites
|
||||
`MxaccessGateway.java` with spurious protobuf-runtime-version churn on every build
|
||||
(revert that one file when no `.proto` changed — see
|
||||
[Gateway Testing](./GatewayTesting.md) "Continuous Integration").
|
||||
|
||||
## Output Directories
|
||||
|
||||
@@ -77,7 +107,7 @@ The manifest declares these generated-code directories:
|
||||
| .NET | `clients/dotnet/generated` |
|
||||
| Go | `clients/go/internal/generated` |
|
||||
| Rust | `clients/rust/src/generated` |
|
||||
| Python | `clients/python/src/mxgateway/generated` |
|
||||
| Python | `clients/python/src/zb_mom_ww_mxgateway/generated` |
|
||||
| Java | `clients/java/src/main/generated` |
|
||||
|
||||
Only generator output belongs in these directories. Handwritten client wrappers
|
||||
@@ -98,7 +128,7 @@ Use these commands to regenerate language-specific client bindings:
|
||||
| Go | `Push-Location clients/go; ./generate-proto.ps1; Pop-Location` |
|
||||
| Rust | `Push-Location clients/rust; cargo check --workspace; Pop-Location` |
|
||||
| Python | `Push-Location clients/python; ./generate-proto.ps1; Pop-Location` |
|
||||
| Java | `Push-Location clients/java; gradle :mxgateway-client:generateProto; Pop-Location` |
|
||||
| Java | `Push-Location clients/java; gradle :zb-mom-ww-mxgateway-client:generateProto; Pop-Location` |
|
||||
|
||||
.NET generation currently runs through the contracts project:
|
||||
|
||||
@@ -142,7 +172,7 @@ cargo check --workspace
|
||||
```
|
||||
|
||||
Python clients should use `grpc_tools.protoc` and write generated modules under
|
||||
`clients/python/src/mxgateway/generated` so imports stay separate from
|
||||
`clients/python/src/zb_mom_ww_mxgateway/generated` so imports stay separate from
|
||||
handwritten async wrappers.
|
||||
|
||||
The Python scaffold provides a repo-local generation script:
|
||||
|
||||
+33
-2
@@ -97,6 +97,24 @@ behavior.
|
||||
Generated C# output is written to `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`. Do not
|
||||
hand-edit generated files.
|
||||
|
||||
The `Generated/` C# is **tracked and must be committed after any `.proto` change.** The
|
||||
contracts project `Compile Remove`s `Generated/**/*.cs` and has `Grpc.Tools` regenerate them,
|
||||
but `Grpc.Tools` skips regeneration when the committed output looks up to date. On the net10
|
||||
build the freshly regenerated code is compiled, so a stale `Generated/` is invisible there —
|
||||
but the **net48 worker** consumes the committed `Generated/` and fails to build with `CS0246`
|
||||
on any new type when the checked-in code lags the `.proto`. So after editing a `.proto` you must
|
||||
regenerate and commit `Generated/`. If a build does not pick up your proto change, delete the
|
||||
stale output to force a full regeneration:
|
||||
|
||||
```bash
|
||||
rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs
|
||||
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
|
||||
```
|
||||
|
||||
`scripts/check-codegen.ps1` enforces this in CI (it force-regenerates and fails on any
|
||||
`git diff` against the committed `Generated/`); the Windows CI job's net48 worker build is the
|
||||
definitive guard.
|
||||
|
||||
Client generation inputs are published through
|
||||
`clients/proto/proto-inputs.json` and the descriptor set under
|
||||
`clients/proto/descriptors/`. See
|
||||
@@ -124,12 +142,25 @@ gateway and test projects:
|
||||
dotnet build src/ZB.MOM.WW.MxGateway.slnx
|
||||
```
|
||||
|
||||
Regenerate the client descriptor after changing either `.proto` file:
|
||||
Regenerate the client descriptor after changing either `.proto` file, using the pinned protoc
|
||||
(34.1, see [Toolchain Links](./ToolchainLinks.md)):
|
||||
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -File scripts/publish-client-proto-inputs.ps1
|
||||
pwsh -File scripts/publish-client-proto-inputs.ps1
|
||||
```
|
||||
|
||||
Freshness is guarded two ways so a skipped regeneration cannot ship silently:
|
||||
|
||||
- `ClientProtoInputTests.Descriptor_ContainsEveryContractMessageAndField` (gateway test project)
|
||||
reflects over the in-process contract descriptors and fails if any message or field is missing
|
||||
from the committed protoset. It is semantic (symbol presence), needs no protoc, and runs in the
|
||||
Linux CI. A red test means "regenerate and commit the protoset."
|
||||
- `pwsh -File scripts/publish-client-proto-inputs.ps1 -Check` rebuilds the descriptor and compares
|
||||
it to the committed one. The comparison normalizes both sides through the same protoc with
|
||||
`source_code_info` stripped, so it does not false-fail across protoc releases.
|
||||
- `pwsh scripts/check-codegen.ps1` runs both the descriptor check and the `Generated/`-clean check
|
||||
together; it is the single guard the CI pipeline invokes.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Proto Generation](./ClientProtoGeneration.md)
|
||||
|
||||
@@ -30,6 +30,14 @@ Each client entry defines commands for the same required operation sequence:
|
||||
The optional `write` command is documented separately because writing changes
|
||||
provider state and should only run when the operator supplies a safe test value.
|
||||
|
||||
When `stream-events` is resumed with an `after_worker_sequence` cursor that
|
||||
predates the oldest event still in the gateway's replay ring, the gateway emits a
|
||||
single `ReplayGap` sentinel at the head of the stream. Every client surfaces this
|
||||
as a distinct, typed, non-terminal signal (see each client README); the resume
|
||||
contract is `after_worker_sequence = oldest_available_sequence - 1`. The default
|
||||
smoke sequence opens a fresh stream (no cursor) and does not exercise the gap
|
||||
path; a resume-with-gap fixture case is tracked separately (TST-24).
|
||||
|
||||
## Integration Gate
|
||||
|
||||
Cross-language smoke execution is opt-in. Runners should skip the matrix unless
|
||||
|
||||
+3
-1
@@ -123,7 +123,9 @@ The split uses `count: 3` because the secret portion may itself contain undersco
|
||||
|
||||
### Command value redaction
|
||||
|
||||
`RedactCommandValue` enforces the "values are opt-in and redacted by default" rule:
|
||||
> **Not yet implemented.** Command-value logging is *not* wired end-to-end. There is no `MxGateway:Diagnostics:LogCommandValues` (or equivalent) configuration knob, and `RedactCommandValue` / `IsCredentialBearingCommand` have no call sites in the gateway — no command values are logged anywhere today, which is the safest posture. The helpers below exist as the intended redaction seam for a future opt-in value-logging feature; that wiring is deferred until secured-bulk command variants are covered by the redactor's credential list (the `WriteSecuredBulk` / `WriteSecured2Bulk` gap), so enabling value logging cannot leak a secured-bulk payload. Until then, treat this section as describing the planned shape, not current behavior.
|
||||
|
||||
The intended `RedactCommandValue` would enforce the "values are opt-in and redacted by default" rule:
|
||||
|
||||
```csharp
|
||||
public static object? RedactCommandValue(
|
||||
|
||||
@@ -29,7 +29,7 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
|
||||
"ShutdownTimeoutSeconds": 10,
|
||||
"HeartbeatIntervalSeconds": 5,
|
||||
"HeartbeatGraceSeconds": 15,
|
||||
"MaxMessageBytes": 16777216
|
||||
"MaxMessageBytes": 16842752
|
||||
},
|
||||
"Sessions": {
|
||||
"DefaultCommandTimeoutSeconds": 30,
|
||||
@@ -93,12 +93,15 @@ Environment variables use the normal .NET double-underscore form. For example,
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Authentication:Mode` | `ApiKey` | Selects public gRPC authentication. Supported values are `ApiKey` and `Disabled`. `Disabled` bypasses API-key verification and is for local development only. |
|
||||
| `MxGateway:Authentication:SqlitePath` | `C:\ProgramData\MxGateway\gateway-auth.db` | SQLite database path for API-key records and audit rows when API-key authentication is enabled. |
|
||||
| `MxGateway:Authentication:SqlitePath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) | SQLite database path for API-key records and audit rows when API-key authentication is enabled. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so the credential store never lands in the launch working directory on a non-Windows host; the production hosts pin the explicit Windows path in `appsettings.json`, which overrides the code default. |
|
||||
| `MxGateway:Authentication:PepperSecretName` | `MxGateway:ApiKeyPepper` | Configuration key used to read the HMAC pepper for API-key secret hashing. The dashboard effective configuration redacts this value. |
|
||||
| `MxGateway:Authentication:RunMigrationsOnStartup` | `true` | Runs SQLite auth schema migrations at gateway startup when API-key authentication is enabled. |
|
||||
|
||||
When `Mode` is `ApiKey`, `SqlitePath` and `PepperSecretName` must be present.
|
||||
`SqlitePath` must be a valid filesystem path.
|
||||
`SqlitePath` must be a valid filesystem path and must be **rooted** (absolute):
|
||||
the validator rejects a non-rooted path so a relative override cannot silently
|
||||
resolve against the working directory and scatter the credential store by launch
|
||||
CWD (SEC-01).
|
||||
|
||||
## Worker Options
|
||||
|
||||
@@ -114,12 +117,16 @@ When `Mode` is `ApiKey`, `SqlitePath` and `PepperSecretName` must be present.
|
||||
| `MxGateway:Worker:ShutdownTimeoutSeconds` | `10` | Grace period for worker shutdown before the gateway treats shutdown as failed and may kill the worker process tree. |
|
||||
| `MxGateway:Worker:HeartbeatIntervalSeconds` | `5` | Worker heartbeat send interval and gateway heartbeat check cadence input. |
|
||||
| `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. |
|
||||
| `MxGateway:Worker:MaxMessageBytes` | `16777216` | Maximum worker IPC frame payload size in bytes. The validator allows values from `1024` through `268435456`. |
|
||||
| `MxGateway:Worker:MaxMessageBytes` | `16842752` | Maximum worker IPC frame payload size in bytes. The validator allows values from `1024` through `268435456`, and additionally requires this to be at least `MxGateway:Protocol:MaxGrpcMessageBytes` plus a 65536-byte (64 KiB) envelope-overhead reserve. The default is the 16 MiB public gRPC cap plus that reserve. The gateway conveys the negotiated value to the worker in the handshake (`GatewayHello.max_frame_bytes`), so the worker frames to the same limit rather than a hard-coded default. |
|
||||
|
||||
`StartupProbeRetryAttempts`, `StartupProbeRetryDelayMilliseconds`,
|
||||
`PipeConnectAttemptTimeoutMilliseconds`, timeout values, heartbeat values, and
|
||||
`MaxMessageBytes` must be positive. `MaxMessageBytes` is intentionally bounded
|
||||
to avoid accidental large allocations from malformed or oversized frames.
|
||||
to avoid accidental large allocations from malformed or oversized frames. It
|
||||
must also stay above `MaxGrpcMessageBytes` by the envelope-overhead reserve so a
|
||||
maximally-sized accepted gRPC payload always fits one worker frame once wrapped
|
||||
in a `WorkerEnvelope`; otherwise the oversized outbound write would fault the
|
||||
whole session. Startup validation fails fast when the headroom is violated.
|
||||
|
||||
## Session Options
|
||||
|
||||
@@ -171,13 +178,13 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
|
||||
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
||||
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
||||
| `MxGateway:Dashboard:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
|
||||
| `MxGateway:Dashboard:CookieName` | _(unset)_ | Dashboard auth cookie name. Leave unset (null/blank) to use the resolved default: `__Host-MxGatewayDashboard` when `RequireHttpsCookie` is `true` (Secure cookie, so the `__Host-` prefix's Secure/no-Domain/`Path=/` guarantees apply), else the plain `MxGatewayDashboard` (a `__Host-` cookie without Secure is silently dropped by browsers). Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. An explicit override always wins over the `__Host-` default. Changing it signs out existing dashboard sessions on next deploy. |
|
||||
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
||||
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||
| `MxGateway:Dashboard:ShowTagValues` | `false` | Reserved display control for tag values. The dashboard does not show full tag values by default. |
|
||||
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
|
||||
| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. |
|
||||
| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. |
|
||||
| `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. |
|
||||
|
||||
`SnapshotIntervalMilliseconds` must be greater than zero. `RecentFaultLimit`
|
||||
@@ -215,21 +222,67 @@ When the dashboard is enabled, three hubs are mapped under `/hubs/*`:
|
||||
side-effect; the dashboard only sees events while a gRPC client is also
|
||||
subscribed to that session.
|
||||
|
||||
`GET /hubs/token` (cookie-only) mints a 30-minute data-protected bearer
|
||||
`GET /hubs/token` (cookie-only) mints a 5-minute data-protected bearer
|
||||
token for the calling user; the Blazor pages use it via
|
||||
`DashboardHubConnectionFactory` to authenticate the SignalR connection.
|
||||
The factory refreshes the token on every (re)connect, so the short lifetime
|
||||
(SEC-05) is transparent to clients. The token is not server-side revocable;
|
||||
its short lifetime bounds exposure of a captured token (see
|
||||
[GatewayDashboardDesign](./GatewayDashboardDesign.md)).
|
||||
|
||||
## LDAP Options (Dashboard Login)
|
||||
|
||||
The `MxGateway:Ldap` section configures the LDAP bind used by dashboard `/login`
|
||||
(the gRPC API-key model is separate). The shipped defaults are the shared
|
||||
dev/test GLAuth posture (`glauth.md`), not a production posture.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Ldap:Enabled` | `true` | Enables LDAP-backed dashboard login. |
|
||||
| `MxGateway:Ldap:Server` | `localhost` | LDAP server host. Dev points at the shared GLAuth instance (`10.100.0.35`). |
|
||||
| `MxGateway:Ldap:Port` | `3893` | LDAP server port. Must be between `1` and `65535`. |
|
||||
| `MxGateway:Ldap:Transport` | `None` | Connection transport: `None` (plaintext), `StartTls` (upgrade), or `Ldaps` (implicit TLS). The dev default is `None` because the shared GLAuth instance has LDAPS disabled and binds send cleartext. **Production hard-stop (SEC-06):** when the host runs in the `Production` environment, `Transport` must not be `None` — startup validation fails otherwise. Deployed hosts must set `Ldaps` or `StartTls`. |
|
||||
| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
|
||||
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
|
||||
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
|
||||
| `MxGateway:Ldap:ServiceAccountPassword` | `serviceaccount123` | Search-account password. **Dev-only committed value.** On the deployed hosts, do not ship this in `appsettings.json`; supply it out-of-band via the env-var override `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form, as set on the NSSM-wrapped services). The committed `serviceaccount123` is a shared dev credential for the local GLAuth instance and should be rotated; production must use a secret it does not share with dev. |
|
||||
| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
|
||||
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
|
||||
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
|
||||
|
||||
When LDAP is enabled, `Server`, `SearchBase`, `ServiceAccountDn`,
|
||||
`ServiceAccountPassword`, and the attribute names must be non-blank, and `Port`
|
||||
must be in range. See `glauth.md` for the shared dev instance and the
|
||||
dev→production hardening posture.
|
||||
|
||||
## Protocol Options
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Protocol:WorkerProtocolVersion` | `1` | Worker IPC protocol version expected by the gateway and worker. This must match `GatewayContractInfo.WorkerProtocolVersion`. |
|
||||
| `MxGateway:Protocol:MaxGrpcMessageBytes` | `16777216` | Public gRPC max send and receive message size in bytes. The same default is used by official clients. The validator allows values from `1024` through `268435456`. |
|
||||
| `MxGateway:Protocol:MaxGrpcMessageBytes` | `16777216` | Public gRPC max send and receive message size in bytes. The same default is used by official clients. The validator allows values from `1024` through `268435456`, and requires `MxGateway:Worker:MaxMessageBytes` to exceed this by at least the 64 KiB worker-frame envelope reserve (see the Worker options). |
|
||||
|
||||
The protocol option is exposed for diagnostics and explicit deployment
|
||||
configuration, not for compatibility negotiation. A mismatch fails validation
|
||||
at startup.
|
||||
|
||||
## Security Options
|
||||
|
||||
Bound from `MxGateway:Security`. These knobs tune the authentication hot path
|
||||
(SEC-08 verification cache / last-used coalescing) and the auth-surface rate
|
||||
limiting (SEC-11). Defaults are safe; leave them unless profiling or a threat
|
||||
model requires otherwise.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Security:ApiKeyVerificationCacheSeconds` | `15` | TTL, in seconds, of a cached successful API-key verification. A cache hit within this window skips the per-call SQLite read (and the coupled `last_used` write). Gateway-initiated revoke/rotate/delete invalidate the entry immediately; this TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke run by the separate `apikey` CLI process). `0` disables the cache. Must be zero or greater. |
|
||||
| `MxGateway:Security:ApiKeyLastUsedCoalesceSeconds` | `60` | Coalescing window, in seconds, for the `last_used_utc` write. The library verifier writes `last_used` on every successful verification; this bounds the write to at most one per key per window, so a hammered key does not churn the WAL. `0` forwards every write. Must be zero or greater. |
|
||||
| `MxGateway:Security:LoginRateLimitPermitLimit` | `10` | Maximum `POST /auth/login` attempts permitted per remote IP within `LoginRateLimitWindowSeconds` before requests are rejected with HTTP 429. Throttles LDAP credential stuffing before the bind is relayed to the directory. Must be greater than zero. |
|
||||
| `MxGateway:Security:LoginRateLimitWindowSeconds` | `60` | Fixed-window length, in seconds, for the per-IP login rate limit. Must be greater than zero. |
|
||||
| `MxGateway:Security:ApiKeyFailureLimit` | `10` | Failed API-key verifications, per peer, within `ApiKeyFailureWindowSeconds` that trip the in-process short-circuit. Once tripped, the gRPC auth path rejects further attempts with `ResourceExhausted` **before** the store read; a successful verification resets the peer's counter. The peer is keyed on the presented key id (falling back to the transport address) so a single abusive credential behind a shared NAT is throttled without locking out co-located clients. Must be greater than zero. |
|
||||
| `MxGateway:Security:ApiKeyFailureWindowSeconds` | `60` | Sliding-window length, in seconds, over which API-key verification failures are counted per peer. Must be greater than zero. |
|
||||
| `MxGateway:Security:ApiKeyFailureTrackedPeers` | `4096` | Maximum distinct peers tracked by the failure counter (a bounded LRU) so a spray of unique peer keys cannot grow memory without limit. Must be greater than zero. |
|
||||
|
||||
## Galaxy Options
|
||||
|
||||
| Option | Default | Description |
|
||||
@@ -238,7 +291,7 @@ at startup.
|
||||
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout for all Galaxy browse RPCs. |
|
||||
| `MxGateway:Galaxy:DashboardRefreshIntervalSeconds` | `30` | Interval between background refreshes of the dashboard Galaxy summary cache. SQL is hit at most once per interval regardless of dashboard render rate. |
|
||||
| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists the latest successful Galaxy browse dataset to disk. When `true`, the cache reloads that snapshot at startup so clients can still browse last-known data while the Galaxy database is unreachable. The restored data is served with `Stale` status until a live query confirms it. |
|
||||
| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). |
|
||||
| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). Set an **absolute** path — this option is bound by the shared `ZB.MOM.WW.GalaxyRepository` package (not by `GatewayOptions`), so the gateway validator does not enforce rooting on it; a relative value would resolve against the launch working directory (SEC-01). |
|
||||
|
||||
See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC surface and
|
||||
behavior.
|
||||
@@ -442,7 +495,7 @@ them.
|
||||
|
||||
| Option | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated certificate is persisted |
|
||||
| `Tls:SelfSignedCertPath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` on Windows, `/usr/share/MxGateway/certs/gateway-selfsigned.pfx` or the container equivalent elsewhere) | Where the generated certificate is persisted. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so a generated private key never lands in the launch working directory on a non-Windows host (SEC-01). Must be **rooted** (absolute); the validator rejects a non-rooted override. |
|
||||
| `Tls:ValidityYears` | `10` | Lifetime of the generated certificate (validated 1–100) |
|
||||
| `Tls:AdditionalDnsNames` | `[]` | Extra DNS SANs (e.g. a load-balancer name) |
|
||||
| `Tls:RegenerateIfExpired` | `true` | Replace an expired persisted certificate instead of failing |
|
||||
|
||||
@@ -167,7 +167,7 @@ bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`.
|
||||
|---|---|---|---|---|
|
||||
| `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick; new connections receive the current snapshot synchronously in `OnConnectedAsync`. |
|
||||
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. |
|
||||
| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`. The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity (per-session dashboard ACL is tracked separately). |
|
||||
| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`. The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. The per-session hub ACL that would scope a Viewer to specific sessions is still outstanding (SEC-25 / remediation roadmap item 12); the value redaction is the near-term hardening that closes the value-leak seam independently of that ACL. |
|
||||
|
||||
`DashboardPageBase` opens a `DashboardSnapshotHub` connection via the connection
|
||||
factory in `OnInitializedAsync`, seeds `Snapshot` synchronously from
|
||||
@@ -257,6 +257,15 @@ gated by a confirmation dialog before it reaches `ISessionManager`.
|
||||
is killed immediately with no graceful-shutdown attempt. The session is
|
||||
removed from the registry and the open-session slot is released either way.
|
||||
|
||||
Both actions write a canonical `AuditEvent` through `IAuditWriter` into the
|
||||
`audit_event` store (category `SessionAdmin`, actions `dashboard-close-session`
|
||||
and `dashboard-kill-worker`) in addition to the operational `ILogger` line — so a
|
||||
worker killed mid-production leaves a durable, queryable row rather than only a
|
||||
rotatable log entry. The event records the LDAP actor, the session id (`Target`),
|
||||
the remote address, and an outcome of `Success`, `Failure`, or `Denied`
|
||||
(an unauthorized attempt is still audited as `Denied`). This mirrors the API-key
|
||||
management audit path.
|
||||
|
||||
### Workers page
|
||||
|
||||
Show:
|
||||
@@ -302,8 +311,9 @@ Right-clicking an attribute (or double-clicking it) adds it to the subscription
|
||||
panel. The panel shows each subscribed tag's live value, MXAccess data type,
|
||||
quality and source timestamp, refreshed every two seconds. The subscription
|
||||
panel is the explicit opt-in tag-value surface: it always shows values
|
||||
regardless of `Dashboard:ShowTagValues`, which continues to govern only the
|
||||
diagnostic session/worker views.
|
||||
regardless of `Dashboard:ShowTagValues`, which governs the diagnostic
|
||||
session/worker views and the per-session `EventsHub` mirror (values are
|
||||
redacted from the mirrored events when the flag is false).
|
||||
|
||||
### Alarms page
|
||||
|
||||
@@ -409,8 +419,12 @@ Do not show API key secrets or pepper values.
|
||||
|
||||
Dashboard authentication is LDAP-backed, distinct from the API-key model used
|
||||
on the gRPC API. Users sign in with directory credentials; the gateway maps
|
||||
their LDAP groups to one of two dashboard roles (`Admin` or `Viewer`) and
|
||||
issues a cookie carrying those role claims.
|
||||
their LDAP groups to one of two dashboard roles (`Administrator` or `Viewer`) and
|
||||
issues a cookie carrying those role claims. `Administrator` is the canonical
|
||||
role value — the exact string `GroupToRole` values and the validator accept
|
||||
(`DashboardRoles.Admin`); the shorthand "Admin" used elsewhere in this document
|
||||
names the same role and the `MxGateway.Dashboard.Admin` policy, not a distinct
|
||||
config value.
|
||||
|
||||
Implemented behavior:
|
||||
|
||||
@@ -422,7 +436,11 @@ Implemented behavior:
|
||||
`ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group`
|
||||
claims;
|
||||
- a successful login signs in the `MxGateway.Dashboard` cookie scheme
|
||||
(`__Host-MxGatewayDashboard`, HttpOnly, SameSite=Strict, Secure);
|
||||
(HttpOnly, SameSite=Strict, Secure); the cookie is named
|
||||
`__Host-MxGatewayDashboard` when `MxGateway:Dashboard:RequireHttpsCookie` is
|
||||
true (default) and no `MxGateway:Dashboard:CookieName` override is set,
|
||||
otherwise the plain `MxGatewayDashboard` name is used (the `__Host-` prefix is
|
||||
only honoured on a Secure cookie);
|
||||
- a user with no matching group cannot sign in — the login screen returns the
|
||||
generic credential-rejected message;
|
||||
- antiforgery tokens guard the login and logout POSTs.
|
||||
@@ -436,11 +454,16 @@ Three authorization policies are registered:
|
||||
cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket upgrades
|
||||
where the cookie can't be forwarded).
|
||||
|
||||
Two environmental bypasses still apply: `MxGateway:Authentication:Mode = Disabled`
|
||||
authorizes every request, and `MxGateway:Dashboard:AllowAnonymousLocalhost`
|
||||
(default `true`) authorizes any loopback request without a role check. Remote
|
||||
requests always require an authenticated principal carrying at least the
|
||||
Viewer role.
|
||||
Two environmental bypasses still apply, both scoped to **read-only** access:
|
||||
`MxGateway:Authentication:Mode = Disabled` and `MxGateway:Dashboard:AllowAnonymousLocalhost`
|
||||
(default `true`, loopback only) each satisfy a requirement that includes the Viewer
|
||||
role (`AnyDashboardRole`) but **never** the `AdminOnly` requirement. Destructive/admin
|
||||
surfaces (API-key CRUD, session Close, worker Kill) still demand a real `Administrator`
|
||||
role claim, so the "anonymous localhost is read-only" contract holds at the policy layer
|
||||
rather than only in downstream service re-checks. Remote requests (outside the `Disabled`
|
||||
bypass) always require an authenticated principal carrying at least the Viewer role. The
|
||||
loopback test trusts `Connection.RemoteIpAddress`; if forwarded-headers middleware is ever
|
||||
added upstream, `DashboardAuthorizationHandler.IsLoopbackRequest()` must be revisited.
|
||||
|
||||
### DisableLogin dev bypass
|
||||
|
||||
@@ -486,9 +509,13 @@ dashboard mints short-lived bearer tokens for the connection:
|
||||
and role claims to JSON, encrypts with the ASP.NET Core data-protection
|
||||
time-limited protector under purpose
|
||||
`ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`, and returns the protected
|
||||
string. Lifetime is 30 minutes.
|
||||
string. Lifetime is **5 minutes**.
|
||||
3. The SignalR client passes the token as either `Authorization: Bearer …` or
|
||||
`?access_token=…` (WebSocket upgrade query string).
|
||||
`?access_token=…` (WebSocket upgrade query string). The query-string form is
|
||||
the standard SignalR carriage for WebSocket upgrades, which cannot attach a
|
||||
custom header; because it is easy to capture (proxy logs, browser history),
|
||||
it must never be request-logged (see the remarks on
|
||||
`HubTokenAuthenticationHandler`).
|
||||
4. `HubTokenAuthenticationHandler` validates the protected payload and
|
||||
rebuilds the `ClaimsPrincipal` with the carried roles.
|
||||
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
|
||||
@@ -496,7 +523,17 @@ dashboard mints short-lived bearer tokens for the connection:
|
||||
|
||||
`DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the
|
||||
HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on
|
||||
every (re)connect.
|
||||
every (re)connect, so the short 5-minute lifetime is transparent to clients.
|
||||
|
||||
Caveat — logout does not revoke outstanding tokens. Logout clears the dashboard
|
||||
cookie, but hub bearer tokens are self-contained, data-protection-encrypted, and
|
||||
carry no server-side revocation state (no jti denylist). A token captured before
|
||||
logout remains valid until it expires, and a role change or key revocation does
|
||||
not take effect on an already-issued token until then. The 5-minute lifetime is
|
||||
the deliberate mitigation: it bounds that exposure window without the cost of a
|
||||
revocation store. Server-side revocation is deferred until per-session hub ACLs
|
||||
land (see the per-session-ACL note), at which point tokens gain session/role
|
||||
binding and a denylist becomes worthwhile.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -513,7 +550,7 @@ Effective configuration:
|
||||
"RecentSessionLimit": 200,
|
||||
"ShowTagValues": false,
|
||||
"GroupToRole": {
|
||||
"GwAdmin": "Admin",
|
||||
"GwAdmin": "Administrator",
|
||||
"GwReader": "Viewer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +481,9 @@ Internally it owns:
|
||||
- pipe stream,
|
||||
- read loop,
|
||||
- write loop,
|
||||
- event write loop,
|
||||
- outbound command/control channel serialized by the write loop,
|
||||
- unbounded event staging channel drained by the event write loop,
|
||||
- bounded inbound event channel,
|
||||
- pending command dictionary keyed by correlation id,
|
||||
- heartbeat monitor,
|
||||
@@ -499,15 +501,29 @@ The read loop:
|
||||
1. Reads one frame.
|
||||
2. Parses `WorkerEnvelope`.
|
||||
3. Validates protocol fields.
|
||||
4. Dispatches by body type:
|
||||
4. Dispatches by body type, **synchronously and without blocking**:
|
||||
- `WorkerCommandReply`: completes pending command.
|
||||
- `WorkerEvent`: enqueues event.
|
||||
- `WorkerEvent`: hands the event to the event write loop via a non-blocking staging write.
|
||||
- `WorkerHeartbeat`: updates heartbeat timestamp.
|
||||
- `WorkerFault`: faults session.
|
||||
5. Stops when pipe closes or cancellation is requested.
|
||||
|
||||
If the pipe closes while the session is not closing, fault the session.
|
||||
|
||||
The read loop never awaits event enqueue. Events are staged to the event write
|
||||
loop with a non-blocking write, so a full inbound event channel (a slow or
|
||||
absent `StreamEvents` consumer) cannot stall the read loop behind an event and
|
||||
delay a command reply or heartbeat (GWC-04). The bounded-channel backpressure
|
||||
window (`EventChannelFullModeTimeout`) and the sustained-overflow fault are
|
||||
applied by the event write loop, not the read loop.
|
||||
|
||||
### Event write loop
|
||||
|
||||
The event write loop drains the staging channel and performs the timed write
|
||||
into the bounded inbound event channel. When the inbound channel stays full past
|
||||
`EventChannelFullModeTimeout` it faults the session (`ProtocolViolation`) — the
|
||||
same overflow contract as before, moved off the read loop.
|
||||
|
||||
### Write loop
|
||||
|
||||
The write loop serializes all writes to the pipe. No other code should write to
|
||||
@@ -683,8 +699,11 @@ password in a form post, calls `DashboardAuthenticator` to bind against
|
||||
`MxGateway:Ldap`, resolves the user's LDAP groups through
|
||||
`MxGateway:Dashboard:GroupToRole` to one of `Admin` / `Viewer`, and signs in
|
||||
with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only,
|
||||
secure, strict SameSite, and named `__Host-MxGatewayDashboard`. Logout
|
||||
clears it. Login and logout posts validate antiforgery tokens. SignalR
|
||||
secure, and strict SameSite. It is named `__Host-MxGatewayDashboard` when
|
||||
`MxGateway:Dashboard:RequireHttpsCookie` is true (default) and no
|
||||
`MxGateway:Dashboard:CookieName` override is set; otherwise it falls back to the
|
||||
plain `MxGatewayDashboard` name (the `__Host-` prefix requires a Secure cookie).
|
||||
Logout clears it. Login and logout posts validate antiforgery tokens. SignalR
|
||||
connections additionally accept a 30-minute data-protected bearer minted at
|
||||
`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests
|
||||
to bypass the cookie requirement and defaults to `true`.
|
||||
|
||||
@@ -408,6 +408,34 @@ Run the gateway test project after shared gateway test infrastructure changes:
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
CI runs on Gitea Actions (`.gitea/workflows/ci.yml`; origin is Gitea at
|
||||
`gitea.dohertylan.com`) on every push and pull request. The pipeline is split by
|
||||
runtime because the x86 Worker cannot build on Linux:
|
||||
|
||||
- **`portable`** (Linux runner) — builds `src/ZB.MOM.WW.MxGateway.NonWindows.slnx`,
|
||||
runs the codegen/descriptor freshness guard (`scripts/check-codegen.ps1`), runs the
|
||||
gateway fake-worker tests, and builds/tests the clients that run on Linux: .NET client
|
||||
build, Go (`gofmt` + `go build` + `go test`), Rust (`cargo fmt --check` + `cargo test`
|
||||
+ `cargo clippy -D warnings`), and Python (`pytest`).
|
||||
- **`java`** (Linux, JDK 17) — `gradle test`. The protobuf gradle plugin rewrites
|
||||
`MxaccessGateway.java` with spurious protobuf-runtime-version churn on every build, so
|
||||
when no `.proto` changed the job reverts that one file (`git checkout`) before asserting
|
||||
the generated tree is clean. The dev Mac has no JRE, so Java verification is CI-only.
|
||||
- **`windows`** (self-hosted windev runner) — the only host that builds the **x86 /
|
||||
net48 Worker and Worker.Tests**; these are Windows-only and out of scope for the Linux
|
||||
jobs. This job also proves the net48 build against the committed `Generated/` (the
|
||||
definitive guard for the "regenerate and commit `Generated/`" rule).
|
||||
- **`live-mxaccess`** (self-hosted, MXAccess-installed) — scheduled only
|
||||
(`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`); never gates a push.
|
||||
|
||||
The freshness guard `scripts/check-codegen.ps1` fails the build when the committed
|
||||
client descriptor set or the C# `Generated/` no longer matches the current `.proto`
|
||||
sources — the codegen drift class this repo has hit repeatedly (stale client
|
||||
descriptors, net48 `CS0246` on unregenerated protos). See
|
||||
[Client Proto Generation](./ClientProtoGeneration.md) and [Contracts](./Contracts.md).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Cross-Language Smoke Matrix](./CrossLanguageSmokeMatrix.md)
|
||||
|
||||
+11
-2
@@ -10,7 +10,7 @@ The layer is composed of four collaborators:
|
||||
|
||||
| Type | Lifetime | Role |
|
||||
|------|----------|------|
|
||||
| `MxAccessGatewayService` | scoped (gRPC) | Implements the six `MxAccessGateway` RPCs, performs exception mapping. |
|
||||
| `MxAccessGatewayService` | scoped (gRPC) | Implements the seven `MxAccessGateway` RPCs, performs exception mapping. |
|
||||
| `MxAccessGrpcRequestValidator` | singleton | Rejects malformed requests before any session work runs. |
|
||||
| `MxAccessGrpcMapper` | singleton | Converts public proto types to internal `WorkerCommand`/`WorkerEvent` types and back. |
|
||||
| `IEventStreamService` (`EventStreamService`) | singleton | Owns the event stream pipeline, including bounded queue and backpressure handling. |
|
||||
@@ -29,7 +29,7 @@ A second gRPC service, `GalaxyRepositoryGrpcService`, is mapped alongside it. It
|
||||
|
||||
## RPC Handlers
|
||||
|
||||
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — six in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, and `StreamAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
|
||||
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — seven in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, `StreamAlarms`, and `QueryActiveAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
|
||||
|
||||
Public gRPC send and receive message sizes are configured from
|
||||
`MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use
|
||||
@@ -94,6 +94,10 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
|
||||
|
||||
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
|
||||
|
||||
### `QueryActiveAlarms`
|
||||
|
||||
`QueryActiveAlarms` is a server-streaming, **session-less** RPC that returns a point-in-time snapshot of the currently-active alarm set. Unlike `StreamAlarms`, it does not stay attached for live transitions — it writes the snapshot and completes, which clients use after a reconnect to reseed alarm state or to reconcile alarms that may have been missed during a transport blip. It is served from the always-on `GatewayAlarmMonitor` cache (`IGatewayAlarmService.CurrentAlarms`), so no worker session is opened. The handler does not run through `MxAccessGrpcRequestValidator`; it only null-checks the request inline, then streams one `ActiveAlarmSnapshot` per cached alarm, honouring cancellation between writes. When `alarm_filter_prefix` is non-empty, only alarms whose `alarm_full_reference` starts with that prefix (ordinal `StartsWith`) are emitted; an empty prefix returns the full set. Exceptions are translated by the same `MapException` path as the other handlers. The RPC requires the `event` scope (`GatewayScopes.EventsRead`), the same scope as `StreamEvents` and `StreamAlarms`.
|
||||
|
||||
#### Provider status on the alarm feed
|
||||
|
||||
`AlarmFeedMessage` has a fourth `payload` case, `provider_status`, carrying
|
||||
@@ -173,6 +177,7 @@ receive this event directly.
|
||||
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
|
||||
| `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
|
||||
| `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — |
|
||||
| `QueryActiveAlarms` | No required fields — `alarm_filter_prefix` is optional. Not routed through `MxAccessGrpcRequestValidator`; the handler only null-checks the request. | — |
|
||||
|
||||
The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error:
|
||||
|
||||
@@ -210,6 +215,10 @@ public WorkerCommand MapCommand(MxCommandRequest request)
|
||||
}
|
||||
```
|
||||
|
||||
The command clone above is the only isolating copy on the command path. `WorkerClient.CreateCommandEnvelope` no longer re-clones the `WorkerCommand` into the outbound envelope: the mapper's clone already produced a fresh graph owned by the invoke pipeline, and the envelope is built and owned entirely inside `WorkerClient`, so a second copy would be redundant.
|
||||
|
||||
`MapEvent` takes the opposite stance from `MapCommand`: it transfers ownership of the inner `MxEvent` rather than cloning it. The enclosing `WorkerEvent` is parsed fresh from a single pipe frame and the `SessionEventDistributor` pump is its single consumer, so nothing else aliases or mutates it. The pump then shares that one `MxEvent` across every subscriber and the replay ring, but that fan-out is read-only, so a single instance is safe. If a second consumer of the `WorkerEvent` is ever introduced, restore the clone in `MapEvent`.
|
||||
|
||||
When the worker reply or event payload is missing, the mapper returns a synthetic public message with `ProtocolStatusCode.ProtocolViolation` (for replies) or a sentinel `MxEvent` with `MxEventFamily.Unspecified` (for events). The gateway never relays a partial frame to clients — anything missing is reported as a protocol violation against the worker, not a transport error against the client.
|
||||
|
||||
The mapper also exposes static factory methods for every `ProtocolStatusCode` (`Ok`, `InvalidRequest`, `SessionNotFound`, `SessionNotReady`, `WorkerUnavailable`, `Timeout`, `Canceled`, `ProtocolViolation`) so that handlers and tests can produce status payloads without duplicating the enum-to-string mapping.
|
||||
|
||||
@@ -451,7 +451,9 @@ Deliverables:
|
||||
- LDAP bind against `MxGateway:Ldap`,
|
||||
- LDAP-group → role mapping (`Admin` / `Viewer`) via
|
||||
`MxGateway:Dashboard:GroupToRole`,
|
||||
- HTTP-only secure cookie (`__Host-MxGatewayDashboard`),
|
||||
- HTTP-only secure cookie (`__Host-MxGatewayDashboard` when
|
||||
`RequireHttpsCookie` is true and no `CookieName` override; else
|
||||
`MxGatewayDashboard`),
|
||||
- `/hubs/token` bearer mint for SignalR connections,
|
||||
- `/logout`,
|
||||
- antiforgery protection,
|
||||
|
||||
+24
-11
@@ -44,7 +44,7 @@ All counters are `Counter<long>`. Tag values come from the call sites listed und
|
||||
| `mxgateway.faults` | `category` | Faults reported by session, event, or worker code paths. The category is a `SessionManagerErrorCode` or `WorkerClientErrorCode` name. |
|
||||
| `mxgateway.workers.killed` | `reason` | Forced terminations of worker processes. |
|
||||
| `mxgateway.workers.exited` | `reason` | Clean or fault-driven worker exits. |
|
||||
| `mxgateway.heartbeats.failed` | `session_id` | Worker heartbeat misses tracked per session. |
|
||||
| `mxgateway.heartbeats.failed` | *(none)* | Aggregate worker heartbeat misses. The exported counter carries **no** `session_id` tag — per-session attribution is unbounded cardinality (every session mints a new time series), so it is kept out of the exporter; use the dashboard snapshot or the log scope for per-session detail. |
|
||||
| `mxgateway.grpc.streams.disconnected` | `reason` | Detachments of the dashboard or client gRPC event stream. |
|
||||
| `mxgateway.retries.attempted` | `area` | Resilience retries executed by gateway components. |
|
||||
|
||||
@@ -73,7 +73,7 @@ Observable gauges are pull-based; the `Meter` invokes the supplied callback when
|
||||
| `mxgateway.sessions.open` | `_openSessions` | Currently open sessions tracked by `SessionManager`. |
|
||||
| `mxgateway.workers.running` | `_workersRunning` | Worker clients in a running state. |
|
||||
| `mxgateway.events.worker_queue.depth` | `_workerEventQueueDepth` | Last reported depth of the worker-side event queue. |
|
||||
| `mxgateway.events.grpc_stream_queue.depth` | `_grpcEventStreamQueueDepth` | Backlog held by `EventStreamService` for the active gRPC stream consumer. |
|
||||
| `mxgateway.events.grpc_stream_queue.depth` | `_eventStreamBacklogSources` (summed on demand) | Live backlog buffered across every active `EventStreamService` subscriber, summed from the subscribers' channel `Count` at collection time. |
|
||||
|
||||
## Snapshot Shape
|
||||
|
||||
@@ -173,14 +173,13 @@ This is the only fault path where the factory itself owns the kill decision; onc
|
||||
|
||||
### gRPC event stream service
|
||||
|
||||
`Grpc/EventStreamService.cs` records the dashboard/client event-stream backlog and disconnect counters:
|
||||
`Grpc/EventStreamService.cs` reports the event-stream backlog through a live backlog source and records the disconnect/overflow counters:
|
||||
|
||||
```csharp
|
||||
metrics.AdjustGrpcEventStreamQueueDepth(1);
|
||||
IDisposable backlogRegistration = metrics.RegisterEventStreamBacklogSource(
|
||||
() => subscriber.Reader.CanCount ? subscriber.Reader.Count : 0);
|
||||
...
|
||||
metrics.AdjustGrpcEventStreamQueueDepth(-1);
|
||||
...
|
||||
metrics.AdjustGrpcEventStreamQueueDepth(-remainingDepth);
|
||||
backlogRegistration.Dispose();
|
||||
metrics.StreamDisconnected("Detached");
|
||||
...
|
||||
metrics.QueueOverflow("grpc-event-stream");
|
||||
@@ -189,20 +188,34 @@ metrics.Fault(SessionManagerErrorCode.EventQueueOverflow.ToString());
|
||||
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
|
||||
```
|
||||
|
||||
The service tracks per-message enqueues and dequeues, so `AdjustGrpcEventStreamQueueDepth` updates the aggregate stream backlog. The `Math.Max(0, ...)` clamp inside the adjuster prevents a negative depth if the bookkeeping ever drifts.
|
||||
Rather than pushing a running total on every streamed event (which took the subscriber channel's `Count` lock and the metric lock per event), each subscriber registers its channel as a backlog source for the duration of the stream. The gauge callback and `GetSnapshot` sum those sources' `Count` on demand — only when the metric is scraped or projected — so the streaming hot path does no per-event gauge bookkeeping (GWC-15). Disposing the registration in the `finally` removes the subscriber's contribution, so the gauge reflects only the remaining subscribers and returns to zero once the last stream detaches. Negative source returns are clamped to zero when summed.
|
||||
|
||||
`Grpc/MxAccessGatewayService.cs` records gRPC event send latency around each response-stream write:
|
||||
`Grpc/MxAccessGatewayService.cs` records gRPC event send latency around each response-stream write using the allocation-free timestamp API (no per-event `Stopwatch` object):
|
||||
|
||||
```csharp
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
long sendStartTimestamp = Stopwatch.GetTimestamp();
|
||||
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
|
||||
metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed);
|
||||
metrics.RecordEventStreamSend(
|
||||
publicEvent.Family.ToString(),
|
||||
Stopwatch.GetElapsedTime(sendStartTimestamp));
|
||||
```
|
||||
|
||||
## Dashboard Consumption
|
||||
|
||||
`Dashboard/DashboardSnapshotService.cs` calls `_metrics.GetSnapshot()` once per `GetSnapshot` invocation and projects it into the dashboard transport types together with the session registry view. The dashboard receives a single, internally consistent snapshot per tick rather than reading individual counters at separate times. See [Gateway Dashboard Design](./GatewayDashboardDesign.md) and [Dashboard Interface Design](./DashboardInterfaceDesign.md) for the projection rules and wire format.
|
||||
|
||||
## Auth Store Write Churn
|
||||
|
||||
The per-RPC authentication path is a hidden write source: the shared verifier
|
||||
refreshes `last_used_utc` on every authenticated call. To keep that off the
|
||||
auth-store throughput ceiling, the gateway caches successful verifications for a
|
||||
short TTL and coalesces the `last_used_utc` write to at most one per key per
|
||||
window (`MxGateway:Security:ApiKeyVerificationCacheSeconds` /
|
||||
`ApiKeyLastUsedCoalesceSeconds`, defaults 15 s / 60 s). This bounds WAL churn on
|
||||
the bulk-read workload without dropping any metric — auth activity is still
|
||||
observable via command-throughput counters and audit rows. See
|
||||
[Authentication](./Authentication.md#hot-path-caching-and-last-used-coalescing).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Dashboard Design](./GatewayDashboardDesign.md)
|
||||
|
||||
@@ -251,7 +251,7 @@ The loop should update a heartbeat timestamp after:
|
||||
- processing an MXAccess event.
|
||||
|
||||
`StaRuntime` implements this runtime boundary in the worker. It starts one
|
||||
background thread named `ZB.MOM.WW.MxGateway.Worker.STA`, sets it to `ApartmentState.STA`,
|
||||
background thread named `MxGateway.Worker.STA`, sets it to `ApartmentState.STA`,
|
||||
initializes COM through `StaComApartmentInitializer`, and runs
|
||||
`StaMessagePump`. Commands are scheduled through `InvokeAsync`; the command
|
||||
queue signals an `AutoResetEvent` so `MsgWaitForMultipleObjectsEx` can wake the
|
||||
@@ -650,8 +650,8 @@ Heartbeat payload includes:
|
||||
|
||||
`MxAccessStaSession.CaptureHeartbeat()` reads `StaRuntime.LastActivityUtc` and
|
||||
`StaCommandDispatcher` queue state without touching the raw MXAccess COM object
|
||||
outside the STA. Event queue depth and event sequence are reported as zero until
|
||||
the event queue implementation owns those counters.
|
||||
outside the STA. Event queue depth (`eventQueue.Count`) and event sequence
|
||||
(`eventQueue.LastEventSequence`) are populated from the live event queue.
|
||||
|
||||
The STA watchdog currently emits a `WorkerFault` with
|
||||
`WorkerFaultCategory.StaHung` when `LastStaActivityUtc` is older than
|
||||
|
||||
@@ -17,8 +17,17 @@ payload_length bytes protobuf WorkerEnvelope
|
||||
```
|
||||
|
||||
The reader rejects zero-length payloads and payloads larger than the configured
|
||||
maximum before allocating the payload buffer. The default maximum is 16 MiB,
|
||||
matching the gateway process design.
|
||||
maximum before allocating the payload buffer. The default maximum is the 16 MiB
|
||||
public gRPC cap plus a 64 KiB envelope-overhead reserve (16842752 bytes) so a
|
||||
maximally-sized accepted gRPC payload always fits one worker frame once wrapped
|
||||
in a `WorkerEnvelope`.
|
||||
|
||||
The gateway is the source of truth for this maximum: it conveys the negotiated
|
||||
value in the handshake as `GatewayHello.max_frame_bytes`, and the worker adopts
|
||||
it as its `WorkerFrameProtocolOptions.MaxMessageBytes` instead of a hard-coded
|
||||
default. A `max_frame_bytes` of 0 (an older gateway that never set the field)
|
||||
means "use the worker's built-in default". This keeps both ends framing to the
|
||||
same limit rather than depending on matched compile-time constants.
|
||||
|
||||
## Envelope Validation
|
||||
|
||||
|
||||
+2
-2
@@ -20,13 +20,13 @@ The installed MXAccess interop assembly declares an `Apartment` threading model
|
||||
|
||||
## STA Thread Initialization
|
||||
|
||||
`StaRuntime`'s constructor configures a background `Thread` named `ZB.MOM.WW.MxGateway.Worker.STA` and forces it into `ApartmentState.STA` before the thread starts. `Start()` releases the thread and then blocks on `startedEvent` so callers observe a fully-initialized apartment (or a captured `startupException`) before the first `InvokeAsync` call:
|
||||
`StaRuntime`'s constructor configures a background `Thread` named `MxGateway.Worker.STA` and forces it into `ApartmentState.STA` before the thread starts. `Start()` releases the thread and then blocks on `startedEvent` so callers observe a fully-initialized apartment (or a captured `startupException`) before the first `InvokeAsync` call:
|
||||
|
||||
```csharp
|
||||
staThread = new Thread(ThreadMain)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "ZB.MOM.WW.MxGateway.Worker.STA"
|
||||
Name = "MxGateway.Worker.STA"
|
||||
};
|
||||
staThread.SetApartmentState(ApartmentState.STA);
|
||||
```
|
||||
|
||||
@@ -13,22 +13,23 @@
|
||||
{"id": 117, "subject": "Task 10: Proto - ReplayGap signal", "status": "completed", "blockedBy": [116]},
|
||||
{"id": 118, "subject": "Task 11: Detach-grace session retention", "status": "completed", "blockedBy": [117]},
|
||||
{"id": 119, "subject": "Task 12: Replay-on-reconnect + emit ReplayGap", "status": "completed", "blockedBy": [118, 110]},
|
||||
{"id": 120, "subject": "Task 13: Owner re-validation on reconnect", "status": "pending", "blockedBy": [119, 108]},
|
||||
{"id": 121, "subject": "Task 14: Client ReplayGap handling - all 5 clients", "status": "pending", "blockedBy": [117]},
|
||||
{"id": 122, "subject": "Task 15: Reconnect integration test (fake worker)", "status": "pending", "blockedBy": [119]},
|
||||
{"id": 123, "subject": "Task 16: gRPC session-owner gate + all-sessions admin scope", "status": "pending", "blockedBy": [116, 108]},
|
||||
{"id": 124, "subject": "Task 17: Session Tag + dashboard group-to-tag config", "status": "pending", "blockedBy": [116]},
|
||||
{"id": 125, "subject": "Task 18: EventsHub per-session ACL + hub-token tag claim", "status": "pending", "blockedBy": [124]},
|
||||
{"id": 126, "subject": "Task 19: ACL tests incl. live LDAP users", "status": "pending", "blockedBy": [125]},
|
||||
{"id": 127, "subject": "Task 20: Stable gateway-instance id + stable pipe naming", "status": "pending", "blockedBy": [126]},
|
||||
{"id": 128, "subject": "Task 21: Adoption manifest store (SQLite)", "status": "pending", "blockedBy": [127]},
|
||||
{"id": 129, "subject": "Task 22: Proto - worker adopt/reconnect frame", "status": "pending", "blockedBy": [128]},
|
||||
{"id": 130, "subject": "Task 23: Worker phone-home reconnect loop + self-terminate", "status": "pending", "blockedBy": [129]},
|
||||
{"id": 131, "subject": "Task 24: Gateway adoption - re-open pipes, nonce-validate, reject impostors", "status": "pending", "blockedBy": [130]},
|
||||
{"id": 132, "subject": "Task 25: Resync adopted worker + ReplayGap to subscribers", "status": "pending", "blockedBy": [131, 119]},
|
||||
{"id": 133, "subject": "Task 26: EnableOrphanReattach flag (default off) + terminator fallback", "status": "pending", "blockedBy": [131]},
|
||||
{"id": 134, "subject": "Task 27: Gateway-restart reattach round-trip (WINDEV + live worker)", "status": "pending", "blockedBy": [132, 133]},
|
||||
{"id": 135, "subject": "Task 28: Documented-rule reversals + stillpending refresh", "status": "pending", "blockedBy": [134]}
|
||||
{"id": 120, "subject": "Task 13: Owner re-validation on reconnect", "status": "completed", "blockedBy": [119, 108], "note": "Shipped as archreview TST-02 (P0): session attach is owner-scoped."},
|
||||
{"id": 121, "subject": "Task 14: Client ReplayGap handling - all 5 clients", "status": "in_progress", "blockedBy": [117], "note": "Shipped as archreview CLI-15 for .NET/Go/Rust/Python; Java pending (windev batch)."},
|
||||
{"id": 122, "subject": "Task 15: Reconnect integration test (fake worker)", "status": "completed", "blockedBy": [119], "note": "Shipped as archreview TST-01: GatewayEndToEndReconnectReplayTests."},
|
||||
{"id": 123, "subject": "Task 16: gRPC session-owner gate + all-sessions admin scope", "status": "pending", "blockedBy": [116, 108], "note": "gRPC owner gate exists (TST-02); Phase 4 adds admin all-sessions scope. Tracked as TST-15."},
|
||||
{"id": 124, "subject": "Task 17: Session Tag + dashboard group-to-tag config", "status": "pending", "blockedBy": [116], "note": "Phase 4 / TST-15."},
|
||||
{"id": 125, "subject": "Task 18: EventsHub per-session ACL + hub-token tag claim", "status": "pending", "blockedBy": [124], "note": "Phase 4 / TST-15. Decision settled: admin-sees-all, Viewer strict per owned/granted session."},
|
||||
{"id": 126, "subject": "Task 19: ACL tests incl. live LDAP users", "status": "pending", "blockedBy": [125], "note": "Phase 4 / TST-15."},
|
||||
{"id": 127, "subject": "Task 20: Stable gateway-instance id + stable pipe naming", "status": "deferred", "blockedBy": [126], "note": "Phase 5 deferred, not planned (TST-04)."},
|
||||
{"id": 128, "subject": "Task 21: Adoption manifest store (SQLite)", "status": "deferred", "blockedBy": [127], "note": "Phase 5 deferred, not planned (TST-04)."},
|
||||
{"id": 129, "subject": "Task 22: Proto - worker adopt/reconnect frame", "status": "deferred", "blockedBy": [128], "note": "Phase 5 deferred, not planned (TST-04)."},
|
||||
{"id": 130, "subject": "Task 23: Worker phone-home reconnect loop + self-terminate", "status": "deferred", "blockedBy": [129], "note": "Phase 5 deferred, not planned (TST-04)."},
|
||||
{"id": 131, "subject": "Task 24: Gateway adoption - re-open pipes, nonce-validate, reject impostors", "status": "deferred", "blockedBy": [130], "note": "Phase 5 deferred, not planned (TST-04)."},
|
||||
{"id": 132, "subject": "Task 25: Resync adopted worker + ReplayGap to subscribers", "status": "deferred", "blockedBy": [131, 119], "note": "Phase 5 deferred, not planned (TST-04)."},
|
||||
{"id": 133, "subject": "Task 26: EnableOrphanReattach flag (default off) + terminator fallback", "status": "deferred", "blockedBy": [131], "note": "Phase 5 deferred, not planned (TST-04). The EnableOrphanReattach flag does not exist yet."},
|
||||
{"id": 134, "subject": "Task 27: Gateway-restart reattach round-trip (WINDEV + live worker)", "status": "deferred", "blockedBy": [132, 133], "note": "Phase 5 deferred, not planned (TST-04)."},
|
||||
{"id": 135, "subject": "Task 28: Documented-rule reversals + stillpending refresh", "status": "deferred", "blockedBy": [134], "note": "Phase 5 deferred, not planned (TST-04)."}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
"governance": "2026-07-09 (archreview TST-04): Phase 3 finished (Task 13=TST-02, Task 15=TST-01, Task 14=CLI-15 4/5, Java pending). Phase 4 (Tasks 16-19) scoped as TST-15, Viewer decision settled (admin-sees-all + Viewer strict). Phase 5 (Tasks 20-28) DEFERRED, not planned; EnableOrphanReattach does not exist.",
|
||||
"lastUpdated": "2026-07-09"
|
||||
}
|
||||
|
||||
+73
-40
@@ -219,7 +219,12 @@ Dashboard authentication is LDAP-backed (distinct from the API-key model on
|
||||
the gRPC API). `/login` accepts username and password in a form body, binds
|
||||
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
|
||||
via `MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure
|
||||
`__Host-MxGatewayDashboard` cookie. `/logout` clears it. Login and logout
|
||||
cookie. The cookie is named `__Host-MxGatewayDashboard` when
|
||||
`MxGateway:Dashboard:RequireHttpsCookie` is true (the default, forcing Secure)
|
||||
and no `MxGateway:Dashboard:CookieName` override is set; otherwise it is named
|
||||
`MxGatewayDashboard` (the `__Host-` prefix is only valid on a Secure cookie, so
|
||||
it is dropped for HTTP-dev or custom-name deployments). `/logout` clears it.
|
||||
Login and logout
|
||||
posts validate antiforgery tokens. SignalR hub connections accept either the
|
||||
cookie or a 30-minute data-protected bearer minted at `/hubs/token`.
|
||||
`MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the
|
||||
@@ -297,43 +302,46 @@ Pipe security:
|
||||
|
||||
### Worker Envelope
|
||||
|
||||
Every IPC message uses a common envelope:
|
||||
Every IPC message uses a common `WorkerEnvelope`. The authoritative definition
|
||||
lives in `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto` — treat
|
||||
that file as the single source of truth and do not hand-copy the message here,
|
||||
because an inlined copy drifts. The shape below describes the current contract so
|
||||
this document explains *why* the envelope is structured the way it is.
|
||||
|
||||
```protobuf
|
||||
message WorkerEnvelope {
|
||||
uint32 protocol_version = 1;
|
||||
string session_id = 2;
|
||||
uint64 sequence = 3;
|
||||
uint64 correlation_id = 4;
|
||||
oneof body {
|
||||
WorkerHello worker_hello = 10;
|
||||
GatewayHello gateway_hello = 11;
|
||||
WorkerReady worker_ready = 12;
|
||||
WorkerCommand command = 20;
|
||||
WorkerCommandReply command_reply = 21;
|
||||
WorkerEvent event = 22;
|
||||
WorkerHeartbeat heartbeat = 23;
|
||||
WorkerCancel cancel = 24;
|
||||
WorkerShutdown shutdown = 25;
|
||||
WorkerFault fault = 26;
|
||||
}
|
||||
}
|
||||
```
|
||||
The envelope header carries four fields: `protocol_version`, `session_id`, a
|
||||
`uint64 sequence`, and a **`string correlation_id`** (an opaque id, not a
|
||||
numeric one). The `body` is a `oneof` whose arms are the handshake and traffic
|
||||
messages, tagged from 10 upward:
|
||||
|
||||
- `gateway_hello` / `worker_hello` — the startup handshake pair.
|
||||
- `worker_ready` — the worker signalling its MXAccess COM instance is live.
|
||||
- `worker_command` / `worker_command_reply` — a command and its correlated reply.
|
||||
- `worker_cancel` — best-effort cancellation of an in-flight command.
|
||||
- `worker_shutdown` / `worker_shutdown_ack` — graceful shutdown request and its
|
||||
acknowledgement.
|
||||
- `worker_event` — a converted MXAccess event.
|
||||
- `worker_heartbeat` — periodic worker liveness/state.
|
||||
- `worker_fault` — a terminal worker fault report.
|
||||
|
||||
Rules:
|
||||
|
||||
- `sequence` is monotonic per sender.
|
||||
- `correlation_id` links commands to replies.
|
||||
- Events use their own correlation id or zero.
|
||||
- `sequence` is a monotonic per-sender counter used as a diagnostic aid; it is
|
||||
not validated for gaps or ordering on receive (the named pipe already
|
||||
guarantees FIFO delivery).
|
||||
- `correlation_id` links a command to its reply; it is authoritative on the
|
||||
envelope, and the inner `MxCommandReply.correlation_id` echoes it for
|
||||
MXAccess parity.
|
||||
- Events carry their own correlation id or leave it empty.
|
||||
- Replies must preserve MXAccess HRESULT/status information even when the
|
||||
command is also represented as a protocol-level failure.
|
||||
- Protocol version mismatch fails session creation.
|
||||
|
||||
## Public gRPC API
|
||||
|
||||
The external API should be session-oriented. A bidirectional stream is the best
|
||||
long-term shape because it naturally carries commands, replies, events,
|
||||
heartbeats, and cancellation.
|
||||
The external API is session-oriented. The shipped service is unary plus
|
||||
server-streaming; the authoritative definition is
|
||||
`src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto`. As built, the
|
||||
`MxAccessGateway` service exposes seven RPCs:
|
||||
|
||||
```protobuf
|
||||
service MxAccessGateway {
|
||||
@@ -341,19 +349,34 @@ service MxAccessGateway {
|
||||
rpc CloseSession(CloseSessionRequest) returns (CloseSessionReply);
|
||||
rpc Invoke(MxCommandRequest) returns (MxCommandReply);
|
||||
rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent);
|
||||
rpc Session(stream ClientMessage) returns (stream ServerMessage);
|
||||
rpc AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply);
|
||||
rpc StreamAlarms(StreamAlarmsRequest) returns (stream AlarmFeedMessage);
|
||||
rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream ActiveAlarmSnapshot);
|
||||
}
|
||||
```
|
||||
|
||||
Recommended rollout:
|
||||
`OpenSession`, `CloseSession`, and `Invoke` are unary; `StreamEvents`,
|
||||
`StreamAlarms`, and `QueryActiveAlarms` are server-streaming. `AcknowledgeAlarm`,
|
||||
`StreamAlarms`, and `QueryActiveAlarms` are session-less — they route to the
|
||||
gateway's always-on central alarm monitor rather than a client worker session.
|
||||
The unary-plus-event-stream shape is easier to debug and reason about than a
|
||||
single multiplexed channel.
|
||||
|
||||
1. Implement unary `OpenSession`, `CloseSession`, and `Invoke`.
|
||||
2. Implement server-streaming `StreamEvents`.
|
||||
3. Add bidirectional `Session` after the command/event model is stable.
|
||||
### Future work: bidirectional `Session` (not implemented)
|
||||
|
||||
The unary plus event-stream shape is easier to debug initially. The
|
||||
bidirectional stream can later reduce per-command overhead and improve
|
||||
backpressure.
|
||||
A single bidirectional `Session` stream carrying commands, replies, events,
|
||||
heartbeats, and cancellation was considered as a possible long-term shape:
|
||||
|
||||
```protobuf
|
||||
// Not implemented — design sketch only.
|
||||
rpc Session(stream ClientMessage) returns (stream ServerMessage);
|
||||
```
|
||||
|
||||
It is **not part of the shipped contract** and there are no `ClientMessage` /
|
||||
`ServerMessage` types in `mxaccess_gateway.proto`. A bidirectional stream could
|
||||
later reduce per-command overhead and improve backpressure, but it would only be
|
||||
added after the command/event model is stable and if a concrete requirement
|
||||
appears.
|
||||
|
||||
## Public MXAccess Command Surface
|
||||
|
||||
@@ -424,7 +447,10 @@ Optional diagnostics:
|
||||
- `Ping`
|
||||
- `GetSessionState`
|
||||
- `GetWorkerInfo`
|
||||
- `DrainEvents`
|
||||
- `DrainEvents` — diagnostic; `max_events` is bounded (the gateway rejects requests
|
||||
above a public ceiling, and the worker caps each reply at its own per-reply limit,
|
||||
treating `max_events = 0` as "the default cap") so one drain cannot pack an
|
||||
unbounded, session-killing reply frame.
|
||||
- `ShutdownWorker`
|
||||
|
||||
Do not compress MXAccess semantics into generic verbs too early. A command enum
|
||||
@@ -747,8 +773,10 @@ Gateway policy:
|
||||
|
||||
- one event sequencer per session,
|
||||
- preserve per-session event order,
|
||||
- allow one active client event subscriber per session,
|
||||
- reject a second subscriber with a clear session error,
|
||||
- allow one active client event subscriber per session **by default**, rejecting
|
||||
a second subscriber with a clear session error; multi-subscriber fan-out is
|
||||
config-gated (`AllowMultipleEventSubscribers`, off by default) — see the
|
||||
fan-out and reconnect detail in [Sessions](docs/Sessions.md),
|
||||
- use a bounded `EventStreamService` queue between worker events and gRPC
|
||||
writes,
|
||||
- fault the session when the bounded stream queue overflows,
|
||||
@@ -856,7 +884,12 @@ Baseline choices:
|
||||
Optimizations after parity:
|
||||
|
||||
- batch commands where MXAccess semantics allow,
|
||||
- batch events from worker to gateway while preserving order,
|
||||
- batch events from worker to gateway while preserving order. Implemented so
|
||||
far: the worker frame writer coalesces the *flush* across a drained batch of
|
||||
event frames, so a burst costs one flush syscall rather than one per event
|
||||
(WRK-12). Still not implemented: a multi-event `WorkerEnvelope` body that
|
||||
packs several events into a single frame — the wire still carries one event
|
||||
per `worker_event` frame (IPC-15), so this remains an additive-proto change,
|
||||
- optional data-change coalescing by item handle,
|
||||
- memory-mapped payload slabs for very large arrays,
|
||||
- shared schema for typed values to avoid raw COM marshaling at the gateway,
|
||||
|
||||
+48
-21
@@ -15,8 +15,27 @@ The authoritative resume state lives in
|
||||
|
||||
## Status
|
||||
|
||||
**12 of 28 tasks complete** (Phases 1–2 + reconnect core of Phase 3). All completed
|
||||
work is merged to `main` (commit `c446bef`, pushed to origin).
|
||||
**Governance update 2026-07-09 (archreview TST-04).** The epic is resolved into three
|
||||
decisions rather than one open backlog:
|
||||
|
||||
- **Phase 3 (reconnect) — finishing now, essentially complete.** Task 13 (owner
|
||||
re-validation) shipped as archreview **TST-02** (P0, session attach is owner-scoped,
|
||||
see CLAUDE.md Authentication). Task 15 (reconnect integration test) shipped as
|
||||
**TST-01** (`GatewayEndToEndReconnectReplayTests`). Task 14 (client `ReplayGap`
|
||||
handling) shipped as **CLI-15** for four of five clients (.NET/Go/Rust/Python);
|
||||
the Java client is the only remainder, batched to the windev build host.
|
||||
- **Phase 4 (per-session dashboard ACL) — scoped, not yet built.** Tracked as archreview
|
||||
**TST-15**. The previously-open Viewer-default decision is **settled**: admin-sees-all,
|
||||
Viewer strictly scoped to sessions it owns/is granted — matching TST-02's gRPC owner
|
||||
binding for consistency.
|
||||
- **Phase 5 (orphan-worker reattach) — DEFERRED, not planned.** It reverses the CLAUDE.md
|
||||
invariant "Gateway restart does not reattach orphan workers" and adds an adoption manifest
|
||||
store + worker phone-home protocol. It stays deferred unless a concrete requirement
|
||||
appears. **The `EnableOrphanReattach` flag (Task 26) does not exist and must not be
|
||||
referenced anywhere as if it does** until that task actually lands.
|
||||
|
||||
Original snapshot (historical): **12 of 28 tasks complete** (Phases 1–2 + reconnect core of
|
||||
Phase 3), merged to `main` (commit `c446bef`).
|
||||
|
||||
### Completed — Phase 1 (Foundation)
|
||||
- ✅ Task 1 (#108): Add OwnerKeyId to the session
|
||||
@@ -36,33 +55,41 @@ work is merged to `main` (commit `c446bef`, pushed to origin).
|
||||
- ✅ Task 11 (#118): Detach-grace session retention
|
||||
- ✅ Task 12 (#119): Replay-on-reconnect + emit ReplayGap
|
||||
|
||||
### Pending — Phase 3 finish
|
||||
- ⏳ Task 13 (#120): Owner re-validation on reconnect — blockedBy 12, 1
|
||||
- ⏳ Task 14 (#121): Client ReplayGap handling — all 5 clients — blockedBy 10
|
||||
- Carry the per-language presence-check idiom note for `optional` message fields.
|
||||
- ⏳ Task 15 (#122): Reconnect integration test (fake worker) — blockedBy 12
|
||||
### Phase 3 finish — DONE (via archreview P0/P2)
|
||||
- ✅ Task 13 (#120): Owner re-validation on reconnect — shipped as **TST-02** (P0).
|
||||
- 🔄 Task 14 (#121): Client ReplayGap handling — shipped as **CLI-15** for 4/5 clients
|
||||
(.NET/Go/Rust/Python); Java pending (windev batch). Per-language presence-check idiom
|
||||
for `optional` message fields carried in each client's surface.
|
||||
- ✅ Task 15 (#122): Reconnect integration test (fake worker) — shipped as **TST-01**.
|
||||
|
||||
### Pending — Phase 4 (Per-session dashboard ACL)
|
||||
### Phase 4 (Per-session dashboard ACL) — SCOPED, tracked as archreview TST-15
|
||||
- ⏳ Task 16 (#123): gRPC session-owner gate + all-sessions admin scope — blockedBy 9, 1
|
||||
- Note: the gRPC owner gate itself already exists (TST-02); Phase 4 adds the admin
|
||||
all-sessions scope + the dashboard-side twin.
|
||||
- ⏳ Task 17 (#124): Session Tag + dashboard group-to-tag config — blockedBy 9
|
||||
- ⏳ Task 18 (#125): EventsHub per-session ACL + hub-token tag claim — blockedBy 17
|
||||
- Open decision: Viewer default (admin-sees-all vs strict per-session).
|
||||
- Decision SETTLED: admin-sees-all, Viewer strictly scoped to owned/granted sessions
|
||||
(matches TST-02 gRPC owner binding).
|
||||
- ⏳ Task 19 (#126): ACL tests incl. live LDAP users — blockedBy 18
|
||||
|
||||
### Pending — Phase 5 (Orphan-worker reattach)
|
||||
- ⏳ Task 20 (#127): Stable gateway-instance id + stable pipe naming — blockedBy 19
|
||||
- ⏳ Task 21 (#128): Adoption manifest store (SQLite) — blockedBy 20
|
||||
- ⏳ Task 22 (#129): Proto — worker adopt/reconnect frame — blockedBy 21
|
||||
- ⏳ Task 23 (#130): Worker phone-home reconnect loop + self-terminate — blockedBy 22 (net48/x86, windev)
|
||||
- ⏳ Task 24 (#131): Gateway adoption — re-open pipes, nonce-validate, reject impostors — blockedBy 23
|
||||
- ⏳ Task 25 (#132): Resync adopted worker + ReplayGap to subscribers — blockedBy 24, 12
|
||||
- ⏳ Task 26 (#133): EnableOrphanReattach flag (default off) + terminator fallback — blockedBy 24
|
||||
- ⏳ Task 27 (#134): Gateway-restart reattach round-trip (WINDEV + live worker) — blockedBy 25, 26
|
||||
- ⏳ Task 28 (#135): Documented-rule reversals + stillpending refresh — blockedBy 27
|
||||
### Phase 5 (Orphan-worker reattach) — DEFERRED, NOT PLANNED
|
||||
Deferred unless a concrete requirement appears. It reverses the CLAUDE.md invariant
|
||||
"Gateway restart does not reattach orphan workers" and adds an adoption manifest store +
|
||||
worker phone-home protocol. **`EnableOrphanReattach` (Task 26) does not exist** — do not
|
||||
reference it as if it does until the task lands.
|
||||
- 🚫 Task 20 (#127): Stable gateway-instance id + stable pipe naming
|
||||
- 🚫 Task 21 (#128): Adoption manifest store (SQLite)
|
||||
- 🚫 Task 22 (#129): Proto — worker adopt/reconnect frame
|
||||
- 🚫 Task 23 (#130): Worker phone-home reconnect loop + self-terminate (net48/x86, windev)
|
||||
- 🚫 Task 24 (#131): Gateway adoption — re-open pipes, nonce-validate, reject impostors
|
||||
- 🚫 Task 25 (#132): Resync adopted worker + ReplayGap to subscribers
|
||||
- 🚫 Task 26 (#133): EnableOrphanReattach flag (default off) + terminator fallback
|
||||
- 🚫 Task 27 (#134): Gateway-restart reattach round-trip (WINDEV + live worker)
|
||||
- 🚫 Task 28 (#135): Documented-rule reversals + stillpending refresh
|
||||
|
||||
## Notes
|
||||
- Phase 5 reverses the documented "Gateway restart does not reattach orphan workers"
|
||||
rule (CLAUDE.md) — this was explicitly approved during design.
|
||||
- Phase 5 was designed to reverse the "Gateway restart does not reattach orphan workers"
|
||||
rule (CLAUDE.md), but is now **deferred, not planned** (TST-04) — the invariant stands.
|
||||
- Two deferred follow-ups noted earlier: dashboard visibility of `DetachedAtUtc` on
|
||||
`DashboardSessionSummary`.
|
||||
- Worker (net48/x86) tasks build/test on windev; everything else builds on macOS.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Codegen freshness guard for CI (IPC-01, IPC-19, IPC-20, CLI-02).
|
||||
#
|
||||
# Three checks, all Linux/macOS-runnable (no Server build, no x86 worker):
|
||||
# 1. Published client descriptor set matches the current .proto sources (delegates to
|
||||
# publish-client-proto-inputs.ps1 -Check, which normalizes source_code_info so it is
|
||||
# protoc-version tolerant).
|
||||
# 2. The committed C# under Contracts/Generated matches a fresh regeneration. Grpc.Tools is
|
||||
# pinned in the contracts csproj, so a clean checkout regenerates byte-identical output; a
|
||||
# non-empty git diff means a .proto was edited without regenerating and committing Generated/
|
||||
# (which breaks the net48 worker build with CS0246 — see docs/Contracts.md).
|
||||
# 3. The Rust crate's vendored protos (clients/rust/protos/*.proto — build inputs that make the
|
||||
# crate buildable outside the repo, CLI-02) are byte-identical to the canonical Contracts
|
||||
# protos. A drift means a .proto was edited without refreshing the vendored copies, which would
|
||||
# publish a stale wire contract to crate consumers while the in-repo build stays correct.
|
||||
#
|
||||
# The x86 Worker + Worker.Tests are Windows-only and are guarded by the Windows CI job, not here.
|
||||
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
|
||||
$generatedDir = Join-Path $repoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/Generated'
|
||||
$contractsProject = Join-Path $repoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj'
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Write-Host '== Check 1/2: client descriptor set freshness =='
|
||||
try {
|
||||
& (Join-Path $PSScriptRoot 'publish-client-proto-inputs.ps1') -Check
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$failures.Add('Client descriptor set is stale (publish-client-proto-inputs.ps1 -Check failed).')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$failures.Add("Descriptor freshness check failed: $($_.Exception.Message)")
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== Check 2/2: Contracts/Generated matches a fresh regeneration =='
|
||||
try {
|
||||
# Force a full regeneration: Grpc.Tools skips regen when the committed .cs look up to date, so
|
||||
# remove them first (the documented "del Generated/*.cs to force regen" trick).
|
||||
if (Test-Path $generatedDir) {
|
||||
Get-ChildItem -Path $generatedDir -Filter '*.cs' -Recurse -File | Remove-Item -Force
|
||||
}
|
||||
|
||||
& dotnet build $contractsProject -c Release --nologo | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$failures.Add('Contracts project failed to build during codegen check.')
|
||||
}
|
||||
|
||||
$diff = (& git -C $repoRoot status --porcelain -- 'src/ZB.MOM.WW.MxGateway.Contracts/Generated').Trim()
|
||||
if (-not [string]::IsNullOrEmpty($diff)) {
|
||||
Write-Host $diff
|
||||
$failures.Add('Contracts/Generated differs from a fresh regeneration. Run `dotnet build` on the contracts project and commit Generated/ (required for the net48 worker build).')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$failures.Add("Generated codegen check failed: $($_.Exception.Message)")
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== Check 3/3: Rust vendored protos match canonical Contracts protos =='
|
||||
try {
|
||||
$canonicalProtoDir = Join-Path $repoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/Protos'
|
||||
$vendoredProtoDir = Join-Path $repoRoot 'clients/rust/protos'
|
||||
foreach ($vendored in Get-ChildItem -Path $vendoredProtoDir -Filter '*.proto' -File) {
|
||||
$canonical = Join-Path $canonicalProtoDir $vendored.Name
|
||||
if (-not (Test-Path $canonical)) {
|
||||
$failures.Add("Vendored Rust proto has no canonical counterpart: $($vendored.Name).")
|
||||
continue
|
||||
}
|
||||
$vendoredHash = (Get-FileHash -Algorithm SHA256 $vendored.FullName).Hash
|
||||
$canonicalHash = (Get-FileHash -Algorithm SHA256 $canonical).Hash
|
||||
if ($vendoredHash -ne $canonicalHash) {
|
||||
$failures.Add("Rust vendored proto drifted from canonical: clients/rust/protos/$($vendored.Name). Refresh it from src/ZB.MOM.WW.MxGateway.Contracts/Protos/$($vendored.Name).")
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$failures.Add("Rust vendored proto check failed: $($_.Exception.Message)")
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
if ($failures.Count -gt 0) {
|
||||
Write-Host 'Codegen freshness check FAILED:' -ForegroundColor Red
|
||||
foreach ($failure in $failures) {
|
||||
Write-Host " - $failure" -ForegroundColor Red
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host 'Codegen freshness check passed.' -ForegroundColor Green
|
||||
@@ -187,7 +187,9 @@ function Invoke-PackRust {
|
||||
}
|
||||
|
||||
Write-Host 'Running cargo package...'
|
||||
& cargo package --no-verify
|
||||
# No --no-verify: the crate vendors its protos (clients/rust/protos) so
|
||||
# the verify build proves it compiles standalone from the tarball (CLI-02).
|
||||
& cargo package
|
||||
if ($LASTEXITCODE -ne 0) { throw 'cargo package failed.' }
|
||||
|
||||
$packageDir = Join-Path $rustDir 'target/package'
|
||||
@@ -207,7 +209,7 @@ function Invoke-PackRust {
|
||||
Write-Host 'Publishing Rust crate to Gitea...' -ForegroundColor Yellow
|
||||
Push-Location (Join-Path $RepoRoot 'clients/rust')
|
||||
try {
|
||||
& cargo publish --no-verify --registry dohertj2-gitea
|
||||
& cargo publish --registry dohertj2-gitea
|
||||
if ($LASTEXITCODE -ne 0) { throw 'cargo publish failed.' }
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
|
||||
@@ -6,23 +6,46 @@ param(
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Pinned protoc toolchain version used to generate the committed client descriptor set.
|
||||
# See docs/ToolchainLinks.md. Generating (committing) the canonical artifact must use this
|
||||
# exact version so the checked-in bytes are reproducible. The -Check comparison is made
|
||||
# tolerant of protoc-version encoding drift (chiefly source_code_info, see IPC-20) by
|
||||
# normalizing both the committed and the freshly built descriptor through the *same* protoc
|
||||
# with source info stripped, so it does not false-fail across protoc releases.
|
||||
$PinnedProtocVersion = "34.1"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$protoRoot = Join-Path $repoRoot "src/ZB.MOM.WW.MxGateway.Contracts/Protos"
|
||||
$manifestPath = Join-Path $repoRoot "clients/proto/proto-inputs.json"
|
||||
$descriptorPath = Join-Path $repoRoot "clients/proto/descriptors/mxaccessgw-client-v1.protoset"
|
||||
$protoFiles = @("mxaccess_gateway.proto", "mxaccess_worker.proto", "galaxy_repository.proto")
|
||||
|
||||
function Resolve-Protoc {
|
||||
$pathCommand = Get-Command "protoc.exe" -ErrorAction SilentlyContinue
|
||||
if ($null -ne $pathCommand) {
|
||||
return $pathCommand.Source
|
||||
# Prefer protoc on PATH (protoc on Linux/macOS, protoc.exe on Windows), then fall back to
|
||||
# the documented winget install location on Windows.
|
||||
foreach ($name in @("protoc", "protoc.exe")) {
|
||||
$pathCommand = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($null -ne $pathCommand) {
|
||||
return $pathCommand.Source
|
||||
}
|
||||
}
|
||||
|
||||
$documentedPath = Join-Path $env:LOCALAPPDATA "Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe"
|
||||
if (Test-Path $documentedPath) {
|
||||
return $documentedPath
|
||||
if ($env:LOCALAPPDATA) {
|
||||
$documentedPath = Join-Path $env:LOCALAPPDATA "Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe"
|
||||
if (Test-Path $documentedPath) {
|
||||
return $documentedPath
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not find protoc.exe. See docs/ToolchainLinks.md for the documented protobuf toolchain path."
|
||||
throw "Could not find protoc on PATH. See docs/ToolchainLinks.md for the documented protobuf toolchain (protoc $PinnedProtocVersion)."
|
||||
}
|
||||
|
||||
function Get-ProtocVersion {
|
||||
param([string]$Protoc)
|
||||
|
||||
# `protoc --version` prints e.g. "libprotoc 34.1".
|
||||
$raw = (& $Protoc --version) | Select-Object -First 1
|
||||
return ($raw -replace '^libprotoc\s+', '').Trim()
|
||||
}
|
||||
|
||||
function Ensure-Directory {
|
||||
@@ -33,7 +56,29 @@ function Ensure-Directory {
|
||||
}
|
||||
}
|
||||
|
||||
function Compare-FileBytes {
|
||||
function New-SourceInfoFreeDescriptor {
|
||||
# Re-emits an existing descriptor set with source_code_info stripped, by round-tripping it
|
||||
# through protoc with --descriptor_set_in and *without* --include_source_info. Because both
|
||||
# the committed and freshly built descriptors are normalized through the same protoc binary,
|
||||
# any protoc-version-specific encoding cancels out and the byte comparison is stable.
|
||||
param(
|
||||
[string]$Protoc,
|
||||
[string]$InputDescriptor,
|
||||
[string]$OutputDescriptor
|
||||
)
|
||||
|
||||
& $Protoc `
|
||||
"--descriptor_set_in=$InputDescriptor" `
|
||||
"--include_imports" `
|
||||
"--descriptor_set_out=$OutputDescriptor" `
|
||||
@protoFiles
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "protoc normalization (--descriptor_set_in) failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-FileBytesEqual {
|
||||
param(
|
||||
[string]$ExpectedPath,
|
||||
[string]$ActualPath
|
||||
@@ -66,31 +111,67 @@ foreach ($output in $manifest.generatedOutputs.PSObject.Properties.Value) {
|
||||
}
|
||||
|
||||
$protoc = Resolve-Protoc
|
||||
$outputPath = $descriptorPath
|
||||
if ($Check) {
|
||||
$outputPath = Join-Path ([System.IO.Path]::GetTempPath()) ("mxaccessgw-client-v1-" + [System.Guid]::NewGuid().ToString("N") + ".protoset")
|
||||
}
|
||||
$protocVersion = Get-ProtocVersion $protoc
|
||||
|
||||
if ($Check) {
|
||||
# Version drift no longer produces a false "stale" failure: the comparison is normalized
|
||||
# below. A mismatch is surfaced as a warning so it is visible without blocking the gate.
|
||||
if ($protocVersion -ne $PinnedProtocVersion) {
|
||||
Write-Warning "protoc version '$protocVersion' differs from the pinned '$PinnedProtocVersion'. The freshness comparison is normalized and tolerant of this, but regeneration must use the pinned version (see docs/ClientProtoGeneration.md)."
|
||||
}
|
||||
|
||||
$freshDescriptor = Join-Path ([System.IO.Path]::GetTempPath()) ("mxaccessgw-client-v1-fresh-" + [System.Guid]::NewGuid().ToString("N") + ".protoset")
|
||||
$committedNormalized = Join-Path ([System.IO.Path]::GetTempPath()) ("mxaccessgw-client-v1-committed-" + [System.Guid]::NewGuid().ToString("N") + ".protoset")
|
||||
|
||||
try {
|
||||
# Fresh descriptor from the current .proto sources, source info omitted (already normalized).
|
||||
& $protoc `
|
||||
"--proto_path=$protoRoot" `
|
||||
"--include_imports" `
|
||||
"--descriptor_set_out=$freshDescriptor" `
|
||||
@protoFiles
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "protoc failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $descriptorPath)) {
|
||||
throw "Committed descriptor '$descriptorPath' does not exist. Run scripts/publish-client-proto-inputs.ps1 to create it."
|
||||
}
|
||||
|
||||
# Normalize the committed descriptor (which carries source info) through the same protoc.
|
||||
New-SourceInfoFreeDescriptor -Protoc $protoc -InputDescriptor $descriptorPath -OutputDescriptor $committedNormalized
|
||||
|
||||
if (-not (Test-FileBytesEqual -ExpectedPath $committedNormalized -ActualPath $freshDescriptor)) {
|
||||
throw "Client proto descriptor is stale. Run scripts/publish-client-proto-inputs.ps1 with the pinned protoc ($PinnedProtocVersion) and commit the updated descriptor."
|
||||
}
|
||||
|
||||
Write-Host "Client proto descriptor is up to date."
|
||||
}
|
||||
finally {
|
||||
foreach ($temp in @($freshDescriptor, $committedNormalized)) {
|
||||
if (Test-Path $temp) {
|
||||
Remove-Item -LiteralPath $temp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Generating the canonical committed artifact must use the pinned protoc so the bytes are reproducible.
|
||||
if ($protocVersion -ne $PinnedProtocVersion) {
|
||||
throw "protoc version '$protocVersion' does not match the pinned toolchain version '$PinnedProtocVersion'. Install the pinned protoc (see docs/ToolchainLinks.md) before regenerating the committed descriptor, or run with -Check to only verify freshness."
|
||||
}
|
||||
|
||||
try {
|
||||
& $protoc `
|
||||
"--proto_path=$protoRoot" `
|
||||
"--include_imports" `
|
||||
"--include_source_info" `
|
||||
"--descriptor_set_out=$outputPath" `
|
||||
"mxaccess_gateway.proto" `
|
||||
"mxaccess_worker.proto" `
|
||||
"galaxy_repository.proto"
|
||||
"--descriptor_set_out=$descriptorPath" `
|
||||
@protoFiles
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "protoc failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
if ($Check -and -not (Compare-FileBytes -ExpectedPath $descriptorPath -ActualPath $outputPath)) {
|
||||
throw "Client proto descriptor is stale. Run scripts/publish-client-proto-inputs.ps1 and commit the updated descriptor."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($Check -and (Test-Path $outputPath)) {
|
||||
Remove-Item -LiteralPath $outputPath
|
||||
}
|
||||
Write-Host "Wrote client proto descriptor to $descriptorPath (protoc $protocVersion)."
|
||||
}
|
||||
|
||||
@@ -9,6 +9,31 @@
|
||||
<Deterministic>true</Deterministic>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- TST-11: single-source the .NET-side version for Server, Worker, Contracts, and tests
|
||||
(they otherwise stamp the SDK default 1.0.0, so a deployed gateway cannot be correlated
|
||||
to a release). Kept at 0.1.2 to match the Contracts package and the aligned Python/Rust/
|
||||
Go clients; the Java client leads at 0.2.0 after its JDK-17 retarget. The git short SHA is
|
||||
appended to InformationalVersion (0.1.2+<sha>) so support can map a running binary to a
|
||||
commit; the query is guarded so a build outside a git checkout still succeeds. -->
|
||||
<PropertyGroup>
|
||||
<Version>0.1.2</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="StampSourceRevision"
|
||||
BeforeTargets="GetAssemblyVersion;GenerateAssemblyInfo"
|
||||
Condition="'$(SourceRevisionId)' == ''">
|
||||
<Exec Command="git -C "$(MSBuildThisFileDirectory)" rev-parse --short HEAD"
|
||||
ConsoleToMSBuild="true"
|
||||
StandardOutputImportance="Low"
|
||||
ContinueOnError="true"
|
||||
IgnoreExitCode="true">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="_StampedGitSha" />
|
||||
</Exec>
|
||||
<PropertyGroup>
|
||||
<SourceRevisionId Condition="'$(_StampedGitSha)' != ''">$(_StampedGitSha.Trim())</SourceRevisionId>
|
||||
</PropertyGroup>
|
||||
</Target>
|
||||
|
||||
<!-- SQLitePCLRaw.lib.e_sqlite3 2.1.11 (transitive via Microsoft.Data.Sqlite) carries GHSA-2m69-gcr7-jv3q,
|
||||
which surfaces as NU1903 (warning-as-error). No patched e_sqlite3 release exists yet (2.1.11 is latest),
|
||||
so this targeted suppression keeps every OTHER transitive package audited. Remove once an upstream fix ships. -->
|
||||
|
||||
@@ -44,63 +44,64 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
"Y2Nlc3Nfd29ya2VyLnYxLldvcmtlckV2ZW50SAASPwoQd29ya2VyX2hlYXJ0",
|
||||
"YmVhdBgTIAEoCzIjLm14YWNjZXNzX3dvcmtlci52MS5Xb3JrZXJIZWFydGJl",
|
||||
"YXRIABI3Cgx3b3JrZXJfZmF1bHQYFCABKAsyHy5teGFjY2Vzc193b3JrZXIu",
|
||||
"djEuV29ya2VyRmF1bHRIAEIGCgRib2R5IloKDEdhdGV3YXlIZWxsbxIiChpz",
|
||||
"djEuV29ya2VyRmF1bHRIAEIGCgRib2R5InMKDEdhdGV3YXlIZWxsbxIiChpz",
|
||||
"dXBwb3J0ZWRfcHJvdG9jb2xfdmVyc2lvbhgBIAEoDRINCgVub25jZRgCIAEo",
|
||||
"CRIXCg9nYXRld2F5X3ZlcnNpb24YAyABKAkiaQoLV29ya2VySGVsbG8SGAoQ",
|
||||
"cHJvdG9jb2xfdmVyc2lvbhgBIAEoDRINCgVub25jZRgCIAEoCRIZChF3b3Jr",
|
||||
"ZXJfcHJvY2Vzc19pZBgDIAEoBRIWCg53b3JrZXJfdmVyc2lvbhgEIAEoCSKO",
|
||||
"AQoLV29ya2VyUmVhZHkSGQoRd29ya2VyX3Byb2Nlc3NfaWQYASABKAUSFwoP",
|
||||
"bXhhY2Nlc3NfcHJvZ2lkGAIgASgJEhYKDm14YWNjZXNzX2Nsc2lkGAMgASgJ",
|
||||
"EjMKD3JlYWR5X3RpbWVzdGFtcBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U",
|
||||
"aW1lc3RhbXAidwoNV29ya2VyQ29tbWFuZBIvCgdjb21tYW5kGAEgASgLMh4u",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmQSNQoRZW5xdWV1ZV90aW1l",
|
||||
"c3RhbXAYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoEBChJX",
|
||||
"b3JrZXJDb21tYW5kUmVwbHkSMgoFcmVwbHkYASABKAsyIy5teGFjY2Vzc19n",
|
||||
"YXRld2F5LnYxLk14Q29tbWFuZFJlcGx5EjcKE2NvbXBsZXRlZF90aW1lc3Rh",
|
||||
"bXAYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIh4KDFdvcmtl",
|
||||
"ckNhbmNlbBIOCgZyZWFzb24YASABKAkiUQoOV29ya2VyU2h1dGRvd24SLwoM",
|
||||
"Z3JhY2VfcGVyaW9kGAEgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9u",
|
||||
"Eg4KBnJlYXNvbhgCIAEoCSJIChFXb3JrZXJTaHV0ZG93bkFjaxIzCgZzdGF0",
|
||||
"dXMYASABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVz",
|
||||
"IjoKC1dvcmtlckV2ZW50EisKBWV2ZW50GAEgASgLMhwubXhhY2Nlc3NfZ2F0",
|
||||
"ZXdheS52MS5NeEV2ZW50IqUCCg9Xb3JrZXJIZWFydGJlYXQSGQoRd29ya2Vy",
|
||||
"X3Byb2Nlc3NfaWQYASABKAUSLgoFc3RhdGUYAiABKA4yHy5teGFjY2Vzc193",
|
||||
"b3JrZXIudjEuV29ya2VyU3RhdGUSPwobbGFzdF9zdGFfYWN0aXZpdHlfdGlt",
|
||||
"ZXN0YW1wGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIdChVw",
|
||||
"ZW5kaW5nX2NvbW1hbmRfY291bnQYBCABKA0SIgoab3V0Ym91bmRfZXZlbnRf",
|
||||
"cXVldWVfZGVwdGgYBSABKA0SGwoTbGFzdF9ldmVudF9zZXF1ZW5jZRgGIAEo",
|
||||
"BBImCh5jdXJyZW50X2NvbW1hbmRfY29ycmVsYXRpb25faWQYByABKAki9AEK",
|
||||
"C1dvcmtlckZhdWx0EjkKCGNhdGVnb3J5GAEgASgOMicubXhhY2Nlc3Nfd29y",
|
||||
"a2VyLnYxLldvcmtlckZhdWx0Q2F0ZWdvcnkSFgoOY29tbWFuZF9tZXRob2QY",
|
||||
"AiABKAkSFAoHaHJlc3VsdBgDIAEoBUgAiAEBEhYKDmV4Y2VwdGlvbl90eXBl",
|
||||
"GAQgASgJEhoKEmRpYWdub3N0aWNfbWVzc2FnZRgFIAEoCRI8Cg9wcm90b2Nv",
|
||||
"bF9zdGF0dXMYBiABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29s",
|
||||
"U3RhdHVzQgoKCF9ocmVzdWx0KpcCCgtXb3JrZXJTdGF0ZRIcChhXT1JLRVJf",
|
||||
"U1RBVEVfVU5TUEVDSUZJRUQQABIZChVXT1JLRVJfU1RBVEVfU1RBUlRJTkcQ",
|
||||
"ARIcChhXT1JLRVJfU1RBVEVfSEFORFNIQUtJTkcQAhIhCh1XT1JLRVJfU1RB",
|
||||
"VEVfSU5JVElBTElaSU5HX1NUQRADEhYKEldPUktFUl9TVEFURV9SRUFEWRAE",
|
||||
"EiIKHldPUktFUl9TVEFURV9FWEVDVVRJTkdfQ09NTUFORBAFEh4KGldPUktF",
|
||||
"Ul9TVEFURV9TSFVUVElOR19ET1dOEAYSGAoUV09SS0VSX1NUQVRFX1NUT1BQ",
|
||||
"RUQQBxIYChRXT1JLRVJfU1RBVEVfRkFVTFRFRBAIKscEChNXb3JrZXJGYXVs",
|
||||
"dENhdGVnb3J5EiUKIVdPUktFUl9GQVVMVF9DQVRFR09SWV9VTlNQRUNJRklF",
|
||||
"RBAAEisKJ1dPUktFUl9GQVVMVF9DQVRFR09SWV9JTlZBTElEX0FSR1VNRU5U",
|
||||
"UxABEjcKM1dPUktFUl9GQVVMVF9DQVRFR09SWV9HQVRFV0FZX0FVVEhFTlRJ",
|
||||
"Q0FUSU9OX0ZBSUxFRBACEisKJ1dPUktFUl9GQVVMVF9DQVRFR09SWV9QUk9U",
|
||||
"T0NPTF9NSVNNQVRDSBADEiwKKFdPUktFUl9GQVVMVF9DQVRFR09SWV9QUk9U",
|
||||
"T0NPTF9WSU9MQVRJT04QBBIrCidXT1JLRVJfRkFVTFRfQ0FURUdPUllfUElQ",
|
||||
"RV9ESVNDT05ORUNURUQQBRIyCi5XT1JLRVJfRkFVTFRfQ0FURUdPUllfTVhB",
|
||||
"Q0NFU1NfQ1JFQVRJT05fRkFJTEVEEAYSMQotV09SS0VSX0ZBVUxUX0NBVEVH",
|
||||
"T1JZX01YQUNDRVNTX0NPTU1BTkRfRkFJTEVEEAcSOgo2V09SS0VSX0ZBVUxU",
|
||||
"X0NBVEVHT1JZX01YQUNDRVNTX0VWRU5UX0NPTlZFUlNJT05fRkFJTEVEEAgS",
|
||||
"IgoeV09SS0VSX0ZBVUxUX0NBVEVHT1JZX1NUQV9IVU5HEAkSKAokV09SS0VS",
|
||||
"X0ZBVUxUX0NBVEVHT1JZX1FVRVVFX09WRVJGTE9XEAoSKgomV09SS0VSX0ZB",
|
||||
"VUxUX0NBVEVHT1JZX1NIVVRET1dOX1RJTUVPVVQQC0ImqgIjWkIuTU9NLldX",
|
||||
"Lk14R2F0ZXdheS5Db250cmFjdHMuUHJvdG9iBnByb3RvMw=="));
|
||||
"CRIXCg9nYXRld2F5X3ZlcnNpb24YAyABKAkSFwoPbWF4X2ZyYW1lX2J5dGVz",
|
||||
"GAQgASgNImkKC1dvcmtlckhlbGxvEhgKEHByb3RvY29sX3ZlcnNpb24YASAB",
|
||||
"KA0SDQoFbm9uY2UYAiABKAkSGQoRd29ya2VyX3Byb2Nlc3NfaWQYAyABKAUS",
|
||||
"FgoOd29ya2VyX3ZlcnNpb24YBCABKAkijgEKC1dvcmtlclJlYWR5EhkKEXdv",
|
||||
"cmtlcl9wcm9jZXNzX2lkGAEgASgFEhcKD214YWNjZXNzX3Byb2dpZBgCIAEo",
|
||||
"CRIWCg5teGFjY2Vzc19jbHNpZBgDIAEoCRIzCg9yZWFkeV90aW1lc3RhbXAY",
|
||||
"BCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIncKDVdvcmtlckNv",
|
||||
"bW1hbmQSLwoHY29tbWFuZBgBIAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
||||
"TXhDb21tYW5kEjUKEWVucXVldWVfdGltZXN0YW1wGAIgASgLMhouZ29vZ2xl",
|
||||
"LnByb3RvYnVmLlRpbWVzdGFtcCKBAQoSV29ya2VyQ29tbWFuZFJlcGx5EjIK",
|
||||
"BXJlcGx5GAEgASgLMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRS",
|
||||
"ZXBseRI3ChNjb21wbGV0ZWRfdGltZXN0YW1wGAIgASgLMhouZ29vZ2xlLnBy",
|
||||
"b3RvYnVmLlRpbWVzdGFtcCIeCgxXb3JrZXJDYW5jZWwSDgoGcmVhc29uGAEg",
|
||||
"ASgJIlEKDldvcmtlclNodXRkb3duEi8KDGdyYWNlX3BlcmlvZBgBIAEoCzIZ",
|
||||
"Lmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhIOCgZyZWFzb24YAiABKAkiSAoR",
|
||||
"V29ya2VyU2h1dGRvd25BY2sSMwoGc3RhdHVzGAEgASgLMiMubXhhY2Nlc3Nf",
|
||||
"Z2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1cyI6CgtXb3JrZXJFdmVudBIrCgVl",
|
||||
"dmVudBgBIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudCKlAgoP",
|
||||
"V29ya2VySGVhcnRiZWF0EhkKEXdvcmtlcl9wcm9jZXNzX2lkGAEgASgFEi4K",
|
||||
"BXN0YXRlGAIgASgOMh8ubXhhY2Nlc3Nfd29ya2VyLnYxLldvcmtlclN0YXRl",
|
||||
"Ej8KG2xhc3Rfc3RhX2FjdGl2aXR5X3RpbWVzdGFtcBgDIAEoCzIaLmdvb2ds",
|
||||
"ZS5wcm90b2J1Zi5UaW1lc3RhbXASHQoVcGVuZGluZ19jb21tYW5kX2NvdW50",
|
||||
"GAQgASgNEiIKGm91dGJvdW5kX2V2ZW50X3F1ZXVlX2RlcHRoGAUgASgNEhsK",
|
||||
"E2xhc3RfZXZlbnRfc2VxdWVuY2UYBiABKAQSJgoeY3VycmVudF9jb21tYW5k",
|
||||
"X2NvcnJlbGF0aW9uX2lkGAcgASgJIvQBCgtXb3JrZXJGYXVsdBI5CghjYXRl",
|
||||
"Z29yeRgBIAEoDjInLm14YWNjZXNzX3dvcmtlci52MS5Xb3JrZXJGYXVsdENh",
|
||||
"dGVnb3J5EhYKDmNvbW1hbmRfbWV0aG9kGAIgASgJEhQKB2hyZXN1bHQYAyAB",
|
||||
"KAVIAIgBARIWCg5leGNlcHRpb25fdHlwZRgEIAEoCRIaChJkaWFnbm9zdGlj",
|
||||
"X21lc3NhZ2UYBSABKAkSPAoPcHJvdG9jb2xfc3RhdHVzGAYgASgLMiMubXhh",
|
||||
"Y2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1c0IKCghfaHJlc3VsdCqX",
|
||||
"AgoLV29ya2VyU3RhdGUSHAoYV09SS0VSX1NUQVRFX1VOU1BFQ0lGSUVEEAAS",
|
||||
"GQoVV09SS0VSX1NUQVRFX1NUQVJUSU5HEAESHAoYV09SS0VSX1NUQVRFX0hB",
|
||||
"TkRTSEFLSU5HEAISIQodV09SS0VSX1NUQVRFX0lOSVRJQUxJWklOR19TVEEQ",
|
||||
"AxIWChJXT1JLRVJfU1RBVEVfUkVBRFkQBBIiCh5XT1JLRVJfU1RBVEVfRVhF",
|
||||
"Q1VUSU5HX0NPTU1BTkQQBRIeChpXT1JLRVJfU1RBVEVfU0hVVFRJTkdfRE9X",
|
||||
"ThAGEhgKFFdPUktFUl9TVEFURV9TVE9QUEVEEAcSGAoUV09SS0VSX1NUQVRF",
|
||||
"X0ZBVUxURUQQCCrHBAoTV29ya2VyRmF1bHRDYXRlZ29yeRIlCiFXT1JLRVJf",
|
||||
"RkFVTFRfQ0FURUdPUllfVU5TUEVDSUZJRUQQABIrCidXT1JLRVJfRkFVTFRf",
|
||||
"Q0FURUdPUllfSU5WQUxJRF9BUkdVTUVOVFMQARI3CjNXT1JLRVJfRkFVTFRf",
|
||||
"Q0FURUdPUllfR0FURVdBWV9BVVRIRU5USUNBVElPTl9GQUlMRUQQAhIrCidX",
|
||||
"T1JLRVJfRkFVTFRfQ0FURUdPUllfUFJPVE9DT0xfTUlTTUFUQ0gQAxIsCihX",
|
||||
"T1JLRVJfRkFVTFRfQ0FURUdPUllfUFJPVE9DT0xfVklPTEFUSU9OEAQSKwon",
|
||||
"V09SS0VSX0ZBVUxUX0NBVEVHT1JZX1BJUEVfRElTQ09OTkVDVEVEEAUSMgou",
|
||||
"V09SS0VSX0ZBVUxUX0NBVEVHT1JZX01YQUNDRVNTX0NSRUFUSU9OX0ZBSUxF",
|
||||
"RBAGEjEKLVdPUktFUl9GQVVMVF9DQVRFR09SWV9NWEFDQ0VTU19DT01NQU5E",
|
||||
"X0ZBSUxFRBAHEjoKNldPUktFUl9GQVVMVF9DQVRFR09SWV9NWEFDQ0VTU19F",
|
||||
"VkVOVF9DT05WRVJTSU9OX0ZBSUxFRBAIEiIKHldPUktFUl9GQVVMVF9DQVRF",
|
||||
"R09SWV9TVEFfSFVORxAJEigKJFdPUktFUl9GQVVMVF9DQVRFR09SWV9RVUVV",
|
||||
"RV9PVkVSRkxPVxAKEioKJldPUktFUl9GQVVMVF9DQVRFR09SWV9TSFVURE9X",
|
||||
"Tl9USU1FT1VUEAtCJqoCI1pCLk1PTS5XVy5NeEdhdGV3YXkuQ29udHJhY3Rz",
|
||||
"LlByb3RvYgZwcm90bzM="));
|
||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor, },
|
||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerState), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEnvelope), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerEnvelope.Parser, new[]{ "ProtocolVersion", "SessionId", "Sequence", "CorrelationId", "GatewayHello", "WorkerHello", "WorkerReady", "WorkerCommand", "WorkerCommandReply", "WorkerCancel", "WorkerShutdown", "WorkerShutdownAck", "WorkerEvent", "WorkerHeartbeat", "WorkerFault" }, new[]{ "Body" }, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello), global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello.Parser, new[]{ "SupportedProtocolVersion", "Nonce", "GatewayVersion" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello), global::ZB.MOM.WW.MxGateway.Contracts.Proto.GatewayHello.Parser, new[]{ "SupportedProtocolVersion", "Nonce", "GatewayVersion", "MaxFrameBytes" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerHello.Parser, new[]{ "ProtocolVersion", "Nonce", "WorkerProcessId", "WorkerVersion" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerReady.Parser, new[]{ "WorkerProcessId", "MxaccessProgid", "MxaccessClsid", "ReadyTimestamp" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerCommand.Parser, new[]{ "Command", "EnqueueTimestamp" }, null, null, null, null),
|
||||
@@ -1108,6 +1109,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
supportedProtocolVersion_ = other.supportedProtocolVersion_;
|
||||
nonce_ = other.nonce_;
|
||||
gatewayVersion_ = other.gatewayVersion_;
|
||||
maxFrameBytes_ = other.maxFrameBytes_;
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1153,6 +1155,25 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "max_frame_bytes" field.</summary>
|
||||
public const int MaxFrameBytesFieldNumber = 4;
|
||||
private uint maxFrameBytes_;
|
||||
/// <summary>
|
||||
/// Maximum worker-frame payload size, in bytes, negotiated by the gateway from its
|
||||
/// configured pipe limit. The worker adopts this as its frame-protocol MaxMessageBytes
|
||||
/// instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
/// "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
/// envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public uint MaxFrameBytes {
|
||||
get { return maxFrameBytes_; }
|
||||
set {
|
||||
maxFrameBytes_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -1171,6 +1192,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
if (SupportedProtocolVersion != other.SupportedProtocolVersion) return false;
|
||||
if (Nonce != other.Nonce) return false;
|
||||
if (GatewayVersion != other.GatewayVersion) return false;
|
||||
if (MaxFrameBytes != other.MaxFrameBytes) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1181,6 +1203,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
if (SupportedProtocolVersion != 0) hash ^= SupportedProtocolVersion.GetHashCode();
|
||||
if (Nonce.Length != 0) hash ^= Nonce.GetHashCode();
|
||||
if (GatewayVersion.Length != 0) hash ^= GatewayVersion.GetHashCode();
|
||||
if (MaxFrameBytes != 0) hash ^= MaxFrameBytes.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -1211,6 +1234,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
output.WriteRawTag(26);
|
||||
output.WriteString(GatewayVersion);
|
||||
}
|
||||
if (MaxFrameBytes != 0) {
|
||||
output.WriteRawTag(32);
|
||||
output.WriteUInt32(MaxFrameBytes);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -1233,6 +1260,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
output.WriteRawTag(26);
|
||||
output.WriteString(GatewayVersion);
|
||||
}
|
||||
if (MaxFrameBytes != 0) {
|
||||
output.WriteRawTag(32);
|
||||
output.WriteUInt32(MaxFrameBytes);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -1252,6 +1283,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
if (GatewayVersion.Length != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(GatewayVersion);
|
||||
}
|
||||
if (MaxFrameBytes != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeUInt32Size(MaxFrameBytes);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -1273,6 +1307,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
if (other.GatewayVersion.Length != 0) {
|
||||
GatewayVersion = other.GatewayVersion;
|
||||
}
|
||||
if (other.MaxFrameBytes != 0) {
|
||||
MaxFrameBytes = other.MaxFrameBytes;
|
||||
}
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1304,6 +1341,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
GatewayVersion = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 32: {
|
||||
MaxFrameBytes = input.ReadUInt32();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1335,6 +1376,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
GatewayVersion = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 32: {
|
||||
MaxFrameBytes = input.ReadUInt32();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ message GatewayHello {
|
||||
uint32 supported_protocol_version = 1;
|
||||
string nonce = 2;
|
||||
string gateway_version = 3;
|
||||
// Maximum worker-frame payload size, in bytes, negotiated by the gateway from its
|
||||
// configured pipe limit. The worker adopts this as its frame-protocol MaxMessageBytes
|
||||
// instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
// "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
// envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
uint32 max_frame_bytes = 4;
|
||||
}
|
||||
|
||||
message WorkerHello {
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Generated\**\*.cs is Compile-Removed and regenerated by Grpc.Tools into the TRACKED Generated
|
||||
folder. It MUST be committed after any .proto change: Grpc.Tools skips regeneration when the
|
||||
committed output looks up to date, so on net10 the freshly regenerated code compiles (drift is
|
||||
invisible) while the net48 worker, which consumes the COMMITTED Generated\*.cs, fails with
|
||||
CS0246 on new types. Regenerate + commit after editing a .proto; if a build does not pick up
|
||||
the change, delete Generated\*.cs to force a full regen. scripts/check-codegen.ps1 enforces
|
||||
this in CI. See docs/Contracts.md. -->
|
||||
<Compile Remove="Generated\**\*.cs" />
|
||||
<Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
|
||||
<Protobuf Include="Protos\mxaccess_worker.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcServices="None" />
|
||||
|
||||
+2
-2
@@ -22,8 +22,8 @@
|
||||
(IntegrationTests-028).
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.2" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.2" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.4" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -5,8 +5,18 @@ public sealed class AuthenticationOptions
|
||||
/// <summary>Gets the authentication mode.</summary>
|
||||
public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey;
|
||||
|
||||
/// <summary>Gets the SQLite database path for authentication credentials.</summary>
|
||||
public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db";
|
||||
/// <summary>
|
||||
/// Gets the SQLite database path for authentication credentials. The default is
|
||||
/// derived from <see cref="Environment.SpecialFolder.CommonApplicationData"/>
|
||||
/// (<c>C:\ProgramData</c> on Windows, <c>/usr/share</c> or the container equivalent
|
||||
/// elsewhere) so the credential store never lands in the launch working directory on a
|
||||
/// non-Windows host. The production hosts override this with an explicit path in
|
||||
/// <c>appsettings.json</c>; the validator rejects a non-rooted override.
|
||||
/// </summary>
|
||||
public string SqlitePath { get; init; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"MxGateway",
|
||||
"gateway-auth.db");
|
||||
|
||||
/// <summary>Gets the secret manager name for API key pepper.</summary>
|
||||
public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper";
|
||||
|
||||
+26
@@ -1,4 +1,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
@@ -12,6 +15,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
|
||||
public static IServiceCollection AddGatewayConfiguration(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// GatewayOptionsValidator depends on IHostEnvironment to gate the production-only rules
|
||||
// (SEC-04/06). The real host and the apikey CLI both build through WebApplicationBuilder,
|
||||
// which registers IHostEnvironment before this call — so TryAdd is a no-op there. Minimal
|
||||
// containers (unit tests, tooling) have none; fall back to a non-production environment so
|
||||
// the validator still activates and the production guards stay off outside a real
|
||||
// deployment (the fail-fast guards only make sense against a real host environment).
|
||||
services.TryAddSingleton<IHostEnvironment>(new FallbackHostEnvironment());
|
||||
|
||||
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
|
||||
configuration, GatewayOptions.SectionName);
|
||||
|
||||
@@ -19,4 +30,19 @@ public static class GatewayConfigurationServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-production <see cref="IHostEnvironment"/> used only when no real host registered one
|
||||
/// (minimal test/tooling containers). The real host's registration always wins via TryAdd.
|
||||
/// </summary>
|
||||
private sealed class FallbackHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = Environments.Development;
|
||||
|
||||
public string ApplicationName { get; set; } = typeof(FallbackHostEnvironment).Assembly.GetName().Name ?? "MxGateway";
|
||||
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,10 @@ public sealed class GatewayOptions
|
||||
|
||||
/// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
|
||||
public TlsOptions Tls { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets security hot-path options (API-key verification cache / last-used coalescing and
|
||||
/// login / API-key rate limiting).
|
||||
/// </summary>
|
||||
public SecurityOptions Security { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
@@ -9,18 +10,83 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
private const int MinimumMaxMessageBytes = 1024;
|
||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||
|
||||
// Whether the host is running in the Production environment. Drives the production-only
|
||||
// hard-stops (dashboard login disabled, plaintext LDAP transport) that must abort startup
|
||||
// rather than merely warn. Non-production hosts keep the permissive dev posture.
|
||||
private readonly bool _isProduction;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GatewayOptionsValidator"/> class for the
|
||||
/// dependency-injection path, deriving the production posture from the host environment.
|
||||
/// </summary>
|
||||
/// <param name="environment">The host environment.</param>
|
||||
public GatewayOptionsValidator(IHostEnvironment environment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
_isProduction = environment.IsProduction();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GatewayOptionsValidator"/> class for unit
|
||||
/// tests and non-DI callers. Defaults to a non-production posture so the production-only
|
||||
/// hard-stops do not fire; pass <see langword="true"/> to exercise them.
|
||||
/// </summary>
|
||||
/// <param name="isProduction">Whether to treat the host as running in Production.</param>
|
||||
internal GatewayOptionsValidator(bool isProduction = false)
|
||||
{
|
||||
_isProduction = isProduction;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
|
||||
{
|
||||
ValidateAuthentication(options.Authentication, builder);
|
||||
ValidateLdap(options.Ldap, builder);
|
||||
ValidateLdap(options.Ldap, builder, _isProduction);
|
||||
ValidateWorker(options.Worker, builder);
|
||||
ValidateSessions(options.Sessions, builder);
|
||||
ValidateEvents(options.Events, builder);
|
||||
ValidateDashboard(options.Dashboard, builder);
|
||||
ValidateDashboard(options.Dashboard, builder, _isProduction);
|
||||
ValidateProtocol(options.Protocol, builder);
|
||||
ValidateFrameSizeHeadroom(options.Worker, options.Protocol, builder);
|
||||
ValidateAlarms(options.Alarms, builder);
|
||||
ValidateTls(options.Tls, builder);
|
||||
ValidateSecurity(options.Security, builder);
|
||||
}
|
||||
|
||||
private static void ValidateSecurity(SecurityOptions options, ValidationBuilder builder)
|
||||
{
|
||||
// Cache/coalesce windows may be 0 (disables that mechanism); negatives are invalid.
|
||||
AddIfNegative(
|
||||
options.ApiKeyVerificationCacheSeconds,
|
||||
"MxGateway:Security:ApiKeyVerificationCacheSeconds must be greater than or equal to zero (0 disables the verification cache).",
|
||||
builder);
|
||||
AddIfNegative(
|
||||
options.ApiKeyLastUsedCoalesceSeconds,
|
||||
"MxGateway:Security:ApiKeyLastUsedCoalesceSeconds must be greater than or equal to zero (0 forwards every last_used write).",
|
||||
builder);
|
||||
|
||||
// Rate-limit knobs must be positive: a zero permit/window/limit is a misconfiguration that
|
||||
// would either reject every request or divide by zero rather than express an intent.
|
||||
AddIfNotPositive(
|
||||
options.LoginRateLimitPermitLimit,
|
||||
"MxGateway:Security:LoginRateLimitPermitLimit must be greater than zero.",
|
||||
builder);
|
||||
AddIfNotPositive(
|
||||
options.LoginRateLimitWindowSeconds,
|
||||
"MxGateway:Security:LoginRateLimitWindowSeconds must be greater than zero.",
|
||||
builder);
|
||||
AddIfNotPositive(
|
||||
options.ApiKeyFailureLimit,
|
||||
"MxGateway:Security:ApiKeyFailureLimit must be greater than zero.",
|
||||
builder);
|
||||
AddIfNotPositive(
|
||||
options.ApiKeyFailureWindowSeconds,
|
||||
"MxGateway:Security:ApiKeyFailureWindowSeconds must be greater than zero.",
|
||||
builder);
|
||||
AddIfNotPositive(
|
||||
options.ApiKeyFailureTrackedPeers,
|
||||
"MxGateway:Security:ApiKeyFailureTrackedPeers must be greater than zero.",
|
||||
builder);
|
||||
}
|
||||
|
||||
private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
|
||||
@@ -41,6 +107,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
||||
builder);
|
||||
AddIfNotRooted(
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath must be an absolute (rooted) path so the credential store never lands in the launch working directory.",
|
||||
builder);
|
||||
AddIfBlank(
|
||||
options.PepperSecretName,
|
||||
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
||||
@@ -48,7 +118,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLdap(LdapOptions options, ValidationBuilder builder)
|
||||
private static void ValidateLdap(LdapOptions options, ValidationBuilder builder, bool isProduction)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
@@ -83,6 +153,16 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
{
|
||||
builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
|
||||
}
|
||||
|
||||
// Production hard-stop: plaintext LDAP binds send the operator's password in the clear.
|
||||
// The permissive dev default (Transport=None against the shared GLAuth instance) is
|
||||
// acceptable off-production but must never ship to a Production host. Deployed hosts must
|
||||
// set Transport=Ldaps/StartTls; see docs/GatewayConfiguration.md and glauth.md.
|
||||
if (isProduction && options.Transport == LdapTransport.None)
|
||||
{
|
||||
builder.Add(
|
||||
"MxGateway:Ldap:Transport must not be None (plaintext) in the Production environment; use Ldaps or StartTls.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder)
|
||||
@@ -224,8 +304,19 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
$"MxGateway:Events:MaxSparseArrayLength must be between 1 and {Array.MaxLength}.");
|
||||
}
|
||||
|
||||
private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder)
|
||||
private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder, bool isProduction)
|
||||
{
|
||||
// Production hard-stop: DisableLogin swaps in an auto-login handler that authenticates
|
||||
// EVERY request (remote included) as AutoLoginUser holding both roles, turning the whole
|
||||
// dashboard — API-key CRUD and worker Kill included — into an unauthenticated admin
|
||||
// surface on a 0.0.0.0-bound port. It is a dev/test-only convenience; abort startup if it
|
||||
// is set in Production. Non-production hosts keep the existing runtime warning.
|
||||
if (isProduction && options.DisableLogin)
|
||||
{
|
||||
builder.Add(
|
||||
"MxGateway:Dashboard:DisableLogin must not be true in the Production environment; it disables all dashboard authentication.");
|
||||
}
|
||||
|
||||
// GroupToRole shape is validated even when the dashboard is disabled so
|
||||
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
||||
// consequence that no LDAP user can sign in (login returns "no roles
|
||||
@@ -348,6 +439,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
options.SelfSignedCertPath,
|
||||
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
||||
builder);
|
||||
AddIfNotRooted(
|
||||
options.SelfSignedCertPath,
|
||||
"MxGateway:Tls:SelfSignedCertPath must be an absolute (rooted) path so the generated private key never lands in the launch working directory.",
|
||||
builder);
|
||||
|
||||
foreach (string dns in options.AdditionalDnsNames)
|
||||
{
|
||||
@@ -373,6 +468,35 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
}
|
||||
}
|
||||
|
||||
// The worker-frame (pipe) maximum must stay above the public gRPC cap by the envelope-overhead
|
||||
// reserve, otherwise a maximally-sized accepted gRPC payload does not fit one worker frame once
|
||||
// wrapped in a WorkerEnvelope and the outbound write faults the whole session (IPC-03). Fail fast
|
||||
// at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the
|
||||
// message is not doubled up with the individual range errors.
|
||||
private static void ValidateFrameSizeHeadroom(
|
||||
WorkerOptions worker,
|
||||
ProtocolOptions protocol,
|
||||
ValidationBuilder builder)
|
||||
{
|
||||
bool workerInRange = worker.MaxMessageBytes is >= MinimumMaxMessageBytes and <= MaximumMaxMessageBytes;
|
||||
bool grpcInRange = protocol.MaxGrpcMessageBytes is >= MinimumMaxMessageBytes and <= MaximumMaxMessageBytes;
|
||||
if (!workerInRange || !grpcInRange)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long requiredWorkerMax =
|
||||
(long)protocol.MaxGrpcMessageBytes + WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes;
|
||||
if (worker.MaxMessageBytes < requiredWorkerMax)
|
||||
{
|
||||
builder.Add(
|
||||
$"MxGateway:Worker:MaxMessageBytes ({worker.MaxMessageBytes}) must be at least "
|
||||
+ $"MxGateway:Protocol:MaxGrpcMessageBytes ({protocol.MaxGrpcMessageBytes}) plus the "
|
||||
+ $"{WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes}-byte worker-frame envelope reserve "
|
||||
+ $"(>= {requiredWorkerMax}).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfBlank(string? value, string message, ValidationBuilder builder)
|
||||
{
|
||||
builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
|
||||
@@ -388,6 +512,53 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
builder.RequireThat(value >= 0, message);
|
||||
}
|
||||
|
||||
private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
|
||||
{
|
||||
// Security-sensitive paths (the auth DB, the self-signed private key) must be absolute:
|
||||
// a non-rooted value silently resolves against the launch working directory, so the store
|
||||
// moves with the CWD and can leak into the source tree. Reject rather than auto-root —
|
||||
// silent relocation of a credential store is worse than a boot error. Blank is handled by
|
||||
// AddIfBlank; an empty value is not treated as non-rooted here.
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsRootedForAnyPlatform(value))
|
||||
{
|
||||
builder.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether <paramref name="value"/> is an absolute path for <em>any</em> platform,
|
||||
/// not just the host running the validator. This matters on the macOS dev box, where the
|
||||
/// production <c>appsettings.json</c> ships Windows-absolute paths (<c>C:\ProgramData\...</c>)
|
||||
/// that <see cref="Path.IsPathRooted(string)"/> reports as non-rooted on Unix. The intent of the
|
||||
/// rooting check is to reject bare filenames that resolve against the launch working directory,
|
||||
/// so a valid Windows drive-qualified or UNC path must pass regardless of the current OS.
|
||||
/// </summary>
|
||||
private static bool IsRootedForAnyPlatform(string value)
|
||||
{
|
||||
// Rooted on the current OS (Unix "/...", or a Windows drive/UNC path when on Windows).
|
||||
if (Path.IsPathRooted(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Windows drive-qualified path ("C:\..." or "C:/...") checked on a non-Windows host.
|
||||
if (value.Length >= 3
|
||||
&& char.IsLetter(value[0])
|
||||
&& value[1] == ':'
|
||||
&& (value[2] == '\\' || value[2] == '/'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Windows UNC path ("\\server\share") checked on a non-Windows host.
|
||||
return value.StartsWith(@"\\", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Security hot-path options bound from <c>MxGateway:Security</c>. Groups the API-key verification
|
||||
/// cache and <c>last_used</c> write-coalescing knobs (SEC-08) with the login and API-key
|
||||
/// rate-limiting knobs (SEC-11). Defaults preserve safe, low-overhead behaviour without requiring
|
||||
/// operators to configure anything.
|
||||
/// </summary>
|
||||
public sealed class SecurityOptions
|
||||
{
|
||||
/// <summary>The configuration sub-section this binds from.</summary>
|
||||
public const string SectionName = "MxGateway:Security";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time-to-live, in seconds, of a cached successful API-key verification. A cache hit
|
||||
/// within this window skips the per-call SQLite read (and the library verifier's coupled
|
||||
/// <c>last_used</c> write). Explicit invalidation on gateway-initiated revoke/rotate is the
|
||||
/// primary correctness mechanism; this short TTL is the backstop for out-of-band mutations.
|
||||
/// Set to <c>0</c> to disable caching. Default is 15 seconds.
|
||||
/// </summary>
|
||||
public int ApiKeyVerificationCacheSeconds { get; init; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the coalescing window, in seconds, for the <c>last_used_utc</c> write. The library
|
||||
/// verifier couples the write into every verification; this decorator forwards at most one
|
||||
/// <c>MarkUsed</c> per key per window, so a hammered key produces at most one write per window
|
||||
/// rather than one per authenticated RPC. Set to <c>0</c> to forward every write. Default is
|
||||
/// 60 seconds (at most one write per key per minute).
|
||||
/// </summary>
|
||||
public int ApiKeyLastUsedCoalesceSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of <c>POST /auth/login</c> attempts permitted per remote IP within
|
||||
/// <see cref="LoginRateLimitWindowSeconds"/> before requests are rejected with HTTP 429. Default
|
||||
/// is 10.
|
||||
/// </summary>
|
||||
public int LoginRateLimitPermitLimit { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fixed-window length, in seconds, for the <c>POST /auth/login</c> per-IP rate limit.
|
||||
/// Default is 60 seconds.
|
||||
/// </summary>
|
||||
public int LoginRateLimitWindowSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of consecutive failed API-key verifications, per peer, within
|
||||
/// <see cref="ApiKeyFailureWindowSeconds"/> that trips the in-process short-circuit. Once tripped,
|
||||
/// the gRPC auth path rejects further attempts before the store read; a successful verification
|
||||
/// resets the peer's counter. Default is 10.
|
||||
/// </summary>
|
||||
public int ApiKeyFailureLimit { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sliding-window length, in seconds, over which API-key verification failures are
|
||||
/// counted per peer. Default is 60 seconds.
|
||||
/// </summary>
|
||||
public int ApiKeyFailureWindowSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of distinct peers tracked by the API-key failure counter. The counter
|
||||
/// is a bounded LRU so a spray of unique peer keys cannot grow memory without limit. Default is
|
||||
/// 4096.
|
||||
/// </summary>
|
||||
public int ApiKeyFailureTrackedPeers { get; init; } = 4096;
|
||||
}
|
||||
@@ -7,9 +7,19 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
/// </summary>
|
||||
public sealed class TlsOptions
|
||||
{
|
||||
/// <summary>Path to the persisted self-signed PFX. Reused across restarts.</summary>
|
||||
public string SelfSignedCertPath { get; init; } =
|
||||
@"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx";
|
||||
/// <summary>
|
||||
/// Path to the persisted self-signed PFX. Reused across restarts. The default is derived
|
||||
/// from <see cref="Environment.SpecialFolder.CommonApplicationData"/> (<c>C:\ProgramData</c>
|
||||
/// on Windows, <c>/usr/share</c> or the container equivalent elsewhere) so a generated
|
||||
/// private key never lands in the launch working directory on a non-Windows host. The
|
||||
/// production hosts override this with an explicit path; the validator rejects a non-rooted
|
||||
/// override.
|
||||
/// </summary>
|
||||
public string SelfSignedCertPath { get; init; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"MxGateway",
|
||||
"certs",
|
||||
"gateway-selfsigned.pfx");
|
||||
|
||||
/// <summary>Lifetime in years of a freshly generated certificate.</summary>
|
||||
public int ValidityYears { get; init; } = 10;
|
||||
|
||||
@@ -33,6 +33,14 @@ public sealed class WorkerOptions
|
||||
/// <summary>The grace period in seconds after a heartbeat before considering the worker unresponsive.</summary>
|
||||
public int HeartbeatGraceSeconds { get; init; } = 15;
|
||||
|
||||
/// <summary>The maximum message size in bytes for IPC communication.</summary>
|
||||
public int MaxMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
/// <summary>
|
||||
/// The maximum worker-frame (pipe) message size in bytes. Must stay at least
|
||||
/// <see cref="Workers.WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes"/> above
|
||||
/// <see cref="ProtocolOptions.MaxGrpcMessageBytes"/> so a maximally-sized accepted gRPC payload
|
||||
/// still fits one worker frame (IPC-03); the gateway conveys this value to the worker in the
|
||||
/// handshake (<c>GatewayHello.max_frame_bytes</c>, IPC-02). Default is the 16 MB public gRPC cap
|
||||
/// plus that reserve.
|
||||
/// </summary>
|
||||
public int MaxMessageBytes { get; init; } =
|
||||
(16 * 1024 * 1024) + Workers.WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ else
|
||||
<th scope="col">Constraints</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Last Used</th>
|
||||
<th scope="col">Expires</th>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<th scope="col">Actions</th>
|
||||
@@ -175,12 +176,13 @@ else
|
||||
{
|
||||
<tr>
|
||||
<td><code>@key.KeyId</code></td>
|
||||
<td><StatusBadge Text="@(key.RevokedUtc is null ? "Active" : "Revoked")" /></td>
|
||||
<td><StatusBadge Text="@KeyStatus(key)" /></td>
|
||||
<td>@DashboardDisplay.Text(key.DisplayName)</td>
|
||||
<td>@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))</td>
|
||||
<td>@DashboardDisplay.Text(ConstraintText(key.Constraints))</td>
|
||||
<td>@DashboardDisplay.DateTime(key.CreatedUtc)</td>
|
||||
<td>@DashboardDisplay.DateTime(key.LastUsedUtc)</td>
|
||||
<td>@(key.ExpiresUtc is null ? "Never" : DashboardDisplay.DateTime(key.ExpiresUtc))</td>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<td>
|
||||
@@ -468,6 +470,35 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
// Window before an expiry within which a key is flagged as "Expiring" (warn) rather than "Active".
|
||||
private static readonly TimeSpan ExpiringSoonWindow = TimeSpan.FromDays(7);
|
||||
|
||||
// Status vocabulary understood by StatusBadge: Revoked wins over expiry; a past expiry is Expired
|
||||
// (bad), an expiry inside ExpiringSoonWindow is Expiring (warn), otherwise Active (SEC-10).
|
||||
private static string KeyStatus(DashboardApiKeySummary key)
|
||||
{
|
||||
if (key.RevokedUtc is not null)
|
||||
{
|
||||
return "Revoked";
|
||||
}
|
||||
|
||||
if (key.ExpiresUtc is { } expiresAt)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
if (expiresAt <= now)
|
||||
{
|
||||
return "Expired";
|
||||
}
|
||||
|
||||
if (expiresAt - now <= ExpiringSoonWindow)
|
||||
{
|
||||
return "Expiring";
|
||||
}
|
||||
}
|
||||
|
||||
return "Active";
|
||||
}
|
||||
|
||||
private static string ConstraintText(ApiKeyConstraints constraints)
|
||||
{
|
||||
if (constraints.IsEmpty)
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
{
|
||||
"Ready" or "Healthy" or "Active" => StatusState.Ok,
|
||||
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing"
|
||||
or "Stale" or "Degraded" => StatusState.Warn,
|
||||
"Faulted" or "Unavailable" => StatusState.Bad,
|
||||
or "Stale" or "Degraded" or "Expiring" => StatusState.Warn,
|
||||
"Faulted" or "Unavailable" or "Expired" => StatusState.Bad,
|
||||
_ => StatusState.Idle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ public sealed class DashboardApiKeyManagementService(
|
||||
ApiKeyAdminCommands adminCommands,
|
||||
IApiKeyAdminStore adminStore,
|
||||
IAuditWriter auditWriter,
|
||||
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IApiKeyCacheInvalidator? cacheInvalidator = null) : IDashboardApiKeyManagementService
|
||||
{
|
||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||
private const string PepperUnavailableMarker = "pepper unavailable";
|
||||
@@ -100,6 +101,10 @@ public sealed class DashboardApiKeyManagementService(
|
||||
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// SEC-08: drop any cached verification for this key in THIS gateway process so the revoke
|
||||
// takes effect immediately rather than after the verification-cache TTL elapses.
|
||||
cacheInvalidator?.Invalidate(normalizedKeyId);
|
||||
|
||||
await WriteDashboardAuditAsync(
|
||||
user,
|
||||
normalizedKeyId,
|
||||
@@ -138,6 +143,10 @@ public sealed class DashboardApiKeyManagementService(
|
||||
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// SEC-08: the old secret's cached verification must not outlive the rotate — evict it so
|
||||
// the superseded secret stops authenticating within this process immediately.
|
||||
cacheInvalidator?.Invalidate(normalizedKeyId);
|
||||
|
||||
bool succeeded = rotated.Token is not null;
|
||||
|
||||
await WriteDashboardAuditAsync(
|
||||
@@ -182,6 +191,10 @@ public sealed class DashboardApiKeyManagementService(
|
||||
.DeleteAsync(normalizedKeyId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// SEC-08: a deleted key is only reachable after a prior revoke, but evict defensively so no
|
||||
// cached verification survives the delete.
|
||||
cacheInvalidator?.Invalidate(normalizedKeyId);
|
||||
|
||||
await WriteDashboardAuditAsync(
|
||||
user,
|
||||
normalizedKeyId,
|
||||
|
||||
@@ -9,4 +9,5 @@ public sealed record DashboardApiKeySummary(
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
DateTimeOffset? RevokedUtc,
|
||||
DateTimeOffset? ExpiresUtc = null);
|
||||
|
||||
@@ -35,5 +35,23 @@ public static class DashboardAuthenticationDefaults
|
||||
|
||||
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure
|
||||
/// (<c>RequireHttpsCookie=false</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.SameAsRequest"/>)
|
||||
/// or when an explicit <c>MxGateway:Dashboard:CookieName</c> override is absent but the
|
||||
/// secure default cannot be applied. This plain name carries no browser-enforced guarantees.
|
||||
/// </summary>
|
||||
public const string CookieName = "MxGatewayDashboard";
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard auth cookie name applied when the cookie is guaranteed Secure
|
||||
/// (<c>RequireHttpsCookie=true</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.Always"/>)
|
||||
/// and no explicit <c>MxGateway:Dashboard:CookieName</c> override is set. The <c>__Host-</c>
|
||||
/// prefix instructs browsers to enforce Secure, no <c>Domain</c>, and <c>Path=/</c>; those
|
||||
/// guarantees only hold for a Secure cookie, so this name must never be applied unless
|
||||
/// <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.Always"/> is in
|
||||
/// effect — a <c>__Host-</c> cookie without Secure is silently dropped by browsers.
|
||||
/// </summary>
|
||||
public const string SecureCookieName = "__Host-MxGatewayDashboard";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,18 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// (b) authentication is fully disabled, or (c) the request is from loopback
|
||||
/// and <c>MxGateway:Dashboard:AllowAnonymousLocalhost</c> is on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The environment bypasses in (b) and (c) grant <em>read-only</em> access only: they
|
||||
/// satisfy a requirement that includes <see cref="DashboardRoles.Viewer"/> (i.e.
|
||||
/// <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>) but never the
|
||||
/// <see cref="DashboardAuthorizationRequirement.AdminOnly"/> requirement. Destructive/admin
|
||||
/// surfaces still require a real Admin role claim, preserving the documented
|
||||
/// "anonymous localhost is read-only" contract at the policy layer rather than by accident
|
||||
/// in downstream service re-checks. The loopback test trusts
|
||||
/// <c>Connection.RemoteIpAddress</c>; if forwarded-headers middleware is ever added upstream,
|
||||
/// <see cref="IsLoopbackRequest"/> must be revisited so a spoofed <c>X-Forwarded-For</c>
|
||||
/// cannot masquerade as loopback.
|
||||
/// </remarks>
|
||||
public sealed class DashboardAuthorizationHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
||||
@@ -22,14 +34,18 @@ public sealed class DashboardAuthorizationHandler(
|
||||
{
|
||||
GatewayOptions gatewayOptions = options.Value;
|
||||
|
||||
if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
|
||||
// Environment bypasses are read-only: they satisfy the Viewer requirement
|
||||
// (AnyDashboardRole) but never AdminOnly, which lacks Viewer in RequiredRoles.
|
||||
bool grantsReadOnly = requirement.RequiredRoles.Contains(DashboardRoles.Viewer);
|
||||
|
||||
if (grantsReadOnly && gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
|
||||
if (grantsReadOnly && gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
@@ -10,6 +11,41 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
|
||||
public static class DashboardEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The named rate-limiter policy applied to the <c>POST /auth/login</c> route (SEC-11). Registered
|
||||
/// in <c>GatewayApplication</c> via <see cref="GetLoginRateLimitPartition"/>.
|
||||
/// </summary>
|
||||
internal const string LoginRateLimiterPolicy = "dashboard-login";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the fixed-window rate-limit partition for a login request, keyed on the remote IP.
|
||||
/// Shared by the production policy registration and the tests so both exercise identical limits.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The inbound request.</param>
|
||||
/// <param name="security">The bound security options carrying the login-limit knobs.</param>
|
||||
/// <returns>The per-IP fixed-window partition.</returns>
|
||||
internal static RateLimitPartition<string> GetLoginRateLimitPartition(
|
||||
HttpContext httpContext,
|
||||
SecurityOptions security)
|
||||
{
|
||||
string partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey,
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = security.LoginRateLimitPermitLimit,
|
||||
Window = TimeSpan.FromSeconds(security.LoginRateLimitWindowSeconds),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true,
|
||||
});
|
||||
}
|
||||
|
||||
// Test seam: a standalone partitioned limiter over the production partition logic, so a test can
|
||||
// acquire leases and assert the (permit+1)th is rejected without an HTTP server.
|
||||
internal static PartitionedRateLimiter<HttpContext> CreateLoginRateLimiter(SecurityOptions security)
|
||||
=> PartitionedRateLimiter.Create<HttpContext, string>(
|
||||
ctx => GetLoginRateLimitPartition(ctx, security));
|
||||
|
||||
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <returns>The route builder for chaining.</returns>
|
||||
@@ -40,6 +76,8 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||
PostLoginAsync(httpContext, antiforgery, authenticator))
|
||||
.AllowAnonymous()
|
||||
// SEC-11: throttle credential stuffing before the LDAP bind is relayed to the directory.
|
||||
.RequireRateLimiting(LoginRateLimiterPolicy)
|
||||
.WithName("DashboardLoginPost");
|
||||
|
||||
endpoints.MapPost(
|
||||
|
||||
@@ -123,13 +123,22 @@ public static class DashboardServiceCollectionExtensions
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
// Config-driven cookie name (MxGateway:Dashboard:CookieName). Null/blank keeps
|
||||
// the canonical default set above, so a misconfiguration cannot unname the cookie.
|
||||
// Config-driven cookie name (MxGateway:Dashboard:CookieName). An explicit override
|
||||
// always wins. With no override, restore the __Host- prefix when the cookie is
|
||||
// guaranteed Secure (RequireHttpsCookie true → SecurePolicy Always): the __Host-
|
||||
// browser guarantees (Secure required, no Domain, Path=/) hold only for a Secure
|
||||
// cookie, and a __Host- cookie without Secure is silently dropped — so the prefix
|
||||
// is never applied unless SecurePolicy is Always. Otherwise keep the plain
|
||||
// canonical default set by AddCookie above, so a misconfiguration cannot unname it.
|
||||
var cookieName = gatewayOptions.Value.Dashboard.CookieName;
|
||||
if (!string.IsNullOrWhiteSpace(cookieName))
|
||||
{
|
||||
cookieOptions.Cookie.Name = cookieName;
|
||||
}
|
||||
else if (cookieOptions.Cookie.SecurePolicy == CookieSecurePolicy.Always)
|
||||
{
|
||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.SecureCookieName;
|
||||
}
|
||||
});
|
||||
|
||||
services.AddAuthorization(authorization =>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
@@ -8,21 +10,35 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDashboardSessionAdminService"/>: gates
|
||||
/// destructive session actions on the <see cref="DashboardRoles.Admin"/> role,
|
||||
/// audit-logs successful operations, and converts <see cref="SessionManagerException"/>
|
||||
/// (and any other unexpected exceptions) into <see cref="DashboardSessionAdminResult.Fail(string)"/>
|
||||
/// so the Blazor pages never see a raw exception.
|
||||
/// records each attempt as a canonical <see cref="AuditEvent"/> through <see cref="IAuditWriter"/>
|
||||
/// (in addition to the operational <see cref="ILogger"/> line), and converts
|
||||
/// <see cref="SessionManagerException"/> (and any other unexpected exceptions) into
|
||||
/// <see cref="DashboardSessionAdminResult.Fail(string)"/> so the Blazor pages never see a raw exception.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The constant <c>dashboard-admin-kill</c> is the reason passed to
|
||||
/// <see cref="ISessionManager.KillWorkerAsync"/> and forwarded as the
|
||||
/// <c>reason</c> tag on the <c>mxgateway.workers.killed</c> counter and in
|
||||
/// the worker-kill audit log entries.
|
||||
/// the worker-kill audit log entries. Destructive dashboard actions write durable,
|
||||
/// queryable audit rows (actions <c>dashboard-close-session</c> / <c>dashboard-kill-worker</c>)
|
||||
/// to the canonical <c>audit_event</c> store, mirroring the API-key management path so a
|
||||
/// worker killed mid-production is not visible only in a rotatable <see cref="ILogger"/> line.
|
||||
/// </remarks>
|
||||
public sealed class DashboardSessionAdminService(
|
||||
ISessionManager sessionManager,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IAuditWriter auditWriter,
|
||||
ILogger<DashboardSessionAdminService>? logger = null) : IDashboardSessionAdminService
|
||||
{
|
||||
/// <summary>Canonical <see cref="AuditEvent.Category"/> for dashboard session-admin actions.</summary>
|
||||
internal const string SessionAdminCategory = "SessionAdmin";
|
||||
|
||||
/// <summary>Canonical <see cref="AuditEvent.Action"/> for a dashboard session close.</summary>
|
||||
internal const string CloseSessionAction = "dashboard-close-session";
|
||||
|
||||
/// <summary>Canonical <see cref="AuditEvent.Action"/> for a dashboard worker kill.</summary>
|
||||
internal const string KillWorkerAction = "dashboard-kill-worker";
|
||||
|
||||
private const string UnauthorizedMessage = "Sign in as an Admin to close sessions or kill workers.";
|
||||
private const string KillReason = "dashboard-admin-kill";
|
||||
|
||||
@@ -46,6 +62,8 @@ public sealed class DashboardSessionAdminService(
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Denied, "unauthorized", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
@@ -68,6 +86,15 @@ public sealed class DashboardSessionAdminService(
|
||||
ResolveRemoteAddress(),
|
||||
result.AlreadyClosed);
|
||||
|
||||
await WriteAuditAsync(
|
||||
user,
|
||||
CloseSessionAction,
|
||||
sessionId,
|
||||
AuditOutcome.Success,
|
||||
result.AlreadyClosed ? "alreadyClosed" : "closed",
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return DashboardSessionAdminResult.Success(
|
||||
result.AlreadyClosed
|
||||
? $"Session {sessionId} was already closed."
|
||||
@@ -75,6 +102,8 @@ public sealed class DashboardSessionAdminService(
|
||||
}
|
||||
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
|
||||
{
|
||||
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Failure, "not-found", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
|
||||
}
|
||||
catch (SessionManagerException exception)
|
||||
@@ -84,6 +113,8 @@ public sealed class DashboardSessionAdminService(
|
||||
"Dashboard admin {Actor} close failed for session {SessionId}.",
|
||||
actor,
|
||||
sessionId);
|
||||
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Failure, exception.Message, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail(
|
||||
$"Close failed: {exception.Message}");
|
||||
}
|
||||
@@ -98,6 +129,8 @@ public sealed class DashboardSessionAdminService(
|
||||
"Dashboard admin {Actor} close failed unexpectedly for session {SessionId}.",
|
||||
actor,
|
||||
sessionId);
|
||||
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Failure, "unexpected", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail(
|
||||
$"Close failed unexpectedly for session {sessionId}. See the gateway log for details.");
|
||||
}
|
||||
@@ -111,6 +144,8 @@ public sealed class DashboardSessionAdminService(
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Denied, "unauthorized", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
@@ -133,6 +168,15 @@ public sealed class DashboardSessionAdminService(
|
||||
ResolveRemoteAddress(),
|
||||
result.AlreadyClosed);
|
||||
|
||||
await WriteAuditAsync(
|
||||
user,
|
||||
KillWorkerAction,
|
||||
sessionId,
|
||||
AuditOutcome.Success,
|
||||
result.AlreadyClosed ? "alreadyClosed" : "killed",
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return DashboardSessionAdminResult.Success(
|
||||
result.AlreadyClosed
|
||||
? $"Session {sessionId} was already closed."
|
||||
@@ -140,6 +184,8 @@ public sealed class DashboardSessionAdminService(
|
||||
}
|
||||
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
|
||||
{
|
||||
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Failure, "not-found", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
|
||||
}
|
||||
catch (SessionManagerException exception)
|
||||
@@ -149,6 +195,8 @@ public sealed class DashboardSessionAdminService(
|
||||
"Dashboard admin {Actor} kill failed for session {SessionId}.",
|
||||
actor,
|
||||
sessionId);
|
||||
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Failure, exception.Message, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail(
|
||||
$"Kill failed: {exception.Message}");
|
||||
}
|
||||
@@ -163,6 +211,8 @@ public sealed class DashboardSessionAdminService(
|
||||
"Dashboard admin {Actor} kill failed unexpectedly for session {SessionId}.",
|
||||
actor,
|
||||
sessionId);
|
||||
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Failure, "unexpected", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return DashboardSessionAdminResult.Fail(
|
||||
$"Kill failed unexpectedly for session {sessionId}. See the gateway log for details.");
|
||||
}
|
||||
@@ -177,4 +227,51 @@ public sealed class DashboardSessionAdminService(
|
||||
{
|
||||
return httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a canonical <see cref="AuditEvent"/> for a dashboard session-admin action through the
|
||||
/// best-effort <see cref="IAuditWriter"/> (failures are swallowed/logged by the writer, so this
|
||||
/// never throws and never masks the operation result). <see cref="AuditEvent.Actor"/> is the LDAP
|
||||
/// operator, <see cref="AuditEvent.Target"/> the session id, and <paramref name="detail"/> is wrapped
|
||||
/// as the <c>detail</c> field of the JSON extension bag.
|
||||
/// </summary>
|
||||
private async Task WriteAuditAsync(
|
||||
ClaimsPrincipal user,
|
||||
string action,
|
||||
string sessionId,
|
||||
AuditOutcome outcome,
|
||||
string? detail,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AuditEvent auditEvent = new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = ResolveActor(user),
|
||||
Action = action,
|
||||
Outcome = outcome,
|
||||
Category = SessionAdminCategory,
|
||||
Target = sessionId,
|
||||
SourceNode = ResolveRemoteAddress(),
|
||||
CorrelationId = ParseCorrelationId(),
|
||||
DetailsJson = WrapDetail(detail),
|
||||
};
|
||||
|
||||
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives a correlation id from the request trace identifier when it is a well-formed GUID;
|
||||
/// otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the connection:request form,
|
||||
/// not a GUID, so it correlates to null rather than fabricating one).
|
||||
/// </summary>
|
||||
private Guid? ParseCorrelationId() =>
|
||||
Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId)
|
||||
? correlationId
|
||||
: null;
|
||||
|
||||
private static string? WrapDetail(string? detail) =>
|
||||
detail is null
|
||||
? null
|
||||
: JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = detail });
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user