Compare commits

..

60 Commits

Author SHA1 Message Date
Joseph Doherty 647fe9a4b5 Merge remote-tracking branch 'origin/main' into agent-2/issue-29-implement-event-sink-and-event-queue 2026-04-26 19:05:31 -04:00
Joseph Doherty dd455089b4 Implement worker MXAccess event queue 2026-04-26 19:04:56 -04:00
dohertj2 d0bc4e3c01 Merge pull request #82 from agent-1/issue-36-publish-stable-client-proto-generation-inputs
Issue #36: publish stable client proto generation inputs
2026-04-26 18:56:37 -04:00
Joseph Doherty 6a40d26366 Publish stable client proto inputs 2026-04-26 18:52:39 -04:00
dohertj2 366f57198f Merge pull request #81 from agent-2/issue-28-implement-advise-unadvise-advisesupervisory
Issue #28: implement Advise, UnAdvise, AdviseSupervisory
2026-04-26 18:45:59 -04:00
dohertj2 aab41e04ab Merge pull request #80 from agent-1/issue-16-implement-blazor-server-dashboard
Issue #16: implement Blazor Server dashboard
2026-04-26 18:41:26 -04:00
Joseph Doherty 3be92a17bd Merge remote-tracking branch 'origin/main' into agent-2/issue-28-implement-advise-unadvise-advisesupervisory 2026-04-26 18:41:17 -04:00
Joseph Doherty a871f2f2e5 Implement worker advise commands 2026-04-26 18:41:10 -04:00
Joseph Doherty 7b86bab705 Merge remote-tracking branch 'origin/main' into agent-1/issue-16-implement-blazor-server-dashboard 2026-04-26 18:38:19 -04:00
Joseph Doherty 56886c3b4e Implement Blazor Server dashboard 2026-04-26 18:37:16 -04:00
dohertj2 a3ccd5c80b Merge pull request #79 from agent-3/issue-19-gateway-e2e-smoke-with-fake-worker
Issue #19: gateway end-to-end smoke with fake worker
2026-04-26 18:34:54 -04:00
dohertj2 0fd954d94c Merge pull request #78 from agent-2/issue-27-implement-additem-additem2-removeitem
Issue #27: implement AddItem, AddItem2, RemoveItem
2026-04-26 18:32:13 -04:00
Joseph Doherty 91f2d8dc14 Merge remote-tracking branch 'origin/main' into agent-3/issue-19-gateway-e2e-smoke-with-fake-worker 2026-04-26 18:31:49 -04:00
Joseph Doherty fb425da009 Add gateway fake worker end-to-end smoke 2026-04-26 18:30:11 -04:00
Joseph Doherty c7e4c4b614 Merge remote-tracking branch 'origin/main' into agent-2/issue-27-implement-additem-additem2-removeitem 2026-04-26 18:27:00 -04:00
Joseph Doherty 59c710d789 Implement worker AddItem commands 2026-04-26 18:26:44 -04:00
dohertj2 862f119b91 Merge pull request #77 from agent-3/issue-18-build-fake-worker-test-harness
Issue #18: build fake worker test harness
2026-04-26 18:25:00 -04:00
Joseph Doherty 35e4442c7b Build fake worker test harness 2026-04-26 18:20:45 -04:00
dohertj2 ed1018c3bb Merge pull request #76 from agent-1/issue-17-implement-dashboard-authentication
Issue #17: implement dashboard authentication
2026-04-26 18:18:43 -04:00
Joseph Doherty 2e4ba11a9f Merge remote-tracking branch 'origin/main' into agent-1/issue-17-implement-dashboard-authentication 2026-04-26 18:15:38 -04:00
Joseph Doherty ff86b3f0b0 Implement dashboard authentication 2026-04-26 18:15:22 -04:00
dohertj2 653f17c669 Merge pull request #75 from agent-2/issue-26-implement-register-and-unregister
Issue #26: implement Register and Unregister
2026-04-26 18:13:41 -04:00
Joseph Doherty 556c3bfa83 Implement worker register and unregister 2026-04-26 18:08:45 -04:00
dohertj2 9b3637257c Merge pull request #74 from agent-3/issue-14-implement-event-streaming-and-backpressure
Issue #14: implement event streaming and backpressure
2026-04-26 18:03:32 -04:00
Joseph Doherty 77eac95f33 Issue #14: implement event streaming and backpressure 2026-04-26 17:59:37 -04:00
dohertj2 015fa1f50d Merge pull request #73 from agent-1/issue-15-implement-dashboard-snapshot-service
Issue #15: implement dashboard snapshot service
2026-04-26 17:56:01 -04:00
dohertj2 dede407304 Merge pull request #72 from agent-2/issue-25-implement-sta-command-dispatcher
Issue #25: implement STA command dispatcher
2026-04-26 17:53:32 -04:00
Joseph Doherty 0d96963c99 Merge remote-tracking branch 'origin/main' into agent-1/issue-15-implement-dashboard-snapshot-service 2026-04-26 17:52:58 -04:00
Joseph Doherty 3661420f0a Issue #15: implement dashboard snapshot service 2026-04-26 17:49:59 -04:00
Joseph Doherty 14419853c7 Issue #25: implement sta command dispatcher 2026-04-26 17:49:01 -04:00
dohertj2 a20517f5ad Merge pull request #71 from agent-3/issue-13-implement-public-grpc-service
Issue #13: implement public grpc service
2026-04-26 17:48:35 -04:00
dohertj2 626e7762d9 Merge PR #70: Issue #31 implement MXSTATUS_PROXY and HRESULT conversion
Verified after merging current main with dotnet build src\\MxGateway.sln, dotnet test src\\MxGateway.Worker.Tests\\MxGateway.Worker.Tests.csproj -p:Platform=x86, and dotnet test src\\MxGateway.sln --no-build.
2026-04-26 17:43:08 -04:00
Joseph Doherty 8d6d3f6188 Issue #13: implement public grpc service 2026-04-26 17:42:46 -04:00
Joseph Doherty 276288ad87 Merge remote-tracking branch 'origin/main' into agent-2/issue-31-implement-mxstatus-proxy-and-hresult-conversion 2026-04-26 17:39:48 -04:00
dohertj2 76bd3de5a2 Merge PR #69: Issue #24 create MXAccess COM object on STA
Verified with dotnet build src\\MxGateway.sln, dotnet test src\\MxGateway.Worker.Tests\\MxGateway.Worker.Tests.csproj -p:Platform=x86, and dotnet test src\\MxGateway.sln --no-build.
2026-04-26 17:39:04 -04:00
Joseph Doherty 29455fc1f6 Issue #31: implement mxstatus proxy and hresult conversion 2026-04-26 17:35:30 -04:00
dohertj2 5511609880 Merge PR #68: Issue #12 implement session manager and registry
Verified with dotnet build src\\MxGateway.sln and dotnet test src\\MxGateway.sln.
2026-04-26 17:34:19 -04:00
Joseph Doherty 451dccf7e3 Issue #24: create mxaccess com object on sta 2026-04-26 17:34:12 -04:00
dohertj2 cde9c89386 Merge PR #67: Issue #30 implement value conversion
Verified with dotnet build src\\MxGateway.sln, dotnet test src\\MxGateway.Worker.Tests\\MxGateway.Worker.Tests.csproj -p:Platform=x86, and dotnet test src\\MxGateway.sln --no-build.
2026-04-26 17:30:19 -04:00
Joseph Doherty d496f1fd75 Issue #12: implement session manager and registry 2026-04-26 17:29:47 -04:00
Joseph Doherty 6559672fc1 Issue #30: implement value conversion 2026-04-26 17:26:36 -04:00
dohertj2 97c30b9d00 Merge PR #66: Issue #23 implement STA runtime and message pump
Verified with dotnet build src\\MxGateway.sln, dotnet test src\\MxGateway.Worker.Tests\\MxGateway.Worker.Tests.csproj -p:Platform=x86, and dotnet test src\\MxGateway.sln --no-build.
2026-04-26 17:23:02 -04:00
dohertj2 603aff7004 Merge PR #65: Issue #22 implement pipe client and frame protocol
Verified with dotnet build src\\MxGateway.sln, dotnet test src\\MxGateway.Worker.Tests\\MxGateway.Worker.Tests.csproj -p:Platform=x86, and dotnet test src\\MxGateway.sln --no-build.
2026-04-26 17:20:28 -04:00
Joseph Doherty e81682e367 Issue #23: implement sta runtime and message pump 2026-04-26 17:19:00 -04:00
Joseph Doherty d5a982152b Issue #22: implement pipe client and frame protocol 2026-04-26 17:16:49 -04:00
dohertj2 0b0be7098e Merge PR #64: Issue #11 implement gateway WorkerClient
Verified with dotnet build src\\MxGateway.sln and dotnet test src\\MxGateway.sln.
2026-04-26 17:14:03 -04:00
Joseph Doherty fce9e99553 Issue #11: implement gateway workerclient 2026-04-26 17:09:51 -04:00
dohertj2 c8fb3e91a3 Merge PR #63: Issue #8 add gRPC authentication and scope authorization
Verified with dotnet build src\\MxGateway.sln and dotnet test src\\MxGateway.sln.
2026-04-26 17:06:23 -04:00
dohertj2 8ce327e6f4 Merge PR #62: Issue #7 implement local api key admin cli
Verified with dotnet build src\\MxGateway.sln and dotnet test src\\MxGateway.sln.
2026-04-26 17:02:09 -04:00
Joseph Doherty fad0ac9948 Issue #8: add grpc authentication and scope authorization 2026-04-26 17:01:59 -04:00
dohertj2 9cb2f1c5cd Merge PR #61: Issue #21 implement worker bootstrap and options
Verified with dotnet build src\\MxGateway.sln, dotnet test src\\MxGateway.Worker.Tests\\MxGateway.Worker.Tests.csproj -p:Platform=x86, and dotnet test src\\MxGateway.sln --no-build.
2026-04-26 16:56:52 -04:00
Joseph Doherty da9ffe0e11 Issue #7: implement local api key admin cli 2026-04-26 16:56:12 -04:00
Joseph Doherty 0af1427859 Issue #21: implement worker bootstrap and options 2026-04-26 16:53:06 -04:00
dohertj2 e2b4dfcb32 Merge PR #60: Issue #10 implement worker process launcher
Verified with dotnet build src\\MxGateway.sln and dotnet test src\\MxGateway.sln --no-build.
2026-04-26 16:50:00 -04:00
dohertj2 3b3e41acf4 Merge PR #59: Issue #6 implement API key hashing and verification
Verified with dotnet build src\\MxGateway.sln and dotnet test src\\MxGateway.sln.
2026-04-26 16:46:06 -04:00
Joseph Doherty c1188c6957 Issue #10: implement worker process launcher 2026-04-26 16:45:42 -04:00
dohertj2 4094e64ee0 Merge PR #58: Issue #20 scaffold worker project
Verified with dotnet build src\\MxGateway.sln, dotnet test src\\MxGateway.Worker.Tests\\MxGateway.Worker.Tests.csproj -p:Platform=x86, and dotnet test src\\MxGateway.sln --no-build.
2026-04-26 16:41:34 -04:00
Joseph Doherty 696be17139 Issue #6: implement api key hashing and verification 2026-04-26 16:40:46 -04:00
Joseph Doherty b42c3c8b3b Issue #20: scaffold worker project 2026-04-26 16:37:23 -04:00
dohertj2 420a813967 Merge PR #56: Issue #5 implement SQLite auth store and migrations
Verified with dotnet build src\\MxGateway.sln and dotnet test src\\MxGateway.sln.
2026-04-26 16:34:28 -04:00
241 changed files with 19375 additions and 356 deletions
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,28 @@
{
"family": "MX_EVENT_FAMILY_ON_DATA_CHANGE",
"sessionId": "session-fixture",
"serverHandle": 12,
"itemHandle": 34,
"value": {
"dataType": "MX_DATA_TYPE_INTEGER",
"variantType": "VT_I4",
"int32Value": 123
},
"quality": 192,
"sourceTimestamp": "2026-01-01T00:00:00Z",
"statuses": [
{
"success": 1,
"category": "MX_STATUS_CATEGORY_OK",
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
"detail": 0,
"rawCategory": 0,
"rawDetectedBy": 0,
"diagnosticText": "OK"
}
],
"workerSequence": "1",
"workerTimestamp": "2026-01-01T00:00:00.010Z",
"gatewayReceiveTimestamp": "2026-01-01T00:00:00.015Z",
"onDataChange": {}
}
@@ -0,0 +1,18 @@
{
"sessionId": "session-fixture",
"backendName": "mxaccess-worker",
"workerProcessId": 1234,
"workerProtocolVersion": 1,
"gatewayProtocolVersion": 1,
"capabilities": [
"unary-open-session",
"unary-close-session",
"unary-invoke",
"server-stream-events"
],
"defaultCommandTimeout": "30s",
"protocolStatus": {
"code": "PROTOCOL_STATUS_CODE_OK",
"message": "Session opened."
}
}
@@ -0,0 +1,10 @@
{
"sessionId": "session-fixture",
"clientCorrelationId": "fixture-register-1",
"command": {
"kind": "MX_COMMAND_KIND_REGISTER",
"register": {
"clientName": "fixture-client"
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"schemaVersion": 1,
"contractName": "mxaccess-gateway",
"gatewayProtocolVersion": 1,
"workerProtocolVersion": 1,
"protoRoot": "src/MxGateway.Contracts/Protos",
"sourceFiles": [
{
"path": "mxaccess_gateway.proto",
"role": "public_gateway"
},
{
"path": "mxaccess_worker.proto",
"role": "gateway_worker_ipc"
}
],
"descriptorSet": "clients/proto/descriptors/mxaccessgw-client-v1.protoset",
"fixtureRoot": "clients/proto/fixtures/golden",
"generatedOutputs": {
"dotnet": "clients/dotnet/generated",
"go": "clients/go/internal/generated",
"rust": "clients/rust/src/generated",
"python": "clients/python/src/mxgateway/generated",
"java": "clients/java/src/main/generated"
}
}
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+13
View File
@@ -18,6 +18,12 @@ event, value, and status shapes.
Generated C# output is written to `src/MxGateway.Contracts/Generated/`. Do not
hand-edit generated files.
Client generation inputs are published through
`clients/proto/proto-inputs.json` and the descriptor set under
`clients/proto/descriptors/`. See
[Client Proto Generation](./client-proto-generation.md) for language-specific
generation inputs, output directories, and golden protobuf JSON fixtures.
## Generation
Run the contracts build to regenerate C# protobuf and gRPC code:
@@ -39,8 +45,15 @@ gateway and test projects:
dotnet build src/MxGateway.sln
```
Regenerate the client descriptor after changing either `.proto` file:
```bash
powershell -ExecutionPolicy Bypass -File scripts/publish-client-proto-inputs.ps1
```
## Related Documentation
- [Client Proto Generation](./client-proto-generation.md)
- [Gateway Process Detailed Design](./gateway-process-design.md)
- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md)
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
+58
View File
@@ -0,0 +1,58 @@
# Gateway Testing
Gateway tests run without installed MXAccess by using fake workers, fake
transports, and in-process gRPC service fakes. Live MXAccess verification belongs
in opt-in integration tests because it depends on installed COM components and
provider state.
## Fake Worker Harness
`FakeWorkerHarness` in `src/MxGateway.Tests/Gateway/Workers/Fakes/` provides an
in-process worker side for named-pipe IPC tests. It uses the same
`WorkerFrameReader`, `WorkerFrameWriter`, and `WorkerEnvelope` contract as the
gateway so tests exercise real frame validation and worker-client state changes.
Use the harness when a gateway or session test needs worker behavior without
starting `MxGateway.Worker.exe` or loading MXAccess COM. The harness scripts:
- `WorkerHello` and `WorkerReady` startup,
- command replies with matching correlation ids,
- ordered `WorkerEvent` frames,
- `WorkerFault` frames,
- shutdown acknowledgements,
- malformed protobuf payloads and oversized frame headers,
- slow or hung workers by withholding a reply.
Session-level tests can connect the harness to the pipe created by
`SessionWorkerClientFactory` with `ConnectToGatewayPipeAsync`. Lower-level
`WorkerClient` tests can use `CreateConnectedPairAsync` to create both pipe ends
inside the test.
`GatewayEndToEndFakeWorkerSmokeTests` composes the real gRPC service,
`SessionManager`, `SessionWorkerClientFactory`, `WorkerClient`, and
`EventStreamService` with a scripted fake worker launcher. The smoke test covers
`OpenSession`, `Register`, `AddItem`, `Advise`, one streamed `OnDataChange`
event, and `CloseSession` without loading MXAccess COM.
## Focused Commands
Run the fake worker tests after changing gateway worker IPC, session startup, or
event streaming behavior:
```bash
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~SessionWorkerClientFactoryFakeWorkerTests
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
```
Run the gateway test project after shared gateway test infrastructure changes:
```bash
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj
```
## Related Documentation
- [Gateway Process Design](./gateway-process-design.md)
- [Worker Frame Protocol](./WorkerFrameProtocol.md)
- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md)
+62
View File
@@ -0,0 +1,62 @@
# Worker Process Launcher
The gateway uses `WorkerProcessLauncher` to validate and start one worker
process for a gateway session. The launcher owns process start semantics only;
pipe handshaking and `WorkerReady` validation remain part of the worker client
startup path.
## Launch Inputs
`WorkerProcessLaunchRequest` carries the per-session bootstrap values:
- `SessionId`,
- `PipeName`,
- `ProtocolVersion`,
- `Nonce`,
- optional `PipeReservation` cleanup handle.
The launcher passes `SessionId`, `PipeName`, and `ProtocolVersion` as command
line arguments:
```text
--session-id <sessionId> --pipe-name <pipeName> --protocol-version <version>
```
The launcher sets the nonce through the `MXGATEWAY_WORKER_NONCE` environment
variable. The nonce is not included in `WorkerProcessCommandLine` so logs and
diagnostics can report the launch command without exposing the secret.
## Validation And Cleanup
Before starting the process, the launcher validates that the configured worker
path exists, has a `.exe` extension, contains a valid Windows Portable
Executable header, and matches the configured `RequiredArchitecture`.
After the process starts, `IWorkerStartupProbe` waits for startup readiness.
The default probe only verifies that the worker did not exit immediately. The
worker client replaces this probe when pipe connection, hello, and
`WorkerReady` handling are implemented.
If startup fails or exceeds `WorkerOptions.StartupTimeoutSeconds`, the launcher
kills the worker process tree, disposes the process handle, disposes the
optional pipe reservation, records a worker kill metric, and reports a
`WorkerProcessLaunchException`.
## Verification
Run the focused launcher tests after changing process launch behavior:
```bash
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter WorkerProcessLauncherTests
```
Run the gateway build because the launcher is part of `MxGateway.Server`:
```bash
dotnet build src/MxGateway.Server/MxGateway.Server.csproj
```
## Related Documentation
- [Gateway Process Detailed Design](./gateway-process-design.md)
- [Worker Frame Protocol](./WorkerFrameProtocol.md)
+15
View File
@@ -26,6 +26,11 @@ Language-specific plans:
- `docs/clients-python-design.md`
- `docs/clients-java-design.md`
Shared generation inputs:
- `docs/client-proto-generation.md`
- `clients/proto/proto-inputs.json`
Language style guides:
| Client | Style guide |
@@ -365,6 +370,16 @@ examples/
Generated code should be reproducible from `src/MxGateway.Contracts/Protos/`.
Do not hand-edit generated code.
The stable client proto manifest defines the generated-code directories:
```text
clients/dotnet/generated
clients/go/internal/generated
clients/rust/src/generated
clients/python/src/mxgateway/generated
clients/java/src/main/generated
```
## Versioning
All clients should expose:
+133
View File
@@ -0,0 +1,133 @@
# Client Proto Generation
This document defines the stable protobuf inputs that official clients use to
generate language-specific gRPC bindings. The checked-in `.proto` files remain
the source of truth so clients do not drift from the gateway and worker
contracts.
## Stable Inputs
The stable client input manifest is `clients/proto/proto-inputs.json`. It
records:
- the public gateway protocol version,
- the worker IPC protocol version,
- the protobuf import root,
- the public and worker source files,
- the descriptor set path,
- golden fixture locations,
- generated-code output directories for each planned client.
The source files listed by the manifest are:
- `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto`
- `src/MxGateway.Contracts/Protos/mxaccess_worker.proto`
`mxaccess_gateway.proto` defines the public gRPC service and shared DTOs.
`mxaccess_worker.proto` is included in the descriptor because worker-aware
tests and fake-worker clients need the same command, reply, event, value, and
status shapes.
## Protocol Version
`GatewayContractInfo.GatewayProtocolVersion` is the public gateway protocol
version. `OpenSessionReply.gateway_protocol_version` returns the same value so
clients can compare their generated bindings against the gateway before issuing
MXAccess commands.
`GatewayContractInfo.WorkerProtocolVersion` remains the gateway-to-worker IPC
protocol version. It is also present in `OpenSessionReply` because parity
fixtures and fake-worker tests need to know the worker contract used by the
session.
## Descriptor Publishing
Run this command after changing either source `.proto` file or the client proto
manifest:
```powershell
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.
Use the check mode in CI or before committing:
```powershell
scripts/publish-client-proto-inputs.ps1 -Check
```
`-Check` rebuilds the descriptor in a temporary path and fails when the checked
in descriptor is stale.
## Output Directories
The manifest declares these generated-code directories:
| Client | Directory |
|--------|-----------|
| .NET | `clients/dotnet/generated` |
| Go | `clients/go/internal/generated` |
| Rust | `clients/rust/src/generated` |
| Python | `clients/python/src/mxgateway/generated` |
| Java | `clients/java/src/main/generated` |
Only generator output belongs in these directories. Handwritten client wrappers
belong in the language-specific source trees created by the client scaffold
issues.
## Language Generation Inputs
All generators use `src/MxGateway.Contracts/Protos` as the protobuf import
root. The checked-in descriptor is available when a language build prefers a
descriptor input, but the `.proto` files remain canonical.
.NET generation currently runs through the contracts project:
```powershell
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
```
Future .NET client projects may either reference `MxGateway.Contracts` or
generate client-local files into `clients/dotnet/generated` with `Grpc.Tools`.
Go clients should generate `mxaccess_gateway.proto` and
`mxaccess_worker.proto` into `clients/go/internal/generated` with
`protoc-gen-go` and `protoc-gen-go-grpc`. Keep generated packages internal
unless the wrapper API intentionally exposes raw protobuf messages.
Rust clients should use `tonic-build` or the selected protobuf generator from
the Rust client build script, with generated modules placed under
`clients/rust/src/generated` or included from the build output according to the
client crate design.
Python clients should use `grpc_tools.protoc` and write generated modules under
`clients/python/src/mxgateway/generated` so imports stay separate from
handwritten async wrappers.
Java clients should use the Gradle protobuf plugin and write generated sources
under `clients/java/src/main/generated`. The Java client scaffold owns the
Gradle plugin versions and source-set wiring.
## Golden Fixtures
Golden protobuf JSON fixtures live in `clients/proto/fixtures/golden`. They
exercise payloads that every language client must parse:
- `open-session-reply.ok.json`
- `register-command-request.json`
- `on-data-change-event.json`
The fixtures use protobuf JSON field names and enum values. Contract tests parse
them with the generated C# types so schema drift is caught before client
generation work starts.
## Related Documentation
- [Protobuf Contracts](./Contracts.md)
- [Client Libraries Detailed Design](./client-libraries-design.md)
- [Client Libraries Implementation Plan](./implementation-plan-clients.md)
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
+46 -37
View File
@@ -34,9 +34,13 @@ SignalR circuit. Bootstrap is sufficient for a basic dashboard.
## Hosting Model
The dashboard is hosted by `MxGateway.Server` alongside the gRPC API.
The dashboard is hosted by `MxGateway.Server` alongside the gRPC API. When
`MxGateway:Dashboard:Enabled` is `true`, `MapGatewayDashboard()` maps the
configured `Dashboard:PathBase` to the Blazor Server app and maps the login,
logout, and access-denied HTTP endpoints beside it. When dashboard hosting is
disabled, those routes are not mapped.
Suggested endpoint layout:
Endpoint layout:
```text
/dashboard
@@ -45,7 +49,7 @@ Suggested endpoint layout:
/dashboard/workers
/dashboard/events
/dashboard/settings
/_blazor
/dashboard/_blazor
```
The app should redirect `/` to `/dashboard` only if the deployment wants the
@@ -59,9 +63,10 @@ MxGateway.Server
Components/
App.razor
Routes.razor
DashboardPageBase.cs
DashboardDisplay.cs
Layout/
DashboardLayout.razor
NavMenu.razor
Pages/
DashboardHome.razor
SessionsPage.razor
@@ -69,26 +74,21 @@ MxGateway.Server
WorkersPage.razor
EventsPage.razor
SettingsPage.razor
Components/
Shared/
MetricCard.razor
SessionTable.razor
WorkerTable.razor
EventRatePanel.razor
StatusBadge.razor
FaultList.razor
Services/
DashboardSnapshotService.cs
DashboardUpdateHub.cs
DashboardAuthorization.cs
Models/
DashboardSnapshot.cs
SessionSummary.cs
WorkerSummary.cs
MetricSummary.cs
DashboardSnapshotService.cs
DashboardAuthorizationHandler.cs
DashboardAuthenticator.cs
DashboardSnapshot.cs
DashboardSessionSummary.cs
DashboardWorkerSummary.cs
DashboardMetricSummary.cs
```
`DashboardUpdateHub` here means an internal application update service, not a
separate public SignalR hub unless implementation proves one is needed. Blazor
Server already uses SignalR for UI circuits.
Blazor Server provides the SignalR circuit for UI updates. The implementation
does not add a separate public dashboard hub.
## Dashboard Data Source
@@ -137,7 +137,7 @@ gateway internals.
Use Blazor Server component state updates for real-time dashboard refresh.
Recommended pattern:
Implemented pattern:
1. Page/component subscribes to `WatchSnapshotsAsync`.
2. Snapshot service emits updates from a bounded channel or timer.
@@ -147,10 +147,8 @@ Recommended pattern:
Default update cadence:
- immediate update on session create/close/fault,
- immediate update on worker fault,
- periodic metrics refresh every 1 second,
- event-rate windows updated every 1 second.
- event counters update on the next snapshot tick.
Avoid pushing every MXAccess data-change event to the dashboard. Aggregate event
counts and rates instead.
@@ -257,19 +255,18 @@ Do not show API key secrets or pepper values.
## Authentication And Authorization
Dashboard access should use the same API-key authentication model as gRPC where
Dashboard access uses the same API-key authentication model as gRPC where
practical.
Recommended v1 behavior:
Implemented v1 behavior:
- dashboard disabled by default unless configured,
- when enabled, require API key auth,
- require `admin` scope for dashboard access,
- accept API key through a secure cookie established by a simple login form, or
through reverse-proxy/header configuration for local deployments,
- do not put API keys in query strings.
- accept API key through a secure cookie established by a simple login form,
- do not put API keys in query strings,
- validate anti-forgery tokens for login and logout posts.
Simplest implementation path:
The implementation path is:
1. Add `/dashboard/login`.
2. User submits API key over HTTPS.
@@ -281,6 +278,13 @@ Simplest implementation path:
For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost`
option. It must default to false.
`DashboardAuthenticator` keeps API-key validation outside UI components. It
formats the submitted key as a bearer authorization header for
`IApiKeyVerifier`, rejects non-admin keys when `Dashboard:RequireAdminScope` is
enabled, and creates the dashboard cookie principal without storing raw API key
material. `DashboardAuthorizationHandler` enforces the cookie, admin-scope, and
explicit loopback bypass decisions for all protected dashboard routes.
## Configuration
Suggested configuration:
@@ -314,7 +318,9 @@ Suggested configuration:
## Styling
Use Bootstrap utility classes and a small local stylesheet.
The dashboard serves Bootstrap 5.3.3 assets from
`src/MxGateway.Server/wwwroot/lib/bootstrap/` and local layout/status styling
from `src/MxGateway.Server/wwwroot/css/dashboard.css`.
Recommended visual language:
@@ -355,15 +361,18 @@ Integration tests should verify:
## Initial Implementation Slice
The first dashboard slice should implement:
The first dashboard slice implements:
1. Blazor Server hosting in `MxGateway.Server`.
2. Bootstrap static assets.
2. local Bootstrap static assets.
3. dashboard configuration binding.
4. dashboard auth using API key login and HTTP-only cookie.
5. read-only `DashboardSnapshotService`.
6. home page with metric cards.
7. sessions page with active session table.
7. sessions page with active session table and session details.
8. workers page with worker table.
9. 1-second realtime refresh through Blazor Server.
10. redaction tests for secrets.
9. events page with aggregate counters.
10. settings page with redacted effective configuration.
11. periodic realtime refresh through Blazor Server.
12. route-mapping tests, disabled-dashboard tests, auth tests, and snapshot
projection/redaction tests.
+137 -14
View File
@@ -64,8 +64,8 @@ MxGateway.Server
Configuration
Grpc
MxAccessGatewayService
RequestReplyMapper
EventMapper
MxAccessGrpcRequestValidator
MxAccessGrpcMapper
Dashboard
Pages
Components
@@ -105,6 +105,15 @@ service MxAccessGateway {
}
```
`MxAccessGatewayService` implements these public RPCs in the gateway process.
It validates public requests with `MxAccessGrpcRequestValidator`, delegates
session lifecycle and command routing to `ISessionManager`, and maps worker
command replies and events through `MxAccessGrpcMapper`. Session lookup,
validation, and worker transport failures become gRPC status errors. MXAccess
method replies that reached the worker remain `MxCommandReply` payloads so
HRESULT values, status arrays, and method-specific reply fields survive
transport boundaries.
Add this later only after the command and event model is stable:
```protobuf
@@ -197,13 +206,23 @@ accounting and a clear fan-out policy.
Behavior:
1. Validate session id and authorize event access.
2. Attach a stream cursor to the session event channel.
3. Send events in worker sequence order.
4. Stop on client cancellation, session close, or session fault.
5. Emit a terminal status when the session faults if gRPC status alone cannot
2. Attach the single active subscriber lease for the session.
3. Read worker events into a bounded public stream queue.
4. Send events in worker sequence order.
5. Stop on client cancellation, session close, or session fault.
6. Emit a terminal status when the session faults if gRPC status alone cannot
preserve the required details.
The gateway must not reorder events from one worker.
`EventStreamService` owns subscriber tracking and public stream backpressure.
The default policy allows one active subscriber per session. A second subscriber
is rejected with `EventSubscriberAlreadyActive`. Stream cancellation releases
the subscriber lease so a later stream can attach to the session.
The gateway must not reorder events from one worker. `EventStreamService` writes
mapped events to a bounded first-in, first-out queue and faults the session with
`EventQueueOverflow` if the queue fills. The gateway does not synthesize
`OperationComplete`; it forwards that family only when the worker reports a
native MXAccess `OperationComplete` event.
## Web Dashboard
@@ -330,6 +349,20 @@ The worker remains authoritative for MXAccess handles. The gateway may keep a
shadow state for diagnostics, but it must not invent, rewrite, or recycle
MXAccess handles.
`SessionManager` owns the current in-memory session registry. It allocates a
session id, creates the worker pipe name and nonce, registers the session before
worker startup, and removes the session if startup fails. A successful
`OpenSession` attaches the ready `IWorkerClient` and transitions the session to
`Ready`.
Only `Ready` sessions accept command and event operations. `CloseSession` is
idempotent for sessions still known to the registry: the first close shuts down
the worker, and later closes return the final `Closed` state. Lease handling is
exposed as a session hook so a monitor can close expired sessions without
embedding lease policy in the worker client. Gateway shutdown walks the
registry, closes each known session, and kills a worker if graceful shutdown
fails.
## Worker Launch
The gateway should launch the worker using explicit configuration:
@@ -360,6 +393,15 @@ Before launch, validate:
- worker file version or product version is acceptable,
- worker is expected to be x86.
`WorkerProcessLauncher` implements the first validation layer now: it resolves
the worker executable path, requires a `.exe`, validates the Windows Portable
Executable header, and verifies the configured processor architecture. It passes
only `--session-id`, `--pipe-name`, and `--protocol-version` on the command
line. The per-session nonce is set through `MXGATEWAY_WORKER_NONCE` so the
command line remains safe to log. Startup failures and startup timeouts kill and
dispose the worker process and the pre-created pipe reservation before the
session manager observes the failure.
## Worker IPC
The gateway creates the pipe server before launching the worker.
@@ -402,7 +444,7 @@ session ids as protocol faults and close the session.
`WorkerClient` is the gateway-side object that owns one worker connection.
Suggested public shape:
Current public shape:
```csharp
public interface IWorkerClient : IAsyncDisposable
@@ -410,6 +452,7 @@ public interface IWorkerClient : IAsyncDisposable
string SessionId { get; }
int? ProcessId { get; }
WorkerClientState State { get; }
DateTimeOffset LastHeartbeatAt { get; }
Task StartAsync(CancellationToken cancellationToken);
Task<WorkerCommandReply> InvokeAsync(
@@ -429,12 +472,17 @@ Internally it owns:
- pipe stream,
- read loop,
- write loop,
- bounded outbound command/control channel,
- outbound command/control channel serialized by the write loop,
- bounded inbound event channel,
- pending command dictionary keyed by correlation id,
- heartbeat monitor,
- terminal fault source.
`StartAsync` sends `GatewayHello`, verifies the `WorkerHello` protocol version
and nonce, waits for `WorkerReady`, and only then exposes `Ready` state. The
read loop starts after readiness so the handshake has a single owner for its
ordered frames.
### Read Loop
The read loop:
@@ -546,7 +594,8 @@ worker MXAccess event
-> worker outbound event queue
-> worker pipe writer
-> gateway read loop
-> session event channel
-> worker client event queue
-> EventStreamService bounded stream queue
-> gRPC StreamEvents
```
@@ -560,13 +609,15 @@ The gateway should record:
Default backpressure policy for parity testing should be fail-fast:
1. If the session event channel fills, fault the session.
1. If the worker client event queue fills, fault the worker client.
2. If the public stream queue fills, fault the gateway session.
2. Preserve the overflow details in logs and metrics.
3. Do not silently drop data-change events.
Do not set a production event-rate target before measurement. Emit event rate,
queue depth, stream send latency, and overflow metrics. Later production modes
may support explicit coalescing by item handle as an opt-in behavior.
Do not set a production event-rate target before measurement. `GatewayMetrics`
records received event counts by family, queue depth, stream disconnects, and
overflow counts. Later production modes may support explicit coalescing by item
handle as an opt-in behavior.
The gateway should not synthesize `OperationComplete` from write completion,
command replies, ASB completion queues, or completion-only status frames. Forward
@@ -589,6 +640,39 @@ The gateway should split the key into a stable key id and secret component,
load the key record by id, hash the presented secret, and compare using a
constant-time comparison.
`ApiKeyParser` accepts only `authorization: Bearer mxgw_<key-id>_<secret>`.
Malformed headers fail before any database lookup. The parsed raw secret is
kept only long enough for `ApiKeySecretHasher` to compute an HMAC-SHA256 hash
using the configured `Authentication:PepperSecretName` lookup in application
configuration. The raw secret is not stored in the auth database, identity
model, logs, or verification result.
`ApiKeyVerifier` loads the stored key record by key id, rejects revoked keys,
hashes the presented secret, and compares the stored and presented hashes with
`CryptographicOperations.FixedTimeEquals`. A successful verification returns an
`ApiKeyIdentity` with key id, key prefix, display name, and scopes. Failure
results distinguish malformed credentials, missing keys, revoked keys, missing
pepper configuration, and hash mismatch for internal authorization handling.
`GatewayGrpcAuthorizationInterceptor` enforces this authentication model for
public gRPC calls. Missing, malformed, revoked, unknown, or mismatched keys fail
with `Unauthenticated`. Authenticated calls missing the scope required by the
RPC fail with `PermissionDenied`. The interceptor applies to unary calls and
server-streaming calls and stores the authenticated `ApiKeyIdentity` in
`IGatewayRequestIdentityAccessor` for the duration of the request handler.
`Authentication:Mode` set to `Disabled` bypasses API-key verification for local
development only.
Dashboard authentication reuses the API-key verifier and scope model. The
dashboard login endpoint accepts the key in a form post, checks `admin` scope
when `Dashboard:RequireAdminScope` is enabled, and signs in with the
`MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict
SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears
that cookie. Login and logout posts use anti-forgery validation, and dashboard
API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost`
allows only loopback requests to bypass the dashboard cookie requirement and
defaults to `false`.
Recommended scopes:
- `session:open`
@@ -608,6 +692,23 @@ gRPC admin API. It should initialize the auth database, create keys, list keys
without secrets, revoke keys, rotate keys, and print raw secrets only once at
creation.
`MxGateway.Server` exposes local API-key administration as an `apikey`
subcommand before the web host starts:
```bash
MxGateway.Server apikey init-db --sqlite-path C:\ProgramData\MxGateway\gateway-auth.db
MxGateway.Server apikey create-key --key-id operator01 --display-name Operator --scopes session:open,events:read
MxGateway.Server apikey list-keys --json
MxGateway.Server apikey revoke-key --key-id operator01
MxGateway.Server apikey rotate-key --key-id operator01 --json
```
The subcommands accept `--sqlite-path`, `--pepper`, and `--json`. `--pepper`
sets the local `MxGateway:ApiKeyPepper` configuration value for the command
process; deployments should normally provide the pepper through the configured
secret source. `create-key` and `rotate-key` print the full raw API key exactly
once. `list-keys` never prints raw secrets or `secret_hash` values.
SQLite auth storage should use startup migrations with a `schema_version` table.
Migrations should run inside transactions and fail startup if the database
schema is newer than the running binary understands.
@@ -637,6 +738,20 @@ Commands requiring authorization:
- worker shutdown diagnostics,
- metadata queries if they expose sensitive plant structure.
Current gRPC scope mapping:
- `OpenSession` requires `session:open`.
- `CloseSession` requires `session:close`.
- `StreamEvents` and `DrainEvents` require `events:read`.
- read-style MXAccess commands such as `Register`, `AddItem`, `Advise`, and
`Ping` require `invoke:read`.
- `Write` and `Write2` require `invoke:write`.
- `WriteSecured`, `WriteSecured2`, and `AuthenticateUser` require
`invoke:secure`.
- metadata commands such as `ArchestrAUserToId`, `GetSessionState`, and
`GetWorkerInfo` require `metadata:read`.
- `ShutdownWorker` requires `admin`.
### Worker IPC
Named pipes should be local only. Pipe ACLs should restrict access to:
@@ -776,9 +891,17 @@ behavior unless an explicit non-parity backend is designed.
Gateway tests should be able to run without installed MXAccess by using fake
workers and fake transports.
Use `FakeWorkerHarness` for tests that need real gateway-to-worker framing,
handshake, command, event, fault, or malformed-protocol behavior without loading
MXAccess COM. See [Gateway Testing](./GatewayTesting.md) for the harness scope
and focused test commands.
Focused tests:
- session state transitions,
- gRPC API-key authentication for unary and streaming calls,
- gRPC scope mapping for sessions, invokes, events, metadata, and admin
commands,
- worker startup failures,
- protocol version mismatch,
- malformed frame handling,
+6 -1
View File
@@ -189,6 +189,8 @@ Tests:
Labels: `area:worker`, `type:feature`, `priority:p0`
Status: implemented.
Deliverables:
- `Register`,
@@ -216,6 +218,8 @@ Live tests:
Labels: `area:worker`, `type:feature`, `priority:p0`
Status: implemented.
Deliverables:
- `AddItem`,
@@ -273,6 +277,8 @@ Live tests:
Labels: `area:worker`, `type:feature`, `priority:p0`
Status: implemented.
Deliverables:
- handlers for `OnDataChange`,
@@ -447,4 +453,3 @@ Acceptance criteria:
- each public method has planned parity fixture or documented gap,
- gateway results preserve HRESULT/status/value/event shape.
+155 -3
View File
@@ -26,6 +26,33 @@ Style guides:
- [C# Style Guide](./style-guides/CSharpStyleGuide.md)
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
## Build And Test
Build the SDK-style worker project with the .NET SDK MSBuild entry point. The
project targets .NET Framework 4.8, but the SDK resolver comes from the .NET SDK
installation:
```powershell
dotnet msbuild src\MxGateway.Worker\MxGateway.Worker.csproj /restore /p:Configuration=Debug /p:Platform=x86
```
`docs/toolchain-links.md` records the Visual Studio MSBuild executable for
classic .NET Framework and COM interop builds:
```powershell
& "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" src\MxGateway.Worker\MxGateway.Worker.csproj /p:Configuration=Debug /p:Platform=x86
```
Run the worker tests with the same platform target:
```powershell
dotnet test src\MxGateway.Worker.Tests\MxGateway.Worker.Tests.csproj -p:Platform=x86
```
The only MXAccess interop reference belongs in `MxGateway.Worker`. Gateway and
test projects may reference the worker project for metadata and scaffold tests,
but they must not reference `ArchestrA.MXAccess.dll` directly.
## Responsibilities
The worker owns:
@@ -87,6 +114,21 @@ Startup sequence:
If validation fails before MXAccess creation, exit quickly with a non-zero exit
code. If MXAccess creation fails, send `WorkerFault` when possible and exit.
The bootstrap layer returns structured exit codes before it creates pipes,
starts the STA, or touches MXAccess:
| Exit code | Name | Meaning |
|-----------|------|---------|
| `0` | `Success` | Required bootstrap options are valid. |
| `1` | `UnexpectedFailure` | A non-bootstrap exception reaches the process boundary. |
| `2` | `InvalidArguments` | Required arguments are missing or unknown arguments are present. |
| `3` | `InvalidProtocolVersion` | `--protocol-version` is not numeric or does not match the supported worker protocol. |
| `4` | `MissingNonce` | `MXGATEWAY_WORKER_NONCE` is absent or empty. |
Bootstrap logs use `WorkerConsoleLogger` key/value output. `WorkerLogRedactor`
redacts fields whose names indicate nonce, secret, password, token,
credential, or API key values before the message is written.
## Internal Components
```text
@@ -208,6 +250,17 @@ The loop should update a heartbeat timestamp after:
- finishing a command,
- processing an MXAccess event.
`StaRuntime` implements this runtime boundary in the worker. It starts one
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
STA without busy-waiting. `LastActivityUtc` records pump, command, startup, and
shutdown activity so the future heartbeat/watchdog can report whether the STA
is still responsive. Shutdown marks the runtime as closing, wakes the pump,
rejects new commands, cancels queued work, uninitializes COM on the STA, and
waits for the thread to exit.
## COM Creation
The MXAccess analysis source at `C:\Users\dohertj2\Desktop\mxaccess` identifies
@@ -236,6 +289,16 @@ The worker should reference the interop assembly and instantiate
`LMXProxyServerClass` on the dedicated STA thread. Keep the ProgID and assembly
path configurable for diagnostics, but this COM class is the v1 default.
`MxAccessStaSession` owns the initial COM creation path. It starts `StaRuntime`,
creates `LMXProxyServerClass` through `MxAccessComObjectFactory` on the STA,
attaches `MxAccessBaseEventSink`, and returns `WorkerReady` only after those
steps succeed. `MxAccessSession` keeps the raw COM object private, records the
STA managed thread id that created it, detaches the base event sink during
disposal, and releases the COM reference on the STA. After creation,
`MxAccessStaSession` owns a `StaCommandDispatcher` backed by
`MxAccessCommandExecutor`; `DispatchAsync` queues contract commands back to the
same STA instead of exposing the COM object to callers.
Creation rules:
- Create COM object only on the STA.
@@ -253,6 +316,11 @@ If COM creation fails, the worker should send a structured fault with:
- worker process id,
- session id.
`WorkerPipeSession` maps startup exceptions from this path to
`WorkerFaultCategory.MxaccessCreationFailed`, includes the captured HRESULT
when the exception exposes one, and does not send `WorkerReady` after a failed
COM creation attempt.
## Event Sink
The worker must subscribe to every public MXAccess event family:
@@ -280,9 +348,28 @@ Event handling rules:
- Enqueue to the outbound event queue.
- Return quickly to preserve message pumping.
If event conversion throws, catch it inside the event handler, enqueue a
structured `WorkerFault` or diagnostic event, and keep the worker alive only if
the fault policy allows it.
`MxAccessBaseEventSink` implements the COM connection-point handlers and keeps
the handlers limited to event argument conversion plus enqueue. It uses
`MxAccessEventMapper` to create `MxEvent` DTOs for `OnDataChange`,
`OnWriteComplete`, `OperationComplete`, and `OnBufferedDataChange`. The mapper
converts scalar and array values through `VariantConverter`, converts
`MXSTATUS_PROXY[]` through `MxStatusProxyConverter`, and maps installed
`MxDataType` values to the public protobuf enum while preserving the raw data
type on buffered events. `OperationComplete` is only emitted from the native
`OperationComplete` handler; write completion does not synthesize it.
`MxAccessEventQueue` is the bounded outbound event queue for one worker
session. It assigns the monotonic `WorkerSequence` and `WorkerTimestamp` when an
event is accepted, preserving the order in which MXAccess handlers enqueue
events. The default capacity is `10000`. When the queue reaches capacity it
records a `WorkerFaultCategory.QueueOverflow` fault and rejects further events.
The event handler catches conversion and enqueue failures, records the first
fault on the queue, and returns to the STA message pump instead of writing to
the pipe.
If event conversion throws, catch it inside the event handler, record a
structured `WorkerFault`, and keep the worker alive only if the fault policy
allows it.
## Command Queue
@@ -349,6 +436,60 @@ Diagnostics:
Implement method-specific dispatch instead of a generic string method invoker.
Parity tests need stable command-specific request and reply shapes.
`MxAccessCommandExecutor` implements the first command pair:
- `Register` calls `LMXProxyServerClass.Register` with the requested client
name and preserves the returned server handle in both `ReturnValue` and
`RegisterReply.ServerHandle`.
- `Unregister` calls `LMXProxyServerClass.Unregister` with the requested server
handle. The reply has no method-specific payload because the public MXAccess
method returns `void`.
Both commands set `Hresult` to `0` only after the COM call returns normally.
COM exceptions flow through `StaCommandDispatcher`, which captures the thrown
HRESULT and converts the reply to `ProtocolStatusCode.MxaccessFailure`.
`MxAccessStaSession.GetRegisteredServerHandlesAsync` returns an STA-read
snapshot of tracked server handles for diagnostics and future cleanup logic.
`MxAccessCommandExecutor` also implements the item lifecycle commands:
- `AddItem` calls `LMXProxyServerClass.AddItem` with the requested server
handle and item definition. It preserves the returned item handle in both
`ReturnValue` and `AddItemReply.ItemHandle`.
- `AddItem2` calls `LMXProxyServerClass.AddItem2` with the requested server
handle, item definition, and context string. The context string is passed to
MXAccess exactly as received.
- `RemoveItem` calls `LMXProxyServerClass.RemoveItem` with the requested server
handle and item handle. The reply has no method-specific payload because the
public MXAccess method returns `void`.
The worker records item handles only after `AddItem` or `AddItem2` returns
normally, and removes item handles only after `RemoveItem` returns normally.
The registry does not prevalidate server or item handles, so invalid and
cross-server handle behavior remains owned by MXAccess. COM exceptions continue
through `StaCommandDispatcher`, which preserves the HRESULT and leaves
diagnostic registry state unchanged for failed cleanup calls.
`MxAccessCommandExecutor` implements advice lifecycle commands on the same STA
path:
- `Advise` calls `LMXProxyServerClass.Advise` with the requested server handle
and item handle.
- `AdviseSupervisory` calls `LMXProxyServerClass.AdviseSupervisory` with the
requested server handle and item handle. This remains a distinct command from
plain `Advise` even though observed scalar captures share the same lower-level
subscription body.
- `UnAdvise` calls `LMXProxyServerClass.UnAdvise` with the requested server
handle and item handle.
The worker records plain and supervisory advice separately only after the COM
call returns normally. Successful `UnAdvise` removes all tracked advice for the
server and item pair because the public MXAccess cleanup method has no plain
versus supervisory selector. Successful `RemoveItem` and `Unregister` also clear
related advice state from the worker registry. Failed advice and cleanup calls
leave registry state unchanged so diagnostics continue to reflect the last
successful MXAccess-owned state transition.
## Handle Registry
The worker should track MXAccess state for diagnostics and cleanup, while still
@@ -369,6 +510,13 @@ Rules:
- Do not invent handles.
- Do not rewrite handles returned by MXAccess.
- Record server handles only after `Register` succeeds.
- Remove server handles only after `Unregister` succeeds.
- Record item handles only after `AddItem` or `AddItem2` succeeds.
- Remove item handles only after `RemoveItem` succeeds.
- Record advice state only after `Advise` or `AdviseSupervisory` succeeds.
- Remove advice state only after `UnAdvise`, `RemoveItem`, or `Unregister`
succeeds.
- Preserve invalid-handle behavior from MXAccess.
- Preserve cross-server handle behavior from MXAccess.
- Use registry state for cleanup and diagnostics, not semantic correction.
@@ -612,6 +760,10 @@ Live MXAccess tests:
Live tests should be opt-in and clearly marked because they depend on installed
MXAccess COM and provider state.
The worker test suite uses `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1` for these
tests. `AddItem` uses `TestChildObject.TestInt` by default and accepts an
override through `MXGATEWAY_LIVE_MXACCESS_ITEM`; `AddItem2` uses the captured
parity fixture shape `AddItem2("TestInt", "TestChildObject")`.
## Initial Implementation Slice
+52 -11
View File
@@ -47,6 +47,8 @@ Detailed follow-up docs:
security, observability, and test strategy.
- `docs/WorkerFrameProtocol.md` covers the gateway-side named-pipe frame
reader/writer and `WorkerEnvelope` validation rules.
- `docs/WorkerProcessLauncher.md` covers worker executable validation, process
launch arguments, nonce handling, and startup cleanup behavior.
- `docs/mxaccess-worker-instance-design.md` covers each .NET Framework 4.8 x86
MXAccess worker instance, including STA ownership, message pumping, COM
lifetime, command dispatch, event sinks, conversion, and shutdown.
@@ -105,6 +107,24 @@ worker, correlation, command, and client identity fields with redaction applied
before values enter log state. `GatewayMetrics` exposes counters, gauges, and
histograms through .NET `Meter` and a snapshot API that dashboard services can
project without binding to a metrics exporter.
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
effective configuration into immutable DTOs for read-only dashboard rendering.
The Blazor Server dashboard renders those snapshots at `/dashboard`,
`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, and
`/dashboard/settings`. Components subscribe to
`IDashboardSnapshotService.WatchSnapshotsAsync()` and update on the configured
snapshot interval without mutating session or worker state. The dashboard uses
local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not
use a Blazor UI component library.
Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login`
accepts the API key in a form body, validates the configured `admin` scope,
and issues an HTTP-only secure cookie for subsequent dashboard requests.
`/dashboard/logout` clears that cookie. Login and logout posts validate
anti-forgery tokens, and API keys are never accepted through query strings.
`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for
loopback requests only when explicitly enabled. Setting
`MxGateway:Dashboard:Enabled` to `false` leaves the dashboard routes unmapped.
### Worker Process
@@ -516,11 +536,7 @@ Worker policy:
- bounded outbound event channel,
- never block MXAccess event handler on pipe writes,
- if the outbound channel is full, apply configured policy:
- disconnect session,
- drop oldest low-priority data-change events,
- coalesce data changes by item handle,
- or block briefly then fault.
- fail the worker session when the outbound channel is full.
For full parity testing, default should be fail-fast rather than silent drop.
For production high-rate telemetry, add explicit coalescing modes.
@@ -529,9 +545,15 @@ Gateway policy:
- one event sequencer per session,
- preserve per-session event order,
- support multiple client event subscribers only if explicitly required,
- apply backpressure from slow gRPC streams,
- disconnect or coalesce according to client-selected mode.
- allow one active client event subscriber per session,
- reject a second subscriber with a clear session error,
- use a bounded `EventStreamService` queue between worker events and gRPC
writes,
- fault the session when the bounded stream queue overflows,
- detach the subscriber when the stream is canceled.
The gateway forwards only events reported by the worker. It does not synthesize
`OperationComplete` from write completion, command replies, or status frames.
## Isolation And Fault Handling
@@ -564,9 +586,13 @@ Because each client owns one worker, a crash or leak affects only that session.
External gateway:
- use TLS for remote gRPC if crossing machine boundaries,
- authenticate clients with Windows auth, mTLS, or a deployment-specific token,
- authorize access to commands that can write, authenticate users, or alter
runtime state.
- authenticate v1 gRPC clients with `authorization: Bearer
mxgw_<key-id>_<secret>` API-key metadata,
- reject missing or invalid API keys with gRPC `Unauthenticated`,
- reject valid keys that lack the required session, invoke, event, metadata, or
admin scope with gRPC `PermissionDenied`,
- authorize access to commands that can write, authenticate users, expose
metadata, stream events, or alter runtime state.
Internal worker IPC:
@@ -793,6 +819,12 @@ Core operations:
- track worker state,
- close or kill worker.
The gateway implementation keeps sessions in an in-memory `SessionRegistry`
keyed by session id. `SessionManager` owns the state machine, creates
per-session pipe names and nonces, starts the worker through the worker-client
factory, gates commands to `Ready` sessions, exposes lease-close hooks, and
cleans up workers during gateway shutdown.
State machine:
```text
@@ -840,6 +872,15 @@ The gRPC layer should be thin:
Avoid embedding MXAccess-specific business logic in gRPC handlers. Keep the
translation code testable.
The gateway maps `MxAccessGateway` to `MxAccessGatewayService`. The service
implements `OpenSession`, `CloseSession`, `Invoke`, and `StreamEvents` by
validating public requests, delegating session work to `ISessionManager`, and
using explicit mapper code for public-to-worker commands and worker replies.
`StreamEvents` delegates subscriber ownership, ordering, and backpressure to
`EventStreamService`. Missing sessions and transport failures return gRPC
status errors; worker command replies preserve MXAccess HRESULT and status
details in the public reply.
## C# Worker Versus C++ Worker
Start with a C# .NET Framework 4.8 x86 worker.
+95
View File
@@ -0,0 +1,95 @@
[CmdletBinding()]
param(
[switch]$Check
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$protoRoot = Join-Path $repoRoot "src/MxGateway.Contracts/Protos"
$manifestPath = Join-Path $repoRoot "clients/proto/proto-inputs.json"
$descriptorPath = Join-Path $repoRoot "clients/proto/descriptors/mxaccessgw-client-v1.protoset"
function Resolve-Protoc {
$pathCommand = Get-Command "protoc.exe" -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
}
throw "Could not find protoc.exe. See docs/toolchain-links.md for the documented protobuf toolchain path."
}
function Ensure-Directory {
param([string]$Path)
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path | Out-Null
}
}
function Compare-FileBytes {
param(
[string]$ExpectedPath,
[string]$ActualPath
)
if (-not (Test-Path $ExpectedPath)) {
return $false
}
$expected = [System.IO.File]::ReadAllBytes($ExpectedPath)
$actual = [System.IO.File]::ReadAllBytes($ActualPath)
if ($expected.Length -ne $actual.Length) {
return $false
}
for ($index = 0; $index -lt $expected.Length; $index++) {
if ($expected[$index] -ne $actual[$index]) {
return $false
}
}
return $true
}
$manifest = Get-Content -Raw $manifestPath | ConvertFrom-Json
Ensure-Directory (Split-Path $descriptorPath -Parent)
foreach ($output in $manifest.generatedOutputs.PSObject.Properties.Value) {
Ensure-Directory (Join-Path $repoRoot $output)
}
$protoc = Resolve-Protoc
$outputPath = $descriptorPath
if ($Check) {
$outputPath = Join-Path ([System.IO.Path]::GetTempPath()) ("mxaccessgw-client-v1-" + [System.Guid]::NewGuid().ToString("N") + ".protoset")
}
try {
& $protoc `
"--proto_path=$protoRoot" `
"--include_imports" `
"--include_source_info" `
"--descriptor_set_out=$outputPath" `
"mxaccess_gateway.proto" `
"mxaccess_worker.proto"
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
}
}
@@ -6,6 +6,8 @@ namespace MxGateway.Contracts;
/// </summary>
public static class GatewayContractInfo
{
public const uint GatewayProtocolVersion = 1;
public const uint WorkerProtocolVersion = 1;
public const string DefaultBackendName = "mxaccess-worker";
@@ -30,282 +30,282 @@ namespace MxGateway.Contracts.Proto {
"ChFyZXF1ZXN0ZWRfYmFja2VuZBgBIAEoCRIbChNjbGllbnRfc2Vzc2lvbl9u",
"YW1lGAIgASgJEh0KFWNsaWVudF9jb3JyZWxhdGlvbl9pZBgDIAEoCRIyCg9j",
"b21tYW5kX3RpbWVvdXQYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRp",
"b24iiAIKEE9wZW5TZXNzaW9uUmVwbHkSEgoKc2Vzc2lvbl9pZBgBIAEoCRIU",
"b24iqgIKEE9wZW5TZXNzaW9uUmVwbHkSEgoKc2Vzc2lvbl9pZBgBIAEoCRIU",
"CgxiYWNrZW5kX25hbWUYAiABKAkSGQoRd29ya2VyX3Byb2Nlc3NfaWQYAyAB",
"KAUSHwoXd29ya2VyX3Byb3RvY29sX3ZlcnNpb24YBCABKA0SFAoMY2FwYWJp",
"bGl0aWVzGAUgAygJEjoKF2RlZmF1bHRfY29tbWFuZF90aW1lb3V0GAYgASgL",
"MhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEjwKD3Byb3RvY29sX3N0YXR1",
"cxgHIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xTdGF0dXMi",
"SAoTQ2xvc2VTZXNzaW9uUmVxdWVzdBISCgpzZXNzaW9uX2lkGAEgASgJEh0K",
"FWNsaWVudF9jb3JyZWxhdGlvbl9pZBgCIAEoCSKdAQoRQ2xvc2VTZXNzaW9u",
"UmVwbHkSEgoKc2Vzc2lvbl9pZBgBIAEoCRI2CgtmaW5hbF9zdGF0ZRgCIAEo",
"DjIhLm14YWNjZXNzX2dhdGV3YXkudjEuU2Vzc2lvblN0YXRlEjwKD3Byb3Rv",
"Y29sX3N0YXR1cxgDIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9j",
"b2xTdGF0dXMiSAoTU3RyZWFtRXZlbnRzUmVxdWVzdBISCgpzZXNzaW9uX2lk",
"GAEgASgJEh0KFWFmdGVyX3dvcmtlcl9zZXF1ZW5jZRgCIAEoBCJ2ChBNeENv",
"bW1hbmRSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSHQoVY2xpZW50X2Nv",
"cnJlbGF0aW9uX2lkGAIgASgJEi8KB2NvbW1hbmQYAyABKAsyHi5teGFjY2Vz",
"c19nYXRld2F5LnYxLk14Q29tbWFuZCKiDAoJTXhDb21tYW5kEjAKBGtpbmQY",
"ASABKA4yIi5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29tbWFuZEtpbmQSOAoI",
"cmVnaXN0ZXIYCiABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLlJlZ2lzdGVy",
"Q29tbWFuZEgAEjwKCnVucmVnaXN0ZXIYCyABKAsyJi5teGFjY2Vzc19nYXRl",
"d2F5LnYxLlVucmVnaXN0ZXJDb21tYW5kSAASNwoIYWRkX2l0ZW0YDCABKAsy",
"Iy5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW1Db21tYW5kSAASOQoJYWRk",
"X2l0ZW0yGA0gASgLMiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5BZGRJdGVtMkNv",
"bW1hbmRIABI9CgtyZW1vdmVfaXRlbRgOIAEoCzImLm14YWNjZXNzX2dhdGV3",
"YXkudjEuUmVtb3ZlSXRlbUNvbW1hbmRIABI0CgZhZHZpc2UYDyABKAsyIi5t",
"eGFjY2Vzc19nYXRld2F5LnYxLkFkdmlzZUNvbW1hbmRIABI5Cgl1bl9hZHZp",
"c2UYECABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLlVuQWR2aXNlQ29tbWFu",
"ZEgAEksKEmFkdmlzZV9zdXBlcnZpc29yeRgRIAEoCzItLm14YWNjZXNzX2dh",
"dGV3YXkudjEuQWR2aXNlU3VwZXJ2aXNvcnlDb21tYW5kSAASSAoRYWRkX2J1",
"ZmZlcmVkX2l0ZW0YEiABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEJ1",
"ZmZlcmVkSXRlbUNvbW1hbmRIABJdChxzZXRfYnVmZmVyZWRfdXBkYXRlX2lu",
"dGVydmFsGBMgASgLMjUubXhhY2Nlc3NfZ2F0ZXdheS52MS5TZXRCdWZmZXJl",
"ZFVwZGF0ZUludGVydmFsQ29tbWFuZEgAEjYKB3N1c3BlbmQYFCABKAsyIy5t",
"eGFjY2Vzc19nYXRld2F5LnYxLlN1c3BlbmRDb21tYW5kSAASOAoIYWN0aXZh",
"dGUYFSABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLkFjdGl2YXRlQ29tbWFu",
"ZEgAEjIKBXdyaXRlGBYgASgLMiEubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0",
"ZUNvbW1hbmRIABI0CgZ3cml0ZTIYFyABKAsyIi5teGFjY2Vzc19nYXRld2F5",
"LnYxLldyaXRlMkNvbW1hbmRIABJBCg13cml0ZV9zZWN1cmVkGBggASgLMigu",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZVNlY3VyZWRDb21tYW5kSAASQwoO",
"d3JpdGVfc2VjdXJlZDIYGSABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLldy",
"aXRlU2VjdXJlZDJDb21tYW5kSAASSQoRYXV0aGVudGljYXRlX3VzZXIYGiAB",
"KAsyLC5teGFjY2Vzc19nYXRld2F5LnYxLkF1dGhlbnRpY2F0ZVVzZXJDb21t",
"YW5kSAASTQoUYXJjaGVzdHJhX3VzZXJfdG9faWQYGyABKAsyLS5teGFjY2Vz",
"c19nYXRld2F5LnYxLkFyY2hlc3RyQVVzZXJUb0lkQ29tbWFuZEgAEjAKBHBp",
"bmcYZCABKAsyIC5teGFjY2Vzc19nYXRld2F5LnYxLlBpbmdDb21tYW5kSAAS",
"SAoRZ2V0X3Nlc3Npb25fc3RhdGUYZSABKAsyKy5teGFjY2Vzc19nYXRld2F5",
"LnYxLkdldFNlc3Npb25TdGF0ZUNvbW1hbmRIABJECg9nZXRfd29ya2VyX2lu",
"Zm8YZiABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLkdldFdvcmtlckluZm9D",
"b21tYW5kSAASPwoMZHJhaW5fZXZlbnRzGGcgASgLMicubXhhY2Nlc3NfZ2F0",
"ZXdheS52MS5EcmFpbkV2ZW50c0NvbW1hbmRIABJFCg9zaHV0ZG93bl93b3Jr",
"ZXIYaCABKAsyKi5teGFjY2Vzc19nYXRld2F5LnYxLlNodXRkb3duV29ya2Vy",
"Q29tbWFuZEgAQgkKB3BheWxvYWQiJgoPUmVnaXN0ZXJDb21tYW5kEhMKC2Ns",
"aWVudF9uYW1lGAEgASgJIioKEVVucmVnaXN0ZXJDb21tYW5kEhUKDXNlcnZl",
"cl9oYW5kbGUYASABKAUiQAoOQWRkSXRlbUNvbW1hbmQSFQoNc2VydmVyX2hh",
"bmRsZRgBIAEoBRIXCg9pdGVtX2RlZmluaXRpb24YAiABKAkiVwoPQWRkSXRl",
"bTJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSFwoPaXRlbV9kZWZp",
"bml0aW9uGAIgASgJEhQKDGl0ZW1fY29udGV4dBgDIAEoCSI/ChFSZW1vdmVJ",
"dGVtQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhMKC2l0ZW1faGFu",
"ZGxlGAIgASgFIjsKDUFkdmlzZUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgB",
"IAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSI9Cg9VbkFkdmlzZUNvbW1hbmQS",
"FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSJG",
"ChhBZHZpc2VTdXBlcnZpc29yeUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgB",
"IAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSJeChZBZGRCdWZmZXJlZEl0ZW1D",
"b21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSFwoPaXRlbV9kZWZpbml0",
"aW9uGAIgASgJEhQKDGl0ZW1fY29udGV4dBgDIAEoCSJfCiBTZXRCdWZmZXJl",
"ZFVwZGF0ZUludGVydmFsQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgF",
"EiQKHHVwZGF0ZV9pbnRlcnZhbF9taWxsaXNlY29uZHMYAiABKAUiPAoOU3Vz",
"cGVuZENvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hh",
"bmRsZRgCIAEoBSI9Cg9BY3RpdmF0ZUNvbW1hbmQSFQoNc2VydmVyX2hhbmRs",
"ZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSJ4CgxXcml0ZUNvbW1hbmQS",
"FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBRIr",
"CgV2YWx1ZRgDIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIP",
"Cgd1c2VyX2lkGAQgASgFIrABCg1Xcml0ZTJDb21tYW5kEhUKDXNlcnZlcl9o",
"cxgHIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xTdGF0dXMS",
"IAoYZ2F0ZXdheV9wcm90b2NvbF92ZXJzaW9uGAggASgNIkgKE0Nsb3NlU2Vz",
"c2lvblJlcXVlc3QSEgoKc2Vzc2lvbl9pZBgBIAEoCRIdChVjbGllbnRfY29y",
"cmVsYXRpb25faWQYAiABKAkinQEKEUNsb3NlU2Vzc2lvblJlcGx5EhIKCnNl",
"c3Npb25faWQYASABKAkSNgoLZmluYWxfc3RhdGUYAiABKA4yIS5teGFjY2Vz",
"c19nYXRld2F5LnYxLlNlc3Npb25TdGF0ZRI8Cg9wcm90b2NvbF9zdGF0dXMY",
"AyABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVzIkgK",
"E1N0cmVhbUV2ZW50c1JlcXVlc3QSEgoKc2Vzc2lvbl9pZBgBIAEoCRIdChVh",
"ZnRlcl93b3JrZXJfc2VxdWVuY2UYAiABKAQidgoQTXhDb21tYW5kUmVxdWVz",
"dBISCgpzZXNzaW9uX2lkGAEgASgJEh0KFWNsaWVudF9jb3JyZWxhdGlvbl9p",
"ZBgCIAEoCRIvCgdjb21tYW5kGAMgASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52",
"MS5NeENvbW1hbmQiogwKCU14Q29tbWFuZBIwCgRraW5kGAEgASgOMiIubXhh",
"Y2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRLaW5kEjgKCHJlZ2lzdGVyGAog",
"ASgLMiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5SZWdpc3RlckNvbW1hbmRIABI8",
"Cgp1bnJlZ2lzdGVyGAsgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5VbnJl",
"Z2lzdGVyQ29tbWFuZEgAEjcKCGFkZF9pdGVtGAwgASgLMiMubXhhY2Nlc3Nf",
"Z2F0ZXdheS52MS5BZGRJdGVtQ29tbWFuZEgAEjkKCWFkZF9pdGVtMhgNIAEo",
"CzIkLm14YWNjZXNzX2dhdGV3YXkudjEuQWRkSXRlbTJDb21tYW5kSAASPQoL",
"cmVtb3ZlX2l0ZW0YDiABKAsyJi5teGFjY2Vzc19nYXRld2F5LnYxLlJlbW92",
"ZUl0ZW1Db21tYW5kSAASNAoGYWR2aXNlGA8gASgLMiIubXhhY2Nlc3NfZ2F0",
"ZXdheS52MS5BZHZpc2VDb21tYW5kSAASOQoJdW5fYWR2aXNlGBAgASgLMiQu",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5VbkFkdmlzZUNvbW1hbmRIABJLChJhZHZp",
"c2Vfc3VwZXJ2aXNvcnkYESABKAsyLS5teGFjY2Vzc19nYXRld2F5LnYxLkFk",
"dmlzZVN1cGVydmlzb3J5Q29tbWFuZEgAEkgKEWFkZF9idWZmZXJlZF9pdGVt",
"GBIgASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5BZGRCdWZmZXJlZEl0ZW1D",
"b21tYW5kSAASXQocc2V0X2J1ZmZlcmVkX3VwZGF0ZV9pbnRlcnZhbBgTIAEo",
"CzI1Lm14YWNjZXNzX2dhdGV3YXkudjEuU2V0QnVmZmVyZWRVcGRhdGVJbnRl",
"cnZhbENvbW1hbmRIABI2CgdzdXNwZW5kGBQgASgLMiMubXhhY2Nlc3NfZ2F0",
"ZXdheS52MS5TdXNwZW5kQ29tbWFuZEgAEjgKCGFjdGl2YXRlGBUgASgLMiQu",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmF0ZUNvbW1hbmRIABIyCgV3cml0",
"ZRgWIAEoCzIhLm14YWNjZXNzX2dhdGV3YXkudjEuV3JpdGVDb21tYW5kSAAS",
"NAoGd3JpdGUyGBcgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZTJD",
"b21tYW5kSAASQQoNd3JpdGVfc2VjdXJlZBgYIAEoCzIoLm14YWNjZXNzX2dh",
"dGV3YXkudjEuV3JpdGVTZWN1cmVkQ29tbWFuZEgAEkMKDndyaXRlX3NlY3Vy",
"ZWQyGBkgASgLMikubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZVNlY3VyZWQy",
"Q29tbWFuZEgAEkkKEWF1dGhlbnRpY2F0ZV91c2VyGBogASgLMiwubXhhY2Nl",
"c3NfZ2F0ZXdheS52MS5BdXRoZW50aWNhdGVVc2VyQ29tbWFuZEgAEk0KFGFy",
"Y2hlc3RyYV91c2VyX3RvX2lkGBsgASgLMi0ubXhhY2Nlc3NfZ2F0ZXdheS52",
"MS5BcmNoZXN0ckFVc2VyVG9JZENvbW1hbmRIABIwCgRwaW5nGGQgASgLMiAu",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5QaW5nQ29tbWFuZEgAEkgKEWdldF9zZXNz",
"aW9uX3N0YXRlGGUgASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5HZXRTZXNz",
"aW9uU3RhdGVDb21tYW5kSAASRAoPZ2V0X3dvcmtlcl9pbmZvGGYgASgLMiku",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5HZXRXb3JrZXJJbmZvQ29tbWFuZEgAEj8K",
"DGRyYWluX2V2ZW50cxhnIAEoCzInLm14YWNjZXNzX2dhdGV3YXkudjEuRHJh",
"aW5FdmVudHNDb21tYW5kSAASRQoPc2h1dGRvd25fd29ya2VyGGggASgLMiou",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5TaHV0ZG93bldvcmtlckNvbW1hbmRIAEIJ",
"CgdwYXlsb2FkIiYKD1JlZ2lzdGVyQ29tbWFuZBITCgtjbGllbnRfbmFtZRgB",
"IAEoCSIqChFVbnJlZ2lzdGVyQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEg",
"ASgFIkAKDkFkZEl0ZW1Db21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUS",
"FwoPaXRlbV9kZWZpbml0aW9uGAIgASgJIlcKD0FkZEl0ZW0yQ29tbWFuZBIV",
"Cg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhcKD2l0ZW1fZGVmaW5pdGlvbhgCIAEo",
"CRIUCgxpdGVtX2NvbnRleHQYAyABKAkiPwoRUmVtb3ZlSXRlbUNvbW1hbmQS",
"FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSI7",
"Cg1BZHZpc2VDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRl",
"bV9oYW5kbGUYAiABKAUiPQoPVW5BZHZpc2VDb21tYW5kEhUKDXNlcnZlcl9o",
"YW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUiRgoYQWR2aXNlU3Vw",
"ZXJ2aXNvcnlDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRl",
"bV9oYW5kbGUYAiABKAUiXgoWQWRkQnVmZmVyZWRJdGVtQ29tbWFuZBIVCg1z",
"ZXJ2ZXJfaGFuZGxlGAEgASgFEhcKD2l0ZW1fZGVmaW5pdGlvbhgCIAEoCRIU",
"CgxpdGVtX2NvbnRleHQYAyABKAkiXwogU2V0QnVmZmVyZWRVcGRhdGVJbnRl",
"cnZhbENvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIkChx1cGRhdGVf",
"aW50ZXJ2YWxfbWlsbGlzZWNvbmRzGAIgASgFIjwKDlN1c3BlbmRDb21tYW5k",
"EhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUi",
"PQoPQWN0aXZhdGVDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoL",
"aXRlbV9oYW5kbGUYAiABKAUieAoMV3JpdGVDb21tYW5kEhUKDXNlcnZlcl9o",
"YW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUSKwoFdmFsdWUYAyAB",
"KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSNQoPdGltZXN0YW1w",
"X3ZhbHVlGAQgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEg8K",
"B3VzZXJfaWQYBSABKAUioQEKE1dyaXRlU2VjdXJlZENvbW1hbmQSFQoNc2Vy",
"dmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBRIXCg9jdXJy",
"ZW50X3VzZXJfaWQYAyABKAUSGAoQdmVyaWZpZXJfdXNlcl9pZBgEIAEoBRIr",
"CgV2YWx1ZRgFIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZSLZ",
"AQoUV3JpdGVTZWN1cmVkMkNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEo",
"BRITCgtpdGVtX2hhbmRsZRgCIAEoBRIXCg9jdXJyZW50X3VzZXJfaWQYAyAB",
"KAUSGAoQdmVyaWZpZXJfdXNlcl9pZBgEIAEoBRIrCgV2YWx1ZRgFIAEoCzIc",
"Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRI1Cg90aW1lc3RhbXBfdmFs",
"dWUYBiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUiYwoXQXV0",
"aGVudGljYXRlVXNlckNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIT",
"Cgt2ZXJpZnlfdXNlchgCIAEoCRIcChR2ZXJpZnlfdXNlcl9wYXNzd29yZBgD",
"IAEoCSJHChhBcmNoZXN0ckFVc2VyVG9JZENvbW1hbmQSFQoNc2VydmVyX2hh",
"bmRsZRgBIAEoBRIUCgx1c2VyX2lkX2d1aWQYAiABKAkiHgoLUGluZ0NvbW1h",
"bmQSDwoHbWVzc2FnZRgBIAEoCSIYChZHZXRTZXNzaW9uU3RhdGVDb21tYW5k",
"IhYKFEdldFdvcmtlckluZm9Db21tYW5kIigKEkRyYWluRXZlbnRzQ29tbWFu",
"ZBISCgptYXhfZXZlbnRzGAEgASgNIkgKFVNodXRkb3duV29ya2VyQ29tbWFu",
"ZBIvCgxncmFjZV9wZXJpb2QYASABKAsyGS5nb29nbGUucHJvdG9idWYuRHVy",
"YXRpb24ikAgKDk14Q29tbWFuZFJlcGx5EhIKCnNlc3Npb25faWQYASABKAkS",
"FgoOY29ycmVsYXRpb25faWQYAiABKAkSMAoEa2luZBgDIAEoDjIiLm14YWNj",
"ZXNzX2dhdGV3YXkudjEuTXhDb21tYW5kS2luZBI8Cg9wcm90b2NvbF9zdGF0",
"dXMYBCABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVz",
"EhQKB2hyZXN1bHQYBSABKAVIAYgBARIyCgxyZXR1cm5fdmFsdWUYBiABKAsy",
"HC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSNAoIc3RhdHVzZXMYByAD",
"KAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkSGgoSZGlh",
"Z25vc3RpY19tZXNzYWdlGAggASgJEjYKCHJlZ2lzdGVyGBQgASgLMiIubXhh",
"Y2Nlc3NfZ2F0ZXdheS52MS5SZWdpc3RlclJlcGx5SAASNQoIYWRkX2l0ZW0Y",
"FSABKAsyIS5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW1SZXBseUgAEjcK",
"CWFkZF9pdGVtMhgWIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuQWRkSXRl",
"bTJSZXBseUgAEkYKEWFkZF9idWZmZXJlZF9pdGVtGBcgASgLMikubXhhY2Nl",
"c3NfZ2F0ZXdheS52MS5BZGRCdWZmZXJlZEl0ZW1SZXBseUgAEjQKB3N1c3Bl",
"bmQYGCABKAsyIS5teGFjY2Vzc19nYXRld2F5LnYxLlN1c3BlbmRSZXBseUgA",
"EjYKCGFjdGl2YXRlGBkgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3Rp",
"dmF0ZVJlcGx5SAASRwoRYXV0aGVudGljYXRlX3VzZXIYGiABKAsyKi5teGFj",
"Y2Vzc19nYXRld2F5LnYxLkF1dGhlbnRpY2F0ZVVzZXJSZXBseUgAEksKFGFy",
"Y2hlc3RyYV91c2VyX3RvX2lkGBsgASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52",
"MS5BcmNoZXN0ckFVc2VyVG9JZFJlcGx5SAASPwoNc2Vzc2lvbl9zdGF0ZRhk",
"IAEoCzImLm14YWNjZXNzX2dhdGV3YXkudjEuU2Vzc2lvblN0YXRlUmVwbHlI",
"ABI7Cgt3b3JrZXJfaW5mbxhlIAEoCzIkLm14YWNjZXNzX2dhdGV3YXkudjEu",
"V29ya2VySW5mb1JlcGx5SAASPQoMZHJhaW5fZXZlbnRzGGYgASgLMiUubXhh",
"Y2Nlc3NfZ2F0ZXdheS52MS5EcmFpbkV2ZW50c1JlcGx5SABCCQoHcGF5bG9h",
"ZEIKCghfaHJlc3VsdCImCg1SZWdpc3RlclJlcGx5EhUKDXNlcnZlcl9oYW5k",
"bGUYASABKAUiIwoMQWRkSXRlbVJlcGx5EhMKC2l0ZW1faGFuZGxlGAEgASgF",
"IiQKDUFkZEl0ZW0yUmVwbHkSEwoLaXRlbV9oYW5kbGUYASABKAUiKwoUQWRk",
"QnVmZmVyZWRJdGVtUmVwbHkSEwoLaXRlbV9oYW5kbGUYASABKAUiQgoMU3Vz",
"cGVuZFJlcGx5EjIKBnN0YXR1cxgBIAEoCzIiLm14YWNjZXNzX2dhdGV3YXku",
"djEuTXhTdGF0dXNQcm94eSJDCg1BY3RpdmF0ZVJlcGx5EjIKBnN0YXR1cxgB",
"IAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQcm94eSIoChVB",
"dXRoZW50aWNhdGVVc2VyUmVwbHkSDwoHdXNlcl9pZBgBIAEoBSIpChZBcmNo",
"ZXN0ckFVc2VyVG9JZFJlcGx5Eg8KB3VzZXJfaWQYASABKAUiRQoRU2Vzc2lv",
"blN0YXRlUmVwbHkSMAoFc3RhdGUYASABKA4yIS5teGFjY2Vzc19nYXRld2F5",
"LnYxLlNlc3Npb25TdGF0ZSJ1Cg9Xb3JrZXJJbmZvUmVwbHkSGQoRd29ya2Vy",
"X3Byb2Nlc3NfaWQYASABKAUSFgoOd29ya2VyX3ZlcnNpb24YAiABKAkSFwoP",
"bXhhY2Nlc3NfcHJvZ2lkGAMgASgJEhYKDm14YWNjZXNzX2Nsc2lkGAQgASgJ",
"IkAKEERyYWluRXZlbnRzUmVwbHkSLAoGZXZlbnRzGAEgAygLMhwubXhhY2Nl",
"c3NfZ2F0ZXdheS52MS5NeEV2ZW50IpsGCgdNeEV2ZW50EjIKBmZhbWlseRgB",
"IAEoDjIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudEZhbWlseRISCgpz",
"ZXNzaW9uX2lkGAIgASgJEhUKDXNlcnZlcl9oYW5kbGUYAyABKAUSEwoLaXRl",
"bV9oYW5kbGUYBCABKAUSKwoFdmFsdWUYBSABKAsyHC5teGFjY2Vzc19nYXRl",
"d2F5LnYxLk14VmFsdWUSDwoHcXVhbGl0eRgGIAEoBRI0ChBzb3VyY2VfdGlt",
"ZXN0YW1wGAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI0Cghz",
"dGF0dXNlcxgIIAMoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQ",
"cm94eRIXCg93b3JrZXJfc2VxdWVuY2UYCSABKAQSNAoQd29ya2VyX3RpbWVz",
"dGFtcBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASPQoZZ2F0",
"ZXdheV9yZWNlaXZlX3RpbWVzdGFtcBgLIAEoCzIaLmdvb2dsZS5wcm90b2J1",
"Zi5UaW1lc3RhbXASFAoHaHJlc3VsdBgMIAEoBUgBiAEBEhIKCnJhd19zdGF0",
"dXMYDSABKAkSQAoOb25fZGF0YV9jaGFuZ2UYFCABKAsyJi5teGFjY2Vzc19n",
"YXRld2F5LnYxLk9uRGF0YUNoYW5nZUV2ZW50SAASRgoRb25fd3JpdGVfY29t",
"cGxldGUYFSABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLk9uV3JpdGVDb21w",
"bGV0ZUV2ZW50SAASSQoSb3BlcmF0aW9uX2NvbXBsZXRlGBYgASgLMisubXhh",
"Y2Nlc3NfZ2F0ZXdheS52MS5PcGVyYXRpb25Db21wbGV0ZUV2ZW50SAASUQoX",
"b25fYnVmZmVyZWRfZGF0YV9jaGFuZ2UYFyABKAsyLi5teGFjY2Vzc19nYXRl",
"d2F5LnYxLk9uQnVmZmVyZWREYXRhQ2hhbmdlRXZlbnRIAEIGCgRib2R5QgoK",
"CF9ocmVzdWx0IhMKEU9uRGF0YUNoYW5nZUV2ZW50IhYKFE9uV3JpdGVDb21w",
"bGV0ZUV2ZW50IhgKFk9wZXJhdGlvbkNvbXBsZXRlRXZlbnQi1AEKGU9uQnVm",
"ZmVyZWREYXRhQ2hhbmdlRXZlbnQSMgoJZGF0YV90eXBlGAEgASgOMh8ubXhh",
"Y2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEjQKDnF1YWxpdHlfdmFsdWVz",
"GAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5EjYKEHRpbWVz",
"dGFtcF92YWx1ZXMYAyABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14QXJy",
"YXkSFQoNcmF3X2RhdGFfdHlwZRgEIAEoBSLrAQoNTXhTdGF0dXNQcm94eRIP",
"CgdzdWNjZXNzGAEgASgFEjcKCGNhdGVnb3J5GAIgASgOMiUubXhhY2Nlc3Nf",
"Z2F0ZXdheS52MS5NeFN0YXR1c0NhdGVnb3J5EjgKC2RldGVjdGVkX2J5GAMg",
"ASgOMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1NvdXJjZRIOCgZk",
"ZXRhaWwYBCABKAUSFAoMcmF3X2NhdGVnb3J5GAUgASgFEhcKD3Jhd19kZXRl",
"Y3RlZF9ieRgGIAEoBRIXCg9kaWFnbm9zdGljX3RleHQYByABKAkipwMKB014",
"VmFsdWUSMgoJZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52",
"MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRIPCgdpc19udWxs",
"GAMgASgIEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEhUKDXJhd19kYXRhX3R5",
"cGUYBSABKAUSFAoKYm9vbF92YWx1ZRgKIAEoCEgAEhUKC2ludDMyX3ZhbHVl",
"GAsgASgFSAASFQoLaW50NjRfdmFsdWUYDCABKANIABIVCgtmbG9hdF92YWx1",
"ZRgNIAEoAkgAEhYKDGRvdWJsZV92YWx1ZRgOIAEoAUgAEhYKDHN0cmluZ192",
"YWx1ZRgPIAEoCUgAEjUKD3RpbWVzdGFtcF92YWx1ZRgQIAEoCzIaLmdvb2ds",
"ZS5wcm90b2J1Zi5UaW1lc3RhbXBIABIzCgthcnJheV92YWx1ZRgRIAEoCzIc",
"Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheUgAEhMKCXJhd192YWx1ZRgS",
"IAEoDEgAQgYKBGtpbmQi/gQKB014QXJyYXkSOgoRZWxlbWVudF9kYXRhX3R5",
"cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUSFAoM",
"dmFyaWFudF90eXBlGAIgASgJEhIKCmRpbWVuc2lvbnMYAyADKA0SFgoOcmF3",
"X2RpYWdub3N0aWMYBCABKAkSHQoVcmF3X2VsZW1lbnRfZGF0YV90eXBlGAUg",
"ASgFEjUKC2Jvb2xfdmFsdWVzGAogASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52",
"MS5Cb29sQXJyYXlIABI3CgxpbnQzMl92YWx1ZXMYCyABKAsyHy5teGFjY2Vz",
"c19nYXRld2F5LnYxLkludDMyQXJyYXlIABI3CgxpbnQ2NF92YWx1ZXMYDCAB",
"KAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkludDY0QXJyYXlIABI3CgxmbG9h",
"dF92YWx1ZXMYDSABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkZsb2F0QXJy",
"YXlIABI5Cg1kb3VibGVfdmFsdWVzGA4gASgLMiAubXhhY2Nlc3NfZ2F0ZXdh",
"eS52MS5Eb3VibGVBcnJheUgAEjkKDXN0cmluZ192YWx1ZXMYDyABKAsyIC5t",
"eGFjY2Vzc19nYXRld2F5LnYxLlN0cmluZ0FycmF5SAASPwoQdGltZXN0YW1w",
"X3ZhbHVlcxgQIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuVGltZXN0YW1w",
"QXJyYXlIABIzCgpyYXdfdmFsdWVzGBEgASgLMh0ubXhhY2Nlc3NfZ2F0ZXdh",
"eS52MS5SYXdBcnJheUgAQggKBnZhbHVlcyIbCglCb29sQXJyYXkSDgoGdmFs",
"dWVzGAEgAygIIhwKCkludDMyQXJyYXkSDgoGdmFsdWVzGAEgAygFIhwKCklu",
"dDY0QXJyYXkSDgoGdmFsdWVzGAEgAygDIhwKCkZsb2F0QXJyYXkSDgoGdmFs",
"dWVzGAEgAygCIh0KC0RvdWJsZUFycmF5Eg4KBnZhbHVlcxgBIAMoASIdCgtT",
"dHJpbmdBcnJheRIOCgZ2YWx1ZXMYASADKAkiPAoOVGltZXN0YW1wQXJyYXkS",
"KgoGdmFsdWVzGAEgAygLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIa",
"CghSYXdBcnJheRIOCgZ2YWx1ZXMYASADKAwiWAoOUHJvdG9jb2xTdGF0dXMS",
"NQoEY29kZRgBIAEoDjInLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xT",
"dGF0dXNDb2RlEg8KB21lc3NhZ2UYAiABKAkqvwYKDU14Q29tbWFuZEtpbmQS",
"HwobTVhfQ09NTUFORF9LSU5EX1VOU1BFQ0lGSUVEEAASHAoYTVhfQ09NTUFO",
"RF9LSU5EX1JFR0lTVEVSEAESHgoaTVhfQ09NTUFORF9LSU5EX1VOUkVHSVNU",
"RVIQAhIcChhNWF9DT01NQU5EX0tJTkRfQUREX0lURU0QAxIdChlNWF9DT01N",
"QU5EX0tJTkRfQUREX0lURU0yEAQSHwobTVhfQ09NTUFORF9LSU5EX1JFTU9W",
"RV9JVEVNEAUSGgoWTVhfQ09NTUFORF9LSU5EX0FEVklTRRAGEh0KGU1YX0NP",
"TU1BTkRfS0lORF9VTl9BRFZJU0UQBxImCiJNWF9DT01NQU5EX0tJTkRfQURW",
"SVNFX1NVUEVSVklTT1JZEAgSJQohTVhfQ09NTUFORF9LSU5EX0FERF9CVUZG",
"RVJFRF9JVEVNEAkSMAosTVhfQ09NTUFORF9LSU5EX1NFVF9CVUZGRVJFRF9V",
"UERBVEVfSU5URVJWQUwQChIbChdNWF9DT01NQU5EX0tJTkRfU1VTUEVORBAL",
"EhwKGE1YX0NPTU1BTkRfS0lORF9BQ1RJVkFURRAMEhkKFU1YX0NPTU1BTkRf",
"S0lORF9XUklURRANEhoKFk1YX0NPTU1BTkRfS0lORF9XUklURTIQDhIhCh1N",
"WF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRBAPEiIKHk1YX0NPTU1BTkRf",
"S0lORF9XUklURV9TRUNVUkVEMhAQEiUKIU1YX0NPTU1BTkRfS0lORF9BVVRI",
"RU5USUNBVEVfVVNFUhAREigKJE1YX0NPTU1BTkRfS0lORF9BUkNIRVNUUkFf",
"VVNFUl9UT19JRBASEhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQohTVhf",
"Q09NTUFORF9LSU5EX0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09NTUFO",
"RF9LSU5EX0dFVF9XT1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lORF9E",
"UkFJTl9FVkVOVFMQZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05fV09S",
"S0VSEGgq0AEKDU14RXZlbnRGYW1pbHkSHwobTVhfRVZFTlRfRkFNSUxZX1VO",
"U1BFQ0lGSUVEEAASIgoeTVhfRVZFTlRfRkFNSUxZX09OX0RBVEFfQ0hBTkdF",
"EAESJQohTVhfRVZFTlRfRkFNSUxZX09OX1dSSVRFX0NPTVBMRVRFEAISJgoi",
"TVhfRVZFTlRfRkFNSUxZX09QRVJBVElPTl9DT01QTEVURRADEisKJ01YX0VW",
"RU5UX0ZBTUlMWV9PTl9CVUZGRVJFRF9EQVRBX0NIQU5HRRAEKqUDChBNeFN0",
"YXR1c0NhdGVnb3J5EiIKHk1YX1NUQVRVU19DQVRFR09SWV9VTlNQRUNJRklF",
"RBAAEh4KGk1YX1NUQVRVU19DQVRFR09SWV9VTktOT1dOEAESGQoVTVhfU1RB",
"VFVTX0NBVEVHT1JZX09LEAISHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1BFTkRJ",
"TkcQAxIeChpNWF9TVEFUVVNfQ0FURUdPUllfV0FSTklORxAEEioKJk1YX1NU",
"QVRVU19DQVRFR09SWV9DT01NVU5JQ0FUSU9OX0VSUk9SEAUSKgomTVhfU1RB",
"VFVTX0NBVEVHT1JZX0NPTkZJR1VSQVRJT05fRVJST1IQBhIoCiRNWF9TVEFU",
"VVNfQ0FURUdPUllfT1BFUkFUSU9OQUxfRVJST1IQBxIlCiFNWF9TVEFUVVNf",
"Q0FURUdPUllfU0VDVVJJVFlfRVJST1IQCBIlCiFNWF9TVEFUVVNfQ0FURUdP",
"UllfU09GVFdBUkVfRVJST1IQCRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfT1RI",
"RVJfRVJST1IQCirKAgoOTXhTdGF0dXNTb3VyY2USIAocTVhfU1RBVFVTX1NP",
"VVJDRV9VTlNQRUNJRklFRBAAEhwKGE1YX1NUQVRVU19TT1VSQ0VfVU5LTk9X",
"ThABEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVRVUVTVElOR19MTVgQAhIjCh9N",
"WF9TVEFUVVNfU09VUkNFX1JFU1BPTkRJTkdfTE1YEAMSIwofTVhfU1RBVFVT",
"X1NPVVJDRV9SRVFVRVNUSU5HX05NWBAEEiMKH01YX1NUQVRVU19TT1VSQ0Vf",
"UkVTUE9ORElOR19OTVgQBRIxCi1NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJ",
"TkdfQVVUT01BVElPTl9PQkpFQ1QQBhIxCi1NWF9TVEFUVVNfU09VUkNFX1JF",
"U1BPTkRJTkdfQVVUT01BVElPTl9PQkpFQ1QQByrdBAoKTXhEYXRhVHlwZRIc",
"ChhNWF9EQVRBX1RZUEVfVU5TUEVDSUZJRUQQABIYChRNWF9EQVRBX1RZUEVf",
"VU5LTk9XThABEhgKFE1YX0RBVEFfVFlQRV9OT19EQVRBEAISGAoUTVhfREFU",
"QV9UWVBFX0JPT0xFQU4QAxIYChRNWF9EQVRBX1RZUEVfSU5URUdFUhAEEhYK",
"Ek1YX0RBVEFfVFlQRV9GTE9BVBAFEhcKE01YX0RBVEFfVFlQRV9ET1VCTEUQ",
"BhIXChNNWF9EQVRBX1RZUEVfU1RSSU5HEAcSFQoRTVhfREFUQV9UWVBFX1RJ",
"TUUQCBIdChlNWF9EQVRBX1RZUEVfRUxBUFNFRF9USU1FEAkSHwobTVhfREFU",
"QV9UWVBFX1JFRkVSRU5DRV9UWVBFEAoSHAoYTVhfREFUQV9UWVBFX1NUQVRV",
"U19UWVBFEAsSFQoRTVhfREFUQV9UWVBFX0VOVU0QDBItCilNWF9EQVRBX1RZ",
"UEVfU0VDVVJJVFlfQ0xBU1NJRklDQVRJT05fRU5VTRANEiIKHk1YX0RBVEFf",
"VFlQRV9EQVRBX1FVQUxJVFlfVFlQRRAOEh8KG01YX0RBVEFfVFlQRV9RVUFM",
"SUZJRURfRU5VTRAPEiEKHU1YX0RBVEFfVFlQRV9RVUFMSUZJRURfU1RSVUNU",
"EBASKQolTVhfREFUQV9UWVBFX0lOVEVSTkFUSU9OQUxJWkVEX1NUUklORxAR",
"EhsKF01YX0RBVEFfVFlQRV9CSUdfU1RSSU5HEBISFAoQTVhfREFUQV9UWVBF",
"X0VORBATKqMDChJQcm90b2NvbFN0YXR1c0NvZGUSJAogUFJPVE9DT0xfU1RB",
"VFVTX0NPREVfVU5TUEVDSUZJRUQQABIbChdQUk9UT0NPTF9TVEFUVVNfQ09E",
"RV9PSxABEigKJFBST1RPQ09MX1NUQVRVU19DT0RFX0lOVkFMSURfUkVRVUVT",
"VBACEioKJlBST1RPQ09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX0ZPVU5E",
"EAMSKgomUFJPVE9DT0xfU1RBVFVTX0NPREVfU0VTU0lPTl9OT1RfUkVBRFkQ",
"BBIrCidQUk9UT0NPTF9TVEFUVVNfQ09ERV9XT1JLRVJfVU5BVkFJTEFCTEUQ",
"BRIgChxQUk9UT0NPTF9TVEFUVVNfQ09ERV9USU1FT1VUEAYSIQodUFJPVE9D",
"T0xfU1RBVFVTX0NPREVfQ0FOQ0VMRUQQBxIrCidQUk9UT0NPTF9TVEFUVVNf",
"Q09ERV9QUk9UT0NPTF9WSU9MQVRJT04QCBIpCiVQUk9UT0NPTF9TVEFUVVNf",
"Q09ERV9NWEFDQ0VTU19GQUlMVVJFEAkqvwIKDFNlc3Npb25TdGF0ZRIdChlT",
"RVNTSU9OX1NUQVRFX1VOU1BFQ0lGSUVEEAASGgoWU0VTU0lPTl9TVEFURV9D",
"UkVBVElORxABEiEKHVNFU1NJT05fU1RBVEVfU1RBUlRJTkdfV09SS0VSEAIS",
"IgoeU0VTU0lPTl9TVEFURV9XQUlUSU5HX0ZPUl9QSVBFEAMSHQoZU0VTU0lP",
"Tl9TVEFURV9IQU5EU0hBS0lORxAEEiUKIVNFU1NJT05fU1RBVEVfSU5JVElB",
"TElaSU5HX1dPUktFUhAFEhcKE1NFU1NJT05fU1RBVEVfUkVBRFkQBhIZChVT",
"RVNTSU9OX1NUQVRFX0NMT1NJTkcQBxIYChRTRVNTSU9OX1NUQVRFX0NMT1NF",
"RBAIEhkKFVNFU1NJT05fU1RBVEVfRkFVTFRFRBAJMoIDCg9NeEFjY2Vzc0dh",
"dGV3YXkSXQoLT3BlblNlc3Npb24SJy5teGFjY2Vzc19nYXRld2F5LnYxLk9w",
"ZW5TZXNzaW9uUmVxdWVzdBolLm14YWNjZXNzX2dhdGV3YXkudjEuT3BlblNl",
"c3Npb25SZXBseRJgCgxDbG9zZVNlc3Npb24SKC5teGFjY2Vzc19nYXRld2F5",
"LnYxLkNsb3NlU2Vzc2lvblJlcXVlc3QaJi5teGFjY2Vzc19nYXRld2F5LnYx",
"LkNsb3NlU2Vzc2lvblJlcGx5ElQKBkludm9rZRIlLm14YWNjZXNzX2dhdGV3",
"YXkudjEuTXhDb21tYW5kUmVxdWVzdBojLm14YWNjZXNzX2dhdGV3YXkudjEu",
"TXhDb21tYW5kUmVwbHkSWAoMU3RyZWFtRXZlbnRzEigubXhhY2Nlc3NfZ2F0",
"ZXdheS52MS5TdHJlYW1FdmVudHNSZXF1ZXN0GhwubXhhY2Nlc3NfZ2F0ZXdh",
"eS52MS5NeEV2ZW50MAFCHKoCGU14R2F0ZXdheS5Db250cmFjdHMuUHJvdG9i",
"BnByb3RvMw=="));
"KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoHdXNlcl9pZBgE",
"IAEoBSKwAQoNV3JpdGUyQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgF",
"EhMKC2l0ZW1faGFuZGxlGAIgASgFEisKBXZhbHVlGAMgASgLMhwubXhhY2Nl",
"c3NfZ2F0ZXdheS52MS5NeFZhbHVlEjUKD3RpbWVzdGFtcF92YWx1ZRgEIAEo",
"CzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIPCgd1c2VyX2lkGAUg",
"ASgFIqEBChNXcml0ZVNlY3VyZWRDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUY",
"ASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUSFwoPY3VycmVudF91c2VyX2lk",
"GAMgASgFEhgKEHZlcmlmaWVyX3VzZXJfaWQYBCABKAUSKwoFdmFsdWUYBSAB",
"KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUi2QEKFFdyaXRlU2Vj",
"dXJlZDJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9o",
"YW5kbGUYAiABKAUSFwoPY3VycmVudF91c2VyX2lkGAMgASgFEhgKEHZlcmlm",
"aWVyX3VzZXJfaWQYBCABKAUSKwoFdmFsdWUYBSABKAsyHC5teGFjY2Vzc19n",
"YXRld2F5LnYxLk14VmFsdWUSNQoPdGltZXN0YW1wX3ZhbHVlGAYgASgLMhwu",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlImMKF0F1dGhlbnRpY2F0ZVVz",
"ZXJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLdmVyaWZ5X3Vz",
"ZXIYAiABKAkSHAoUdmVyaWZ5X3VzZXJfcGFzc3dvcmQYAyABKAkiRwoYQXJj",
"aGVzdHJBVXNlclRvSWRDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUS",
"FAoMdXNlcl9pZF9ndWlkGAIgASgJIh4KC1BpbmdDb21tYW5kEg8KB21lc3Nh",
"Z2UYASABKAkiGAoWR2V0U2Vzc2lvblN0YXRlQ29tbWFuZCIWChRHZXRXb3Jr",
"ZXJJbmZvQ29tbWFuZCIoChJEcmFpbkV2ZW50c0NvbW1hbmQSEgoKbWF4X2V2",
"ZW50cxgBIAEoDSJIChVTaHV0ZG93bldvcmtlckNvbW1hbmQSLwoMZ3JhY2Vf",
"cGVyaW9kGAEgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIpAICg5N",
"eENvbW1hbmRSZXBseRISCgpzZXNzaW9uX2lkGAEgASgJEhYKDmNvcnJlbGF0",
"aW9uX2lkGAIgASgJEjAKBGtpbmQYAyABKA4yIi5teGFjY2Vzc19nYXRld2F5",
"LnYxLk14Q29tbWFuZEtpbmQSPAoPcHJvdG9jb2xfc3RhdHVzGAQgASgLMiMu",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1cxIUCgdocmVzdWx0",
"GAUgASgFSAGIAQESMgoMcmV0dXJuX3ZhbHVlGAYgASgLMhwubXhhY2Nlc3Nf",
"Z2F0ZXdheS52MS5NeFZhbHVlEjQKCHN0YXR1c2VzGAcgAygLMiIubXhhY2Nl",
"c3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0aWNfbWVz",
"c2FnZRgIIAEoCRI2CghyZWdpc3RlchgUIAEoCzIiLm14YWNjZXNzX2dhdGV3",
"YXkudjEuUmVnaXN0ZXJSZXBseUgAEjUKCGFkZF9pdGVtGBUgASgLMiEubXhh",
"Y2Nlc3NfZ2F0ZXdheS52MS5BZGRJdGVtUmVwbHlIABI3CglhZGRfaXRlbTIY",
"FiABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW0yUmVwbHlIABJG",
"ChFhZGRfYnVmZmVyZWRfaXRlbRgXIAEoCzIpLm14YWNjZXNzX2dhdGV3YXku",
"djEuQWRkQnVmZmVyZWRJdGVtUmVwbHlIABI0CgdzdXNwZW5kGBggASgLMiEu",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5TdXNwZW5kUmVwbHlIABI2CghhY3RpdmF0",
"ZRgZIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuQWN0aXZhdGVSZXBseUgA",
"EkcKEWF1dGhlbnRpY2F0ZV91c2VyGBogASgLMioubXhhY2Nlc3NfZ2F0ZXdh",
"eS52MS5BdXRoZW50aWNhdGVVc2VyUmVwbHlIABJLChRhcmNoZXN0cmFfdXNl",
"cl90b19pZBgbIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuQXJjaGVzdHJB",
"VXNlclRvSWRSZXBseUgAEj8KDXNlc3Npb25fc3RhdGUYZCABKAsyJi5teGFj",
"Y2Vzc19nYXRld2F5LnYxLlNlc3Npb25TdGF0ZVJlcGx5SAASOwoLd29ya2Vy",
"X2luZm8YZSABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLldvcmtlckluZm9S",
"ZXBseUgAEj0KDGRyYWluX2V2ZW50cxhmIAEoCzIlLm14YWNjZXNzX2dhdGV3",
"YXkudjEuRHJhaW5FdmVudHNSZXBseUgAQgkKB3BheWxvYWRCCgoIX2hyZXN1",
"bHQiJgoNUmVnaXN0ZXJSZXBseRIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFIiMK",
"DEFkZEl0ZW1SZXBseRITCgtpdGVtX2hhbmRsZRgBIAEoBSIkCg1BZGRJdGVt",
"MlJlcGx5EhMKC2l0ZW1faGFuZGxlGAEgASgFIisKFEFkZEJ1ZmZlcmVkSXRl",
"bVJlcGx5EhMKC2l0ZW1faGFuZGxlGAEgASgFIkIKDFN1c3BlbmRSZXBseRIy",
"CgZzdGF0dXMYASABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVz",
"UHJveHkiQwoNQWN0aXZhdGVSZXBseRIyCgZzdGF0dXMYASABKAsyIi5teGFj",
"Y2Vzc19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkiKAoVQXV0aGVudGljYXRl",
"VXNlclJlcGx5Eg8KB3VzZXJfaWQYASABKAUiKQoWQXJjaGVzdHJBVXNlclRv",
"SWRSZXBseRIPCgd1c2VyX2lkGAEgASgFIkUKEVNlc3Npb25TdGF0ZVJlcGx5",
"EjAKBXN0YXRlGAEgASgOMiEubXhhY2Nlc3NfZ2F0ZXdheS52MS5TZXNzaW9u",
"U3RhdGUidQoPV29ya2VySW5mb1JlcGx5EhkKEXdvcmtlcl9wcm9jZXNzX2lk",
"GAEgASgFEhYKDndvcmtlcl92ZXJzaW9uGAIgASgJEhcKD214YWNjZXNzX3By",
"b2dpZBgDIAEoCRIWCg5teGFjY2Vzc19jbHNpZBgEIAEoCSJAChBEcmFpbkV2",
"ZW50c1JlcGx5EiwKBmV2ZW50cxgBIAMoCzIcLm14YWNjZXNzX2dhdGV3YXku",
"djEuTXhFdmVudCKbBgoHTXhFdmVudBIyCgZmYW1pbHkYASABKA4yIi5teGFj",
"Y2Vzc19nYXRld2F5LnYxLk14RXZlbnRGYW1pbHkSEgoKc2Vzc2lvbl9pZBgC",
"IAEoCRIVCg1zZXJ2ZXJfaGFuZGxlGAMgASgFEhMKC2l0ZW1faGFuZGxlGAQg",
"ASgFEisKBXZhbHVlGAUgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZh",
"bHVlEg8KB3F1YWxpdHkYBiABKAUSNAoQc291cmNlX3RpbWVzdGFtcBgHIAEo",
"CzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNAoIc3RhdHVzZXMYCCAD",
"KAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkSFwoPd29y",
"a2VyX3NlcXVlbmNlGAkgASgEEjQKEHdvcmtlcl90aW1lc3RhbXAYCiABKAsy",
"Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEj0KGWdhdGV3YXlfcmVjZWl2",
"ZV90aW1lc3RhbXAYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
"EhQKB2hyZXN1bHQYDCABKAVIAYgBARISCgpyYXdfc3RhdHVzGA0gASgJEkAK",
"Dm9uX2RhdGFfY2hhbmdlGBQgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5P",
"bkRhdGFDaGFuZ2VFdmVudEgAEkYKEW9uX3dyaXRlX2NvbXBsZXRlGBUgASgL",
"MikubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbldyaXRlQ29tcGxldGVFdmVudEgA",
"EkkKEm9wZXJhdGlvbl9jb21wbGV0ZRgWIAEoCzIrLm14YWNjZXNzX2dhdGV3",
"YXkudjEuT3BlcmF0aW9uQ29tcGxldGVFdmVudEgAElEKF29uX2J1ZmZlcmVk",
"X2RhdGFfY2hhbmdlGBcgASgLMi4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkJ1",
"ZmZlcmVkRGF0YUNoYW5nZUV2ZW50SABCBgoEYm9keUIKCghfaHJlc3VsdCIT",
"ChFPbkRhdGFDaGFuZ2VFdmVudCIWChRPbldyaXRlQ29tcGxldGVFdmVudCIY",
"ChZPcGVyYXRpb25Db21wbGV0ZUV2ZW50ItQBChlPbkJ1ZmZlcmVkRGF0YUNo",
"YW5nZUV2ZW50EjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3",
"YXkudjEuTXhEYXRhVHlwZRI0Cg5xdWFsaXR5X3ZhbHVlcxgCIAEoCzIcLm14",
"YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRI2ChB0aW1lc3RhbXBfdmFsdWVz",
"GAMgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5EhUKDXJhd19k",
"YXRhX3R5cGUYBCABKAUi6wEKDU14U3RhdHVzUHJveHkSDwoHc3VjY2VzcxgB",
"IAEoBRI3CghjYXRlZ29yeRgCIAEoDjIlLm14YWNjZXNzX2dhdGV3YXkudjEu",
"TXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRlY3RlZF9ieRgDIAEoDjIjLm14YWNj",
"ZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNTb3VyY2USDgoGZGV0YWlsGAQgASgF",
"EhQKDHJhd19jYXRlZ29yeRgFIAEoBRIXCg9yYXdfZGV0ZWN0ZWRfYnkYBiAB",
"KAUSFwoPZGlhZ25vc3RpY190ZXh0GAcgASgJIqcDCgdNeFZhbHVlEjIKCWRh",
"dGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEuTXhEYXRhVHlw",
"ZRIUCgx2YXJpYW50X3R5cGUYAiABKAkSDwoHaXNfbnVsbBgDIAEoCBIWCg5y",
"YXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1yYXdfZGF0YV90eXBlGAUgASgFEhQK",
"CmJvb2xfdmFsdWUYCiABKAhIABIVCgtpbnQzMl92YWx1ZRgLIAEoBUgAEhUK",
"C2ludDY0X3ZhbHVlGAwgASgDSAASFQoLZmxvYXRfdmFsdWUYDSABKAJIABIW",
"Cgxkb3VibGVfdmFsdWUYDiABKAFIABIWCgxzdHJpbmdfdmFsdWUYDyABKAlI",
"ABI1Cg90aW1lc3RhbXBfdmFsdWUYECABKAsyGi5nb29nbGUucHJvdG9idWYu",
"VGltZXN0YW1wSAASMwoLYXJyYXlfdmFsdWUYESABKAsyHC5teGFjY2Vzc19n",
"YXRld2F5LnYxLk14QXJyYXlIABITCglyYXdfdmFsdWUYEiABKAxIAEIGCgRr",
"aW5kIv4ECgdNeEFycmF5EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgOMh8u",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlw",
"ZRgCIAEoCRISCgpkaW1lbnNpb25zGAMgAygNEhYKDnJhd19kaWFnbm9zdGlj",
"GAQgASgJEh0KFXJhd19lbGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1Cgtib29s",
"X3ZhbHVlcxgKIAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuQm9vbEFycmF5",
"SAASNwoMaW50MzJfdmFsdWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52",
"MS5JbnQzMkFycmF5SAASNwoMaW50NjRfdmFsdWVzGAwgASgLMh8ubXhhY2Nl",
"c3NfZ2F0ZXdheS52MS5JbnQ2NEFycmF5SAASNwoMZmxvYXRfdmFsdWVzGA0g",
"ASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5GbG9hdEFycmF5SAASOQoNZG91",
"YmxlX3ZhbHVlcxgOIAEoCzIgLm14YWNjZXNzX2dhdGV3YXkudjEuRG91Ymxl",
"QXJyYXlIABI5Cg1zdHJpbmdfdmFsdWVzGA8gASgLMiAubXhhY2Nlc3NfZ2F0",
"ZXdheS52MS5TdHJpbmdBcnJheUgAEj8KEHRpbWVzdGFtcF92YWx1ZXMYECAB",
"KAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlRpbWVzdGFtcEFycmF5SAASMwoK",
"cmF3X3ZhbHVlcxgRIAEoCzIdLm14YWNjZXNzX2dhdGV3YXkudjEuUmF3QXJy",
"YXlIAEIICgZ2YWx1ZXMiGwoJQm9vbEFycmF5Eg4KBnZhbHVlcxgBIAMoCCIc",
"CgpJbnQzMkFycmF5Eg4KBnZhbHVlcxgBIAMoBSIcCgpJbnQ2NEFycmF5Eg4K",
"BnZhbHVlcxgBIAMoAyIcCgpGbG9hdEFycmF5Eg4KBnZhbHVlcxgBIAMoAiId",
"CgtEb3VibGVBcnJheRIOCgZ2YWx1ZXMYASADKAEiHQoLU3RyaW5nQXJyYXkS",
"DgoGdmFsdWVzGAEgAygJIjwKDlRpbWVzdGFtcEFycmF5EioKBnZhbHVlcxgB",
"IAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiGgoIUmF3QXJyYXkS",
"DgoGdmFsdWVzGAEgAygMIlgKDlByb3RvY29sU3RhdHVzEjUKBGNvZGUYASAB",
"KA4yJy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVzQ29kZRIP",
"CgdtZXNzYWdlGAIgASgJKr8GCg1NeENvbW1hbmRLaW5kEh8KG01YX0NPTU1B",
"TkRfS0lORF9VTlNQRUNJRklFRBAAEhwKGE1YX0NPTU1BTkRfS0lORF9SRUdJ",
"U1RFUhABEh4KGk1YX0NPTU1BTkRfS0lORF9VTlJFR0lTVEVSEAISHAoYTVhf",
"Q09NTUFORF9LSU5EX0FERF9JVEVNEAMSHQoZTVhfQ09NTUFORF9LSU5EX0FE",
"RF9JVEVNMhAEEh8KG01YX0NPTU1BTkRfS0lORF9SRU1PVkVfSVRFTRAFEhoK",
"Fk1YX0NPTU1BTkRfS0lORF9BRFZJU0UQBhIdChlNWF9DT01NQU5EX0tJTkRf",
"VU5fQURWSVNFEAcSJgoiTVhfQ09NTUFORF9LSU5EX0FEVklTRV9TVVBFUlZJ",
"U09SWRAIEiUKIU1YX0NPTU1BTkRfS0lORF9BRERfQlVGRkVSRURfSVRFTRAJ",
"EjAKLE1YX0NPTU1BTkRfS0lORF9TRVRfQlVGRkVSRURfVVBEQVRFX0lOVEVS",
"VkFMEAoSGwoXTVhfQ09NTUFORF9LSU5EX1NVU1BFTkQQCxIcChhNWF9DT01N",
"QU5EX0tJTkRfQUNUSVZBVEUQDBIZChVNWF9DT01NQU5EX0tJTkRfV1JJVEUQ",
"DRIaChZNWF9DT01NQU5EX0tJTkRfV1JJVEUyEA4SIQodTVhfQ09NTUFORF9L",
"SU5EX1dSSVRFX1NFQ1VSRUQQDxIiCh5NWF9DT01NQU5EX0tJTkRfV1JJVEVf",
"U0VDVVJFRDIQEBIlCiFNWF9DT01NQU5EX0tJTkRfQVVUSEVOVElDQVRFX1VT",
"RVIQERIoCiRNWF9DT01NQU5EX0tJTkRfQVJDSEVTVFJBX1VTRVJfVE9fSUQQ",
"EhIYChRNWF9DT01NQU5EX0tJTkRfUElORxBkEiUKIU1YX0NPTU1BTkRfS0lO",
"RF9HRVRfU0VTU0lPTl9TVEFURRBlEiMKH01YX0NPTU1BTkRfS0lORF9HRVRf",
"V09SS0VSX0lORk8QZhIgChxNWF9DT01NQU5EX0tJTkRfRFJBSU5fRVZFTlRT",
"EGcSIwofTVhfQ09NTUFORF9LSU5EX1NIVVRET1dOX1dPUktFUhBoKtABCg1N",
"eEV2ZW50RmFtaWx5Eh8KG01YX0VWRU5UX0ZBTUlMWV9VTlNQRUNJRklFRBAA",
"EiIKHk1YX0VWRU5UX0ZBTUlMWV9PTl9EQVRBX0NIQU5HRRABEiUKIU1YX0VW",
"RU5UX0ZBTUlMWV9PTl9XUklURV9DT01QTEVURRACEiYKIk1YX0VWRU5UX0ZB",
"TUlMWV9PUEVSQVRJT05fQ09NUExFVEUQAxIrCidNWF9FVkVOVF9GQU1JTFlf",
"T05fQlVGRkVSRURfREFUQV9DSEFOR0UQBCqlAwoQTXhTdGF0dXNDYXRlZ29y",
"eRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfVU5TUEVDSUZJRUQQABIeChpNWF9T",
"VEFUVVNfQ0FURUdPUllfVU5LTk9XThABEhkKFU1YX1NUQVRVU19DQVRFR09S",
"WV9PSxACEh4KGk1YX1NUQVRVU19DQVRFR09SWV9QRU5ESU5HEAMSHgoaTVhf",
"U1RBVFVTX0NBVEVHT1JZX1dBUk5JTkcQBBIqCiZNWF9TVEFUVVNfQ0FURUdP",
"UllfQ09NTVVOSUNBVElPTl9FUlJPUhAFEioKJk1YX1NUQVRVU19DQVRFR09S",
"WV9DT05GSUdVUkFUSU9OX0VSUk9SEAYSKAokTVhfU1RBVFVTX0NBVEVHT1JZ",
"X09QRVJBVElPTkFMX0VSUk9SEAcSJQohTVhfU1RBVFVTX0NBVEVHT1JZX1NF",
"Q1VSSVRZX0VSUk9SEAgSJQohTVhfU1RBVFVTX0NBVEVHT1JZX1NPRlRXQVJF",
"X0VSUk9SEAkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX09USEVSX0VSUk9SEAoq",
"ygIKDk14U3RhdHVzU291cmNlEiAKHE1YX1NUQVRVU19TT1VSQ0VfVU5TUEVD",
"SUZJRUQQABIcChhNWF9TVEFUVVNfU09VUkNFX1VOS05PV04QARIjCh9NWF9T",
"VEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTE1YEAISIwofTVhfU1RBVFVTX1NP",
"VVJDRV9SRVNQT05ESU5HX0xNWBADEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVR",
"VUVTVElOR19OTVgQBBIjCh9NWF9TVEFUVVNfU09VUkNFX1JFU1BPTkRJTkdf",
"Tk1YEAUSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNUSU5HX0FVVE9NQVRJ",
"T05fT0JKRUNUEAYSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX0FV",
"VE9NQVRJT05fT0JKRUNUEAcq3QQKCk14RGF0YVR5cGUSHAoYTVhfREFUQV9U",
"WVBFX1VOU1BFQ0lGSUVEEAASGAoUTVhfREFUQV9UWVBFX1VOS05PV04QARIY",
"ChRNWF9EQVRBX1RZUEVfTk9fREFUQRACEhgKFE1YX0RBVEFfVFlQRV9CT09M",
"RUFOEAMSGAoUTVhfREFUQV9UWVBFX0lOVEVHRVIQBBIWChJNWF9EQVRBX1RZ",
"UEVfRkxPQVQQBRIXChNNWF9EQVRBX1RZUEVfRE9VQkxFEAYSFwoTTVhfREFU",
"QV9UWVBFX1NUUklORxAHEhUKEU1YX0RBVEFfVFlQRV9USU1FEAgSHQoZTVhf",
"REFUQV9UWVBFX0VMQVBTRURfVElNRRAJEh8KG01YX0RBVEFfVFlQRV9SRUZF",
"UkVOQ0VfVFlQRRAKEhwKGE1YX0RBVEFfVFlQRV9TVEFUVVNfVFlQRRALEhUK",
"EU1YX0RBVEFfVFlQRV9FTlVNEAwSLQopTVhfREFUQV9UWVBFX1NFQ1VSSVRZ",
"X0NMQVNTSUZJQ0FUSU9OX0VOVU0QDRIiCh5NWF9EQVRBX1RZUEVfREFUQV9R",
"VUFMSVRZX1RZUEUQDhIfChtNWF9EQVRBX1RZUEVfUVVBTElGSUVEX0VOVU0Q",
"DxIhCh1NWF9EQVRBX1RZUEVfUVVBTElGSUVEX1NUUlVDVBAQEikKJU1YX0RB",
"VEFfVFlQRV9JTlRFUk5BVElPTkFMSVpFRF9TVFJJTkcQERIbChdNWF9EQVRB",
"X1RZUEVfQklHX1NUUklORxASEhQKEE1YX0RBVEFfVFlQRV9FTkQQEyqjAwoS",
"UHJvdG9jb2xTdGF0dXNDb2RlEiQKIFBST1RPQ09MX1NUQVRVU19DT0RFX1VO",
"U1BFQ0lGSUVEEAASGwoXUFJPVE9DT0xfU1RBVFVTX0NPREVfT0sQARIoCiRQ",
"Uk9UT0NPTF9TVEFUVVNfQ09ERV9JTlZBTElEX1JFUVVFU1QQAhIqCiZQUk9U",
"T0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9GT1VORBADEioKJlBST1RP",
"Q09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX1JFQURZEAQSKwonUFJPVE9D",
"T0xfU1RBVFVTX0NPREVfV09SS0VSX1VOQVZBSUxBQkxFEAUSIAocUFJPVE9D",
"T0xfU1RBVFVTX0NPREVfVElNRU9VVBAGEiEKHVBST1RPQ09MX1NUQVRVU19D",
"T0RFX0NBTkNFTEVEEAcSKwonUFJPVE9DT0xfU1RBVFVTX0NPREVfUFJPVE9D",
"T0xfVklPTEFUSU9OEAgSKQolUFJPVE9DT0xfU1RBVFVTX0NPREVfTVhBQ0NF",
"U1NfRkFJTFVSRRAJKr8CCgxTZXNzaW9uU3RhdGUSHQoZU0VTU0lPTl9TVEFU",
"RV9VTlNQRUNJRklFRBAAEhoKFlNFU1NJT05fU1RBVEVfQ1JFQVRJTkcQARIh",
"Ch1TRVNTSU9OX1NUQVRFX1NUQVJUSU5HX1dPUktFUhACEiIKHlNFU1NJT05f",
"U1RBVEVfV0FJVElOR19GT1JfUElQRRADEh0KGVNFU1NJT05fU1RBVEVfSEFO",
"RFNIQUtJTkcQBBIlCiFTRVNTSU9OX1NUQVRFX0lOSVRJQUxJWklOR19XT1JL",
"RVIQBRIXChNTRVNTSU9OX1NUQVRFX1JFQURZEAYSGQoVU0VTU0lPTl9TVEFU",
"RV9DTE9TSU5HEAcSGAoUU0VTU0lPTl9TVEFURV9DTE9TRUQQCBIZChVTRVNT",
"SU9OX1NUQVRFX0ZBVUxURUQQCTKCAwoPTXhBY2Nlc3NHYXRld2F5El0KC09w",
"ZW5TZXNzaW9uEicubXhhY2Nlc3NfZ2F0ZXdheS52MS5PcGVuU2Vzc2lvblJl",
"cXVlc3QaJS5teGFjY2Vzc19nYXRld2F5LnYxLk9wZW5TZXNzaW9uUmVwbHkS",
"YAoMQ2xvc2VTZXNzaW9uEigubXhhY2Nlc3NfZ2F0ZXdheS52MS5DbG9zZVNl",
"c3Npb25SZXF1ZXN0GiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5DbG9zZVNlc3Np",
"b25SZXBseRJUCgZJbnZva2USJS5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29t",
"bWFuZFJlcXVlc3QaIy5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29tbWFuZFJl",
"cGx5ElgKDFN0cmVhbUV2ZW50cxIoLm14YWNjZXNzX2dhdGV3YXkudjEuU3Ry",
"ZWFtRXZlbnRzUmVxdWVzdBocLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVu",
"dDABQhyqAhlNeEdhdGV3YXkuQ29udHJhY3RzLlByb3RvYgZwcm90bzM="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::MxGateway.Contracts.Proto.MxCommandKind), typeof(global::MxGateway.Contracts.Proto.MxEventFamily), typeof(global::MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::MxGateway.Contracts.Proto.MxStatusSource), typeof(global::MxGateway.Contracts.Proto.MxDataType), typeof(global::MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] {
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionRequest), global::MxGateway.Contracts.Proto.OpenSessionRequest.Parser, new[]{ "RequestedBackend", "ClientSessionName", "ClientCorrelationId", "CommandTimeout" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionReply), global::MxGateway.Contracts.Proto.OpenSessionReply.Parser, new[]{ "SessionId", "BackendName", "WorkerProcessId", "WorkerProtocolVersion", "Capabilities", "DefaultCommandTimeout", "ProtocolStatus" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionReply), global::MxGateway.Contracts.Proto.OpenSessionReply.Parser, new[]{ "SessionId", "BackendName", "WorkerProcessId", "WorkerProtocolVersion", "Capabilities", "DefaultCommandTimeout", "ProtocolStatus", "GatewayProtocolVersion" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionRequest), global::MxGateway.Contracts.Proto.CloseSessionRequest.Parser, new[]{ "SessionId", "ClientCorrelationId" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionReply), global::MxGateway.Contracts.Proto.CloseSessionReply.Parser, new[]{ "SessionId", "FinalState", "ProtocolStatus" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.StreamEventsRequest), global::MxGateway.Contracts.Proto.StreamEventsRequest.Parser, new[]{ "SessionId", "AfterWorkerSequence" }, null, null, null, null),
@@ -841,6 +841,7 @@ namespace MxGateway.Contracts.Proto {
capabilities_ = other.capabilities_.Clone();
defaultCommandTimeout_ = other.defaultCommandTimeout_ != null ? other.defaultCommandTimeout_.Clone() : null;
protocolStatus_ = other.protocolStatus_ != null ? other.protocolStatus_.Clone() : null;
gatewayProtocolVersion_ = other.gatewayProtocolVersion_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -933,6 +934,23 @@ namespace MxGateway.Contracts.Proto {
}
}
/// <summary>Field number for the "gateway_protocol_version" field.</summary>
public const int GatewayProtocolVersionFieldNumber = 8;
private uint gatewayProtocolVersion_;
/// <summary>
/// Public gateway contract version implemented by this endpoint. Clients use
/// this value to reject incompatible generated-code inputs before issuing
/// command-specific MXAccess calls.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public uint GatewayProtocolVersion {
get { return gatewayProtocolVersion_; }
set {
gatewayProtocolVersion_ = value;
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -955,6 +973,7 @@ namespace MxGateway.Contracts.Proto {
if(!capabilities_.Equals(other.capabilities_)) return false;
if (!object.Equals(DefaultCommandTimeout, other.DefaultCommandTimeout)) return false;
if (!object.Equals(ProtocolStatus, other.ProtocolStatus)) return false;
if (GatewayProtocolVersion != other.GatewayProtocolVersion) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -969,6 +988,7 @@ namespace MxGateway.Contracts.Proto {
hash ^= capabilities_.GetHashCode();
if (defaultCommandTimeout_ != null) hash ^= DefaultCommandTimeout.GetHashCode();
if (protocolStatus_ != null) hash ^= ProtocolStatus.GetHashCode();
if (GatewayProtocolVersion != 0) hash ^= GatewayProtocolVersion.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -1012,6 +1032,10 @@ namespace MxGateway.Contracts.Proto {
output.WriteRawTag(58);
output.WriteMessage(ProtocolStatus);
}
if (GatewayProtocolVersion != 0) {
output.WriteRawTag(64);
output.WriteUInt32(GatewayProtocolVersion);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -1047,6 +1071,10 @@ namespace MxGateway.Contracts.Proto {
output.WriteRawTag(58);
output.WriteMessage(ProtocolStatus);
}
if (GatewayProtocolVersion != 0) {
output.WriteRawTag(64);
output.WriteUInt32(GatewayProtocolVersion);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -1076,6 +1104,9 @@ namespace MxGateway.Contracts.Proto {
if (protocolStatus_ != null) {
size += 1 + pb::CodedOutputStream.ComputeMessageSize(ProtocolStatus);
}
if (GatewayProtocolVersion != 0) {
size += 1 + pb::CodedOutputStream.ComputeUInt32Size(GatewayProtocolVersion);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -1113,6 +1144,9 @@ namespace MxGateway.Contracts.Proto {
}
ProtocolStatus.MergeFrom(other.ProtocolStatus);
}
if (other.GatewayProtocolVersion != 0) {
GatewayProtocolVersion = other.GatewayProtocolVersion;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -1166,6 +1200,10 @@ namespace MxGateway.Contracts.Proto {
input.ReadMessage(ProtocolStatus);
break;
}
case 64: {
GatewayProtocolVersion = input.ReadUInt32();
break;
}
}
}
#endif
@@ -1219,6 +1257,10 @@ namespace MxGateway.Contracts.Proto {
input.ReadMessage(ProtocolStatus);
break;
}
case 64: {
GatewayProtocolVersion = input.ReadUInt32();
break;
}
}
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net10.0;net48</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
@@ -17,6 +17,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.2" />
</ItemGroup>
</Project>
@@ -30,6 +30,10 @@ message OpenSessionReply {
repeated string capabilities = 5;
google.protobuf.Duration default_command_timeout = 6;
ProtocolStatus protocol_status = 7;
// Public gateway contract version implemented by this endpoint. Clients use
// this value to reject incompatible generated-code inputs before issuing
// command-specific MXAccess calls.
uint32 gateway_protocol_version = 8;
}
message CloseSessionRequest {
@@ -0,0 +1,34 @@
@inject IOptions<GatewayOptions> GatewayOptions
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="@DashboardBaseHref" />
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body class="dashboard-body">
<Routes @rendermode="InteractiveServer" />
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@code {
private string DashboardBaseHref
{
get
{
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
return $"{pathBase}/";
}
}
}
@@ -0,0 +1,30 @@
namespace MxGateway.Server.Dashboard.Components;
public static class DashboardDisplay
{
public static string DateTime(DateTimeOffset? value)
{
return value.HasValue
? value.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", System.Globalization.CultureInfo.InvariantCulture)
: "-";
}
public static string Duration(TimeSpan value)
{
return value.TotalDays >= 1
? value.ToString(@"d\.hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture)
: value.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture);
}
public static string Text(string? value)
{
return string.IsNullOrWhiteSpace(value) ? "-" : value;
}
public static long MetricValue(DashboardSnapshot snapshot, string name, string? dimension = null)
{
return snapshot.Metrics.FirstOrDefault(metric =>
string.Equals(metric.Name, name, StringComparison.Ordinal)
&& string.Equals(metric.Dimension, dimension, StringComparison.Ordinal))?.Value ?? 0;
}
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Components;
namespace MxGateway.Server.Dashboard.Components;
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
{
private readonly CancellationTokenSource _disposeCancellation = new();
private Task? _watchTask;
[Inject]
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
protected DashboardSnapshot? Snapshot { get; private set; }
protected override void OnInitialized()
{
_watchTask = WatchSnapshotsAsync();
}
public async ValueTask DisposeAsync()
{
await _disposeCancellation.CancelAsync().ConfigureAwait(false);
if (_watchTask is not null)
{
await _watchTask.ConfigureAwait(false);
}
_disposeCancellation.Dispose();
GC.SuppressFinalize(this);
}
private async Task WatchSnapshotsAsync()
{
try
{
await foreach (DashboardSnapshot snapshot in SnapshotService
.WatchSnapshotsAsync(_disposeCancellation.Token)
.ConfigureAwait(false))
{
Snapshot = snapshot;
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_disposeCancellation.IsCancellationRequested)
{
}
}
}
@@ -0,0 +1,53 @@
@inherits LayoutComponentBase
@inject IOptions<GatewayOptions> GatewayOptions
<div class="dashboard-shell">
<nav class="navbar navbar-expand-lg bg-body border-bottom dashboard-navbar">
<div class="container-fluid">
<a class="navbar-brand" href="">MXAccess Gateway</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#dashboardNav"
aria-controls="dashboardNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="dashboardNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">Overview</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="sessions">Sessions</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="workers">Workers</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="events">Events</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="settings">Settings</NavLink>
</li>
</ul>
<form method="post" action="@DashboardPath("/logout")" class="d-flex">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
</div>
</div>
</nav>
<main class="container-fluid dashboard-content">
@Body
</main>
</div>
@code {
private string DashboardPath(string relativePath)
{
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
return $"{pathBase}{relativePath}";
}
}
@@ -0,0 +1,37 @@
@page "/"
@inherits DashboardPageBase
<PageTitle>MXAccess Gateway Dashboard</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading dashboard snapshot.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Overview</h1>
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
</div>
<StatusBadge Text="@Snapshot.GatewayStatus" />
</div>
<section class="metric-grid">
<MetricCard Label="Uptime" Value="@DashboardDisplay.Duration(Snapshot.GatewayUptime)" Detail="@Snapshot.GatewayVersion" />
<MetricCard Label="Open Sessions" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.sessions.open").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Workers Running" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.workers.running").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Event Queue Depth" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.queue.depth").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Commands Failed" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.commands.failed").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Events Received" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Faults" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.faults").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Recent Faults</h2>
</div>
<FaultList Faults="@Snapshot.Faults" />
</section>
}
@@ -0,0 +1,64 @@
@page "/events"
@inherits DashboardPageBase
<PageTitle>Dashboard Events</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading event diagnostics.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Events</h1>
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
</div>
</div>
<section class="metric-grid compact">
<MetricCard Label="Events Received" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Event Queue Depth" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.queue.depth").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
<MetricCard Label="Stream Disconnects" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.grpc.streams.disconnected").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Event Families</h2>
</div>
@if (EventFamilyMetrics.Count == 0)
{
<div class="empty-state">No event family counters recorded.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Family</th>
<th scope="col">Count</th>
</tr>
</thead>
<tbody>
@foreach (DashboardMetricSummary metric in EventFamilyMetrics)
{
<tr>
<td>@metric.Dimension</td>
<td>@metric.Value</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private IReadOnlyList<DashboardMetricSummary> EventFamilyMetrics => Snapshot?.Metrics
.Where(metric => metric.Name == "mxgateway.events.received" && metric.Dimension is not null)
.OrderBy(metric => metric.Dimension, StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
}
@@ -0,0 +1,69 @@
@page "/sessions/{SessionId}"
@inherits DashboardPageBase
<PageTitle>Dashboard Session</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading session.</div>
}
else if (CurrentSession is null)
{
<section class="dashboard-section">
<h1 class="h4 mb-3">Session Not Found</h1>
<p class="mb-0">The session is not present in the current snapshot.</p>
</section>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Session Details</h1>
<div class="text-secondary"><code>@CurrentSession.SessionId</code></div>
</div>
<StatusBadge Text="@CurrentSession.State.ToString()" />
</div>
<section class="dashboard-section">
<div class="section-heading">
<h2>Session</h2>
</div>
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Backend</th><td>@CurrentSession.BackendName</td></tr>
<tr><th scope="row">Client identity</th><td>@DashboardDisplay.Text(CurrentSession.ClientIdentity)</td></tr>
<tr><th scope="row">Client session</th><td>@DashboardDisplay.Text(CurrentSession.ClientSessionName)</td></tr>
<tr><th scope="row">Client correlation</th><td>@DashboardDisplay.Text(CurrentSession.ClientCorrelationId)</td></tr>
<tr><th scope="row">Opened</th><td>@DashboardDisplay.DateTime(CurrentSession.OpenedAt)</td></tr>
<tr><th scope="row">Last activity</th><td>@DashboardDisplay.DateTime(CurrentSession.LastClientActivityAt)</td></tr>
<tr><th scope="row">Lease expires</th><td>@DashboardDisplay.DateTime(CurrentSession.LeaseExpiresAt)</td></tr>
<tr><th scope="row">Last fault</th><td>@DashboardDisplay.Text(CurrentSession.LastFault)</td></tr>
</tbody>
</table>
</div>
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Worker</h2>
</div>
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Process id</th><td>@(CurrentSession.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td></tr>
<tr><th scope="row">State</th><td><StatusBadge Text="@(CurrentSession.WorkerState?.ToString() ?? "-")" /></td></tr>
<tr><th scope="row">Last heartbeat</th><td>@DashboardDisplay.DateTime(CurrentSession.LastWorkerHeartbeatAt)</td></tr>
</tbody>
</table>
</div>
</section>
}
@code {
[Parameter]
public string SessionId { get; set; } = string.Empty;
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
}
@@ -0,0 +1,67 @@
@page "/sessions"
@inherits DashboardPageBase
<PageTitle>Dashboard Sessions</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading sessions.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Sessions</h1>
<div class="text-secondary">@Snapshot.Sessions.Count session rows</div>
</div>
</div>
<section class="dashboard-section">
@if (Snapshot.Sessions.Count == 0)
{
<div class="empty-state">No sessions are active or retained.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Session</th>
<th scope="col">State</th>
<th scope="col">Client</th>
<th scope="col">Backend</th>
<th scope="col">Worker</th>
<th scope="col">Opened</th>
<th scope="col">Activity</th>
<th scope="col">Heartbeat</th>
<th scope="col">Fault</th>
</tr>
</thead>
<tbody>
@foreach (DashboardSessionSummary session in Snapshot.Sessions)
{
<tr>
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(session.SessionId)}")"><code>@session.SessionId</code></NavLink></td>
<td><StatusBadge Text="@session.State.ToString()" /></td>
<td>@DashboardDisplay.Text(session.ClientIdentity)</td>
<td>@session.BackendName</td>
<td>
@(session.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")
@if (session.WorkerState is not null)
{
<span class="ms-1"><StatusBadge Text="@session.WorkerState.ToString()" /></span>
}
</td>
<td>@DashboardDisplay.DateTime(session.OpenedAt)</td>
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
<td>@DashboardDisplay.Text(session.LastFault)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@@ -0,0 +1,47 @@
@page "/settings"
@inherits DashboardPageBase
<PageTitle>Dashboard Settings</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading settings.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Settings</h1>
<div class="text-secondary">Effective gateway configuration</div>
</div>
</div>
<section class="dashboard-section">
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Authentication mode</th><td>@Snapshot.Configuration.Authentication.Mode</td></tr>
<tr><th scope="row">Auth database</th><td><code>@Snapshot.Configuration.Authentication.SqlitePath</code></td></tr>
<tr><th scope="row">Pepper secret</th><td>@Snapshot.Configuration.Authentication.PepperSecretName</td></tr>
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
<tr><th scope="row">Shutdown timeout</th><td>@Snapshot.Configuration.Worker.ShutdownTimeoutSeconds seconds</td></tr>
<tr><th scope="row">Heartbeat grace</th><td>@Snapshot.Configuration.Worker.HeartbeatGraceSeconds seconds</td></tr>
<tr><th scope="row">Default command timeout</th><td>@Snapshot.Configuration.Sessions.DefaultCommandTimeoutSeconds seconds</td></tr>
<tr><th scope="row">Max sessions</th><td>@Snapshot.Configuration.Sessions.MaxSessions</td></tr>
<tr><th scope="row">Event queue capacity</th><td>@Snapshot.Configuration.Events.QueueCapacity</td></tr>
<tr><th scope="row">Backpressure policy</th><td>@Snapshot.Configuration.Events.BackpressurePolicy</td></tr>
<tr><th scope="row">Dashboard enabled</th><td>@Snapshot.Configuration.Dashboard.Enabled</td></tr>
<tr><th scope="row">Dashboard path</th><td>@Snapshot.Configuration.Dashboard.PathBase</td></tr>
<tr><th scope="row">Require admin scope</th><td>@Snapshot.Configuration.Dashboard.RequireAdminScope</td></tr>
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
<tr><th scope="row">Worker protocol</th><td>@Snapshot.Configuration.Protocol.WorkerProtocolVersion</td></tr>
</tbody>
</table>
</div>
</section>
}
@@ -0,0 +1,53 @@
@page "/workers"
@inherits DashboardPageBase
<PageTitle>Dashboard Workers</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading workers.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Workers</h1>
<div class="text-secondary">@Snapshot.Workers.Count worker rows</div>
</div>
</div>
<section class="dashboard-section">
@if (Snapshot.Workers.Count == 0)
{
<div class="empty-state">No worker processes are attached.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Process</th>
<th scope="col">State</th>
<th scope="col">Session</th>
<th scope="col">Heartbeat</th>
<th scope="col">Fault</th>
</tr>
</thead>
<tbody>
@foreach (DashboardWorkerSummary worker in Snapshot.Workers)
{
<tr>
<td>@(worker.ProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
<td><StatusBadge Text="@worker.State.ToString()" /></td>
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
<td>@DashboardDisplay.Text(worker.LastFault)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@@ -0,0 +1,15 @@
<Router AppAssembly="@typeof(Routes).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(DashboardLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(DashboardLayout)">
<PageTitle>Dashboard - Not Found</PageTitle>
<section class="dashboard-section">
<h1 class="h4 mb-3">Not Found</h1>
<p class="mb-0">The requested dashboard page does not exist.</p>
</section>
</LayoutView>
</NotFound>
</Router>
@@ -0,0 +1,39 @@
@if (Faults.Count == 0)
{
<div class="empty-state">No faults recorded.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Observed</th>
<th scope="col">Source</th>
<th scope="col">Session</th>
<th scope="col">Worker</th>
<th scope="col">State</th>
<th scope="col">Message</th>
</tr>
</thead>
<tbody>
@foreach (DashboardFaultSummary fault in Faults)
{
<tr>
<td>@DashboardDisplay.DateTime(fault.ObservedAt)</td>
<td>@fault.Source</td>
<td><code>@DashboardDisplay.Text(fault.SessionId)</code></td>
<td>@(fault.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
<td><StatusBadge Text="@fault.State" /></td>
<td>@fault.Message</td>
</tr>
}
</tbody>
</table>
</div>
}
@code {
[Parameter]
public IReadOnlyList<DashboardFaultSummary> Faults { get; set; } = [];
}
@@ -0,0 +1,21 @@
<div class="card metric-card h-100">
<div class="card-body">
<div class="metric-label">@Label</div>
<div class="metric-value">@Value</div>
@if (!string.IsNullOrWhiteSpace(Detail))
{
<div class="metric-detail">@Detail</div>
}
</div>
</div>
@code {
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public string Value { get; set; } = string.Empty;
[Parameter]
public string? Detail { get; set; }
}
@@ -0,0 +1,15 @@
<span class="badge @CssClass">@Text</span>
@code {
[Parameter]
public string? Text { get; set; }
private string CssClass => Text switch
{
"Ready" or "Healthy" => "text-bg-success",
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info",
"Closed" => "text-bg-secondary",
"Faulted" => "text-bg-danger",
_ => "text-bg-light text-dark border"
};
}
@@ -0,0 +1,12 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Options
@using MxGateway.Contracts.Proto
@using MxGateway.Server.Configuration
@using MxGateway.Server.Dashboard
@using MxGateway.Server.Dashboard.Components.Layout
@using MxGateway.Server.Dashboard.Components.Shared
@using MxGateway.Server.Workers
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@@ -0,0 +1,10 @@
namespace MxGateway.Server.Dashboard;
public static class DashboardAuthenticationDefaults
{
public const string AuthenticationScheme = "MxGateway.Dashboard";
public const string AuthorizationPolicy = "MxGateway.Dashboard";
public const string ScopeClaimType = "scope";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
public const string CookieName = "__Host-MxGatewayDashboard";
}
@@ -0,0 +1,19 @@
using System.Security.Claims;
namespace MxGateway.Server.Dashboard;
public sealed record DashboardAuthenticationResult(
bool Succeeded,
ClaimsPrincipal? Principal,
string? FailureMessage)
{
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
{
return new DashboardAuthenticationResult(true, principal, null);
}
public static DashboardAuthenticationResult Fail(string failureMessage)
{
return new DashboardAuthenticationResult(false, null, failureMessage);
}
}
@@ -0,0 +1,81 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardAuthenticator(
IApiKeyVerifier apiKeyVerifier,
IOptions<GatewayOptions> options) : IDashboardAuthenticator
{
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey,
CancellationToken cancellationToken)
{
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
{
return DashboardAuthenticationResult.Success(CreatePrincipal(new ApiKeyIdentity(
KeyId: "authentication-disabled",
KeyPrefix: "authentication-disabled",
DisplayName: "Authentication Disabled",
Scopes: new HashSet<string>([GatewayScopes.Admin], StringComparer.Ordinal))));
}
if (string.IsNullOrWhiteSpace(apiKey))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
.VerifyAsync(FormatAuthorizationHeader(apiKey), cancellationToken)
.ConfigureAwait(false);
if (!verificationResult.Succeeded || verificationResult.Identity is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
if (options.Value.Dashboard.RequireAdminScope
&& !verificationResult.Identity.Scopes.Contains(GatewayScopes.Admin))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
return DashboardAuthenticationResult.Success(CreatePrincipal(verificationResult.Identity));
}
private static string FormatAuthorizationHeader(string apiKey)
{
string trimmedApiKey = apiKey.Trim();
return trimmedApiKey.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? trimmedApiKey
: $"Bearer {trimmedApiKey}";
}
private static ClaimsPrincipal CreatePrincipal(ApiKeyIdentity identity)
{
List<Claim> claims =
[
new Claim(ClaimTypes.NameIdentifier, identity.KeyId),
new Claim(ClaimTypes.Name, identity.DisplayName),
new Claim(DashboardAuthenticationDefaults.KeyPrefixClaimType, identity.KeyPrefix)
];
claims.AddRange(identity.Scopes.Select(scope => new Claim(
DashboardAuthenticationDefaults.ScopeClaimType,
scope)));
ClaimsIdentity claimsIdentity = new(
claims,
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
DashboardAuthenticationDefaults.ScopeClaimType);
return new ClaimsPrincipal(claimsIdentity);
}
}
@@ -0,0 +1,59 @@
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardAuthorizationHandler(
IHttpContextAccessor httpContextAccessor,
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
DashboardAuthorizationRequirement requirement)
{
GatewayOptions gatewayOptions = options.Value;
if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (context.User.Identity?.IsAuthenticated != true)
{
return Task.CompletedTask;
}
if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
private bool IsLoopbackRequest()
{
IPAddress? remoteAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress);
}
private static bool HasAdminScope(AuthorizationHandlerContext context)
{
return context.User.HasClaim(
DashboardAuthenticationDefaults.ScopeClaimType,
GatewayScopes.Admin);
}
}
@@ -0,0 +1,5 @@
using Microsoft.AspNetCore.Authorization;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
@@ -0,0 +1,197 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.HttpResults;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard.Components;
namespace MxGateway.Server.Dashboard;
public static class DashboardEndpointRouteBuilderExtensions
{
public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints)
{
IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>();
IConfigurationSection dashboardSection = configuration
.GetSection($"{GatewayOptions.SectionName}:Dashboard");
if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled)
{
return endpoints;
}
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
dashboard.MapGet(
"/login",
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
.AllowAnonymous()
.WithName("DashboardLogin");
dashboard.MapPost(
"/login",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase))
.AllowAnonymous()
.WithName("DashboardLoginPost");
dashboard.MapPost(
"/logout",
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
.WithName("DashboardLogout");
dashboard.MapGet("/denied", () => Results.Content(
RenderPage("Access denied", "<p>The signed-in API key is not authorized for dashboard access.</p>"),
"text/html"))
.AllowAnonymous()
.WithName("DashboardAccessDenied");
dashboard.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
return endpoints;
}
private static Task<ContentHttpResult> GetLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
string pathBase)
{
string returnUrl = SanitizeReturnUrl(
httpContext.Request.Query["returnUrl"].ToString(),
pathBase);
return Task.FromResult(TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null),
"text/html"));
}
private static async Task<IResult> PostLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
IDashboardAuthenticator authenticator,
string pathBase)
{
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
IFormCollection form = await httpContext.Request
.ReadFormAsync(httpContext.RequestAborted)
.ConfigureAwait(false);
string returnUrl = SanitizeReturnUrl(
form["returnUrl"].ToString(),
pathBase);
DashboardAuthenticationResult result = await authenticator
.AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted)
.ConfigureAwait(false);
if (!result.Succeeded || result.Principal is null)
{
return TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage),
"text/html",
statusCode: StatusCodes.Status401Unauthorized);
}
await httpContext
.SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal)
.ConfigureAwait(false);
return Results.LocalRedirect(returnUrl);
}
private static async Task<IResult> PostLogoutAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
string pathBase)
{
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
await httpContext
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
.ConfigureAwait(false);
return Results.LocalRedirect($"{pathBase}/login");
}
private static string RenderLoginPage(
HttpContext httpContext,
IAntiforgery antiforgery,
string returnUrl,
string pathBase,
string? failureMessage)
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
string requestToken = tokens.RequestToken ?? string.Empty;
string alert = string.IsNullOrWhiteSpace(failureMessage)
? string.Empty
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
string body = $"""
<section class="dashboard-login">
{alert}
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}" class="card login-card">
<div class="card-body">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
<div class="mb-3">
<label for="apiKey" class="form-label">API key</label>
<input id="apiKey" name="apiKey" type="password" autocomplete="off" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</div>
</form>
</section>
""";
return RenderPage("Dashboard Sign In", body);
}
private static string RenderPage(string title, string body)
{
return $"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{HtmlEncoder.Default.Encode(title)}</title>
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
</head>
<body class="dashboard-body">
<main class="container py-5">
<h1 class="h3 mb-4">{HtmlEncoder.Default.Encode(title)}</h1>
{body}
</main>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
</html>
""";
}
private static string NormalizePathBase(string pathBase)
{
string normalized = pathBase.TrimEnd('/');
return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal)
? "/dashboard"
: normalized;
}
private static string SanitizeReturnUrl(string? returnUrl, string pathBase)
{
if (string.IsNullOrWhiteSpace(returnUrl)
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|| returnUrl.StartsWith("//", StringComparison.Ordinal)
|| !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
{
return pathBase;
}
return returnUrl;
}
}
@@ -0,0 +1,9 @@
namespace MxGateway.Server.Dashboard;
public sealed record DashboardFaultSummary(
string Source,
string? SessionId,
int? WorkerProcessId,
string State,
string Message,
DateTimeOffset ObservedAt);
@@ -0,0 +1,6 @@
namespace MxGateway.Server.Dashboard;
public sealed record DashboardMetricSummary(
string Name,
long Value,
string? Dimension = null);
@@ -0,0 +1,34 @@
using MxGateway.Server.Diagnostics;
namespace MxGateway.Server.Dashboard;
internal static class DashboardRedactor
{
private static readonly string[] SensitiveTextMarkers =
[
"apikey",
"api_key",
"authorization",
"credential",
"password",
"secret",
"token",
];
public static string? Redact(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
if (value.Contains("mxgw_", StringComparison.OrdinalIgnoreCase))
{
return GatewayLogRedactor.RedactClientIdentity(value);
}
return SensitiveTextMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase))
? GatewayLogRedactor.RedactedValue
: value;
}
}
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
namespace MxGateway.Server.Dashboard;
public static class DashboardServiceCollectionExtensions
{
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
{
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
services.AddHttpContextAccessor();
services.AddAntiforgery();
services.AddCascadingAuthenticationState();
services.AddRazorComponents()
.AddInteractiveServerComponents();
services
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
services.AddAuthorization(options =>
{
options.AddPolicy(
DashboardAuthenticationDefaults.AuthorizationPolicy,
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
});
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
return services;
}
private static void ConfigureCookieOptions(
CookieAuthenticationOptions cookieOptions,
IOptions<GatewayOptions> gatewayOptions)
{
string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.HttpOnly = true;
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
cookieOptions.Cookie.Path = "/";
cookieOptions.LoginPath = $"{pathBase}/login";
cookieOptions.LogoutPath = $"{pathBase}/logout";
cookieOptions.AccessDeniedPath = $"{pathBase}/denied";
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
cookieOptions.SlidingExpiration = true;
}
}
@@ -0,0 +1,19 @@
using MxGateway.Contracts.Proto;
using MxGateway.Server.Workers;
namespace MxGateway.Server.Dashboard;
public sealed record DashboardSessionSummary(
string SessionId,
string BackendName,
SessionState State,
string? ClientIdentity,
string? ClientSessionName,
string? ClientCorrelationId,
DateTimeOffset OpenedAt,
DateTimeOffset LastClientActivityAt,
DateTimeOffset? LeaseExpiresAt,
int? WorkerProcessId,
WorkerClientState? WorkerState,
DateTimeOffset? LastWorkerHeartbeatAt,
string? LastFault);
@@ -0,0 +1,15 @@
using MxGateway.Server.Configuration;
namespace MxGateway.Server.Dashboard;
public sealed record DashboardSnapshot(
DateTimeOffset GeneratedAt,
DateTimeOffset GatewayStartedAt,
TimeSpan GatewayUptime,
string GatewayStatus,
string GatewayVersion,
IReadOnlyList<DashboardSessionSummary> Sessions,
IReadOnlyList<DashboardWorkerSummary> Workers,
IReadOnlyList<DashboardMetricSummary> Metrics,
IReadOnlyList<DashboardFaultSummary> Faults,
EffectiveGatewayConfiguration Configuration);
@@ -0,0 +1,196 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardSnapshotService : IDashboardSnapshotService
{
private const string HealthyStatus = "Healthy";
private readonly ISessionRegistry _sessionRegistry;
private readonly GatewayMetrics _metrics;
private readonly IGatewayConfigurationProvider _configurationProvider;
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _gatewayStartedAt;
private readonly TimeSpan _snapshotInterval;
private readonly int _recentFaultLimit;
private readonly int _recentSessionLimit;
public DashboardSnapshotService(
ISessionRegistry sessionRegistry,
GatewayMetrics metrics,
IGatewayConfigurationProvider configurationProvider,
IOptions<GatewayOptions> options,
TimeProvider? timeProvider = null)
{
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider));
ArgumentNullException.ThrowIfNull(options);
_timeProvider = timeProvider ?? TimeProvider.System;
_gatewayStartedAt = _timeProvider.GetUtcNow();
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
}
public DashboardSnapshot GetSnapshot()
{
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
IReadOnlyList<GatewaySession> sessions = _sessionRegistry.Snapshot()
.OrderByDescending(session => session.OpenedAt)
.ToArray();
IReadOnlyList<DashboardSessionSummary> sessionSummaries = sessions
.Take(ResolveLimit(_recentSessionLimit))
.Select(CreateSessionSummary)
.ToArray();
IReadOnlyList<DashboardWorkerSummary> workerSummaries = sessions
.Where(session => session.WorkerClient is not null)
.Select(CreateWorkerSummary)
.ToArray();
GatewayMetricsSnapshot metricsSnapshot = _metrics.GetSnapshot();
return new DashboardSnapshot(
GeneratedAt: generatedAt,
GatewayStartedAt: _gatewayStartedAt,
GatewayUptime: generatedAt - _gatewayStartedAt,
GatewayStatus: HealthyStatus,
GatewayVersion: typeof(DashboardSnapshotService).Assembly.GetName().Version?.ToString() ?? "unknown",
Sessions: sessionSummaries,
Workers: workerSummaries,
Metrics: CreateMetricSummaries(metricsSnapshot),
Faults: CreateFaultSummaries(sessions, generatedAt),
Configuration: _configurationProvider.GetEffectiveConfiguration());
}
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
yield break;
}
yield return GetSnapshot();
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
while (true)
{
bool hasNext;
try
{
hasNext = await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
yield break;
}
if (!hasNext)
{
yield break;
}
yield return GetSnapshot();
}
}
private static DashboardSessionSummary CreateSessionSummary(GatewaySession session)
{
IWorkerClient? workerClient = session.WorkerClient;
return new DashboardSessionSummary(
SessionId: session.SessionId,
BackendName: session.BackendName,
State: session.State,
ClientIdentity: DashboardRedactor.Redact(session.ClientIdentity),
ClientSessionName: DashboardRedactor.Redact(session.ClientSessionName),
ClientCorrelationId: DashboardRedactor.Redact(session.ClientCorrelationId),
OpenedAt: session.OpenedAt,
LastClientActivityAt: session.LastClientActivityAt,
LeaseExpiresAt: session.LeaseExpiresAt,
WorkerProcessId: workerClient?.ProcessId,
WorkerState: workerClient?.State,
LastWorkerHeartbeatAt: workerClient?.LastHeartbeatAt,
LastFault: DashboardRedactor.Redact(session.FinalFault));
}
private static DashboardWorkerSummary CreateWorkerSummary(GatewaySession session)
{
IWorkerClient workerClient = session.WorkerClient!;
return new DashboardWorkerSummary(
SessionId: session.SessionId,
ProcessId: workerClient.ProcessId,
State: workerClient.State,
LastHeartbeatAt: workerClient.LastHeartbeatAt,
LastFault: DashboardRedactor.Redact(session.FinalFault));
}
private static IReadOnlyList<DashboardMetricSummary> CreateMetricSummaries(GatewayMetricsSnapshot snapshot)
{
List<DashboardMetricSummary> metrics =
[
new("mxgateway.sessions.open", snapshot.OpenSessions),
new("mxgateway.workers.running", snapshot.WorkersRunning),
new("mxgateway.events.queue.depth", snapshot.EventQueueDepth),
new("mxgateway.sessions.opened", snapshot.SessionsOpened),
new("mxgateway.sessions.closed", snapshot.SessionsClosed),
new("mxgateway.commands.started", snapshot.CommandsStarted),
new("mxgateway.commands.succeeded", snapshot.CommandsSucceeded),
new("mxgateway.commands.failed", snapshot.CommandsFailed),
new("mxgateway.events.received", snapshot.EventsReceived),
new("mxgateway.queues.overflows", snapshot.QueueOverflows),
new("mxgateway.faults", snapshot.Faults),
new("mxgateway.workers.killed", snapshot.WorkerKills),
new("mxgateway.workers.exited", snapshot.WorkerExits),
new("mxgateway.heartbeats.failed", snapshot.HeartbeatFailures),
new("mxgateway.grpc.streams.disconnected", snapshot.StreamDisconnects),
];
metrics.AddRange(snapshot.CommandFailuresByMethod
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
.Select(entry => new DashboardMetricSummary("mxgateway.commands.failed", entry.Value, entry.Key)));
metrics.AddRange(snapshot.EventsByFamily
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
.Select(entry => new DashboardMetricSummary("mxgateway.events.received", entry.Value, entry.Key)));
return metrics;
}
private IReadOnlyList<DashboardFaultSummary> CreateFaultSummaries(
IReadOnlyList<GatewaySession> sessions,
DateTimeOffset generatedAt)
{
return sessions
.Where(HasFault)
.Take(ResolveLimit(_recentFaultLimit))
.Select(session => new DashboardFaultSummary(
Source: session.WorkerClient?.State == WorkerClientState.Faulted ? "Worker" : "Session",
SessionId: session.SessionId,
WorkerProcessId: session.WorkerProcessId,
State: session.WorkerClient?.State == WorkerClientState.Faulted
? WorkerClientState.Faulted.ToString()
: session.State.ToString(),
Message: DashboardRedactor.Redact(session.FinalFault) ?? "Faulted",
ObservedAt: generatedAt))
.ToArray();
}
private static bool HasFault(GatewaySession session)
{
return session.State == MxGateway.Contracts.Proto.SessionState.Faulted
|| session.WorkerClient?.State == WorkerClientState.Faulted
|| !string.IsNullOrWhiteSpace(session.FinalFault);
}
private static int ResolveLimit(int configuredLimit)
{
return configuredLimit < 0 ? 0 : configuredLimit;
}
}
@@ -0,0 +1,10 @@
using MxGateway.Server.Workers;
namespace MxGateway.Server.Dashboard;
public sealed record DashboardWorkerSummary(
string SessionId,
int? ProcessId,
WorkerClientState State,
DateTimeOffset LastHeartbeatAt,
string? LastFault);
@@ -0,0 +1,8 @@
namespace MxGateway.Server.Dashboard;
public interface IDashboardAuthenticator
{
Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey,
CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace MxGateway.Server.Dashboard;
public interface IDashboardSnapshotService
{
DashboardSnapshot GetSnapshot();
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
}
@@ -1,8 +1,13 @@
using MxGateway.Contracts;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Diagnostics;
using MxGateway.Server.Grpc;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace MxGateway.Server;
@@ -14,6 +19,10 @@ public static class GatewayApplication
WebApplication app = builder.Build();
app.UseGatewayRequestLoggingScope();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapGatewayEndpoints();
return app;
@@ -25,8 +34,15 @@ public static class GatewayApplication
builder.Services.AddGatewayConfiguration();
builder.Services.AddSqliteAuthStore();
builder.Services.AddGatewayGrpcAuthorization();
builder.Services.AddHealthChecks();
builder.Services.AddSingleton<GatewayMetrics>();
builder.Services.AddSingleton<MxAccessGrpcMapper>();
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
builder.Services.AddWorkerProcessLauncher();
builder.Services.AddGatewaySessions();
builder.Services.AddGatewayDashboard();
return builder;
}
@@ -43,6 +59,9 @@ public static class GatewayApplication
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
.WithName("LiveHealth");
endpoints.MapGrpcService<MxAccessGatewayService>();
endpoints.MapGatewayDashboard();
return endpoints;
}
}
@@ -0,0 +1,140 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace MxGateway.Server.Grpc;
public sealed class EventStreamService(
ISessionManager sessionManager,
IOptions<GatewayOptions> options,
MxAccessGrpcMapper mapper,
GatewayMetrics metrics,
ILogger<EventStreamService> logger) : IEventStreamService
{
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (!sessionManager.TryGetSession(request.SessionId, out GatewaySession session))
{
throw new SessionManagerException(
SessionManagerErrorCode.SessionNotFound,
$"Session {request.SessionId} was not found.");
}
using IDisposable subscriber = session.AttachEventSubscriber(
options.Value.Sessions.AllowMultipleEventSubscribers);
using CancellationTokenSource streamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
int streamQueueDepth = 0;
Channel<MxEvent> eventQueue = Channel.CreateBounded<MxEvent>(
new BoundedChannelOptions(options.Value.Events.QueueCapacity)
{
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.Wait,
AllowSynchronousContinuations = false,
});
Task producerTask = ProduceEventsAsync(
session,
request.AfterWorkerSequence,
eventQueue.Writer,
() =>
{
int depth = Interlocked.Increment(ref streamQueueDepth);
metrics.SetEventQueueDepth(depth);
},
streamCts.Token);
try
{
await foreach (MxEvent mxEvent in eventQueue.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
int depth = Math.Max(0, Interlocked.Decrement(ref streamQueueDepth));
metrics.SetEventQueueDepth(depth);
yield return mxEvent;
}
await producerTask.ConfigureAwait(false);
}
finally
{
await streamCts.CancelAsync().ConfigureAwait(false);
subscriber.Dispose();
metrics.StreamDisconnected("Detached");
try
{
await producerTask.ConfigureAwait(false);
}
catch (OperationCanceledException) when (streamCts.IsCancellationRequested)
{
}
catch (Exception exception)
{
logger.LogDebug(
exception,
"Event stream producer stopped for session {SessionId}.",
request.SessionId);
}
}
}
private async Task ProduceEventsAsync(
GatewaySession session,
ulong afterWorkerSequence,
ChannelWriter<MxEvent> writer,
Action eventQueued,
CancellationToken cancellationToken)
{
try
{
await foreach (WorkerEvent workerEvent in session
.ReadEventsAsync(cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
MxEvent publicEvent = mapper.MapEvent(workerEvent);
if (publicEvent.WorkerSequence <= afterWorkerSequence)
{
continue;
}
if (!writer.TryWrite(publicEvent))
{
string message = $"Session {session.SessionId} event stream queue overflowed.";
session.MarkFaulted(message);
metrics.QueueOverflow("grpc-event-stream");
metrics.Fault(SessionManagerErrorCode.EventQueueOverflow.ToString());
writer.TryComplete(new SessionManagerException(
SessionManagerErrorCode.EventQueueOverflow,
message));
return;
}
eventQueued();
}
writer.TryComplete();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
writer.TryComplete();
}
catch (Exception exception)
{
if (exception is WorkerClientException)
{
session.MarkFaulted(exception.Message);
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
}
writer.TryComplete(exception);
}
}
}
@@ -0,0 +1,10 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Grpc;
public interface IEventStreamService
{
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,177 @@
using Grpc.Core;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace MxGateway.Server.Grpc;
public sealed class MxAccessGatewayService(
ISessionManager sessionManager,
IGatewayRequestIdentityAccessor identityAccessor,
MxAccessGrpcRequestValidator requestValidator,
MxAccessGrpcMapper mapper,
IEventStreamService eventStreamService,
ILogger<MxAccessGatewayService> logger) : MxAccessGateway.MxAccessGatewayBase
{
public override async Task<OpenSessionReply> OpenSession(
OpenSessionRequest request,
ServerCallContext context)
{
try
{
requestValidator.ValidateOpenSession(request);
GatewaySession session = await sessionManager
.OpenSessionAsync(
SessionOpenRequest.FromContract(request),
ResolveClientIdentity(),
context.CancellationToken)
.ConfigureAwait(false);
OpenSessionReply reply = new()
{
SessionId = session.SessionId,
BackendName = session.BackendName,
WorkerProcessId = session.WorkerProcessId ?? 0,
WorkerProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
GatewayProtocolVersion = GatewayContractInfo.GatewayProtocolVersion,
DefaultCommandTimeout = Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan(session.CommandTimeout),
ProtocolStatus = MxAccessGrpcMapper.Ok(),
};
reply.Capabilities.Add("unary-open-session");
reply.Capabilities.Add("unary-close-session");
reply.Capabilities.Add("unary-invoke");
reply.Capabilities.Add("server-stream-events");
return reply;
}
catch (Exception exception) when (exception is not RpcException)
{
throw MapException(exception);
}
}
public override async Task<CloseSessionReply> CloseSession(
CloseSessionRequest request,
ServerCallContext context)
{
try
{
requestValidator.ValidateCloseSession(request);
SessionCloseResult result = await sessionManager
.CloseSessionAsync(request.SessionId, context.CancellationToken)
.ConfigureAwait(false);
return new CloseSessionReply
{
SessionId = result.SessionId,
FinalState = result.FinalState,
ProtocolStatus = MxAccessGrpcMapper.Ok(result.AlreadyClosed ? "Session was already closed." : "Session closed."),
};
}
catch (Exception exception) when (exception is not RpcException)
{
throw MapException(exception);
}
}
public override async Task<MxCommandReply> Invoke(
MxCommandRequest request,
ServerCallContext context)
{
try
{
requestValidator.ValidateInvoke(request);
WorkerCommand workerCommand = mapper.MapCommand(request);
WorkerCommandReply workerReply = await sessionManager
.InvokeAsync(request.SessionId, workerCommand, context.CancellationToken)
.ConfigureAwait(false);
return mapper.MapCommandReply(workerReply);
}
catch (Exception exception) when (exception is not RpcException)
{
throw MapException(exception);
}
}
public override async Task StreamEvents(
StreamEventsRequest request,
IServerStreamWriter<MxEvent> responseStream,
ServerCallContext context)
{
try
{
requestValidator.ValidateStreamEvents(request);
await foreach (MxEvent publicEvent in eventStreamService
.StreamEventsAsync(request, context.CancellationToken)
.WithCancellation(context.CancellationToken)
.ConfigureAwait(false))
{
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
}
}
catch (Exception exception) when (exception is not RpcException)
{
throw MapException(exception);
}
}
private string? ResolveClientIdentity()
{
return identityAccessor.Current?.DisplayName ?? identityAccessor.Current?.KeyId;
}
private RpcException MapException(Exception exception)
{
if (exception is OperationCanceledException)
{
return new RpcException(new Status(StatusCode.Cancelled, "gRPC request was canceled."));
}
if (exception is SessionManagerException sessionException)
{
return MapSessionException(sessionException);
}
if (exception is WorkerClientException workerClientException)
{
return MapWorkerClientException(workerClientException);
}
logger.LogWarning(exception, "Public gRPC request failed.");
return new RpcException(new Status(StatusCode.Unavailable, "Gateway request failed before an MXAccess reply was available."));
}
private static RpcException MapSessionException(SessionManagerException exception)
{
StatusCode statusCode = exception.ErrorCode switch
{
SessionManagerErrorCode.SessionNotFound => StatusCode.NotFound,
SessionManagerErrorCode.SessionNotReady => StatusCode.FailedPrecondition,
SessionManagerErrorCode.EventSubscriberAlreadyActive => StatusCode.ResourceExhausted,
SessionManagerErrorCode.EventQueueOverflow => StatusCode.ResourceExhausted,
SessionManagerErrorCode.SessionLimitExceeded => StatusCode.ResourceExhausted,
SessionManagerErrorCode.OpenFailed => StatusCode.Unavailable,
SessionManagerErrorCode.CloseFailed => StatusCode.Unavailable,
_ => StatusCode.Unavailable,
};
return new RpcException(new Status(statusCode, exception.Message));
}
private static RpcException MapWorkerClientException(WorkerClientException exception)
{
StatusCode statusCode = exception.ErrorCode switch
{
WorkerClientErrorCode.CommandTimeout => StatusCode.DeadlineExceeded,
WorkerClientErrorCode.GatewayShutdown => StatusCode.Cancelled,
WorkerClientErrorCode.InvalidState => StatusCode.FailedPrecondition,
WorkerClientErrorCode.ProtocolViolation => StatusCode.Internal,
_ => StatusCode.Unavailable,
};
return new RpcException(new Status(statusCode, exception.Message));
}
}
@@ -0,0 +1,124 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Grpc;
public sealed class MxAccessGrpcMapper
{
private readonly TimeProvider _timeProvider;
public MxAccessGrpcMapper(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public WorkerCommand MapCommand(MxCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Command);
return new WorkerCommand
{
Command = request.Command.Clone(),
EnqueueTimestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()),
};
}
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
if (reply.Reply is null)
{
return new MxCommandReply
{
ProtocolStatus = ProtocolViolation("Worker command reply did not contain a public reply payload."),
};
}
return reply.Reply.Clone();
}
public MxEvent MapEvent(WorkerEvent workerEvent)
{
ArgumentNullException.ThrowIfNull(workerEvent);
return workerEvent.Event?.Clone() ?? new MxEvent
{
Family = MxEventFamily.Unspecified,
RawStatus = "Worker event did not contain a public event payload.",
};
}
public static ProtocolStatus Ok(string message = "OK")
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.Ok,
Message = message,
};
}
public static ProtocolStatus InvalidRequest(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.InvalidRequest,
Message = message,
};
}
public static ProtocolStatus SessionNotFound(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.SessionNotFound,
Message = message,
};
}
public static ProtocolStatus SessionNotReady(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.SessionNotReady,
Message = message,
};
}
public static ProtocolStatus WorkerUnavailable(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.WorkerUnavailable,
Message = message,
};
}
public static ProtocolStatus Timeout(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.Timeout,
Message = message,
};
}
public static ProtocolStatus Canceled(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.Canceled,
Message = message,
};
}
public static ProtocolStatus ProtocolViolation(string message)
{
return new ProtocolStatus
{
Code = ProtocolStatusCode.ProtocolViolation,
Message = message,
};
}
}
@@ -0,0 +1,101 @@
using Grpc.Core;
using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Grpc;
public sealed class MxAccessGrpcRequestValidator
{
public void ValidateOpenSession(OpenSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.CommandTimeout is not null && request.CommandTimeout.ToTimeSpan() <= TimeSpan.Zero)
{
throw InvalidArgument("Command timeout must be greater than zero when provided.");
}
}
public void ValidateCloseSession(CloseSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId);
}
public void ValidateStreamEvents(StreamEventsRequest request)
{
ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId);
}
public void ValidateInvoke(MxCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId);
if (request.Command is null)
{
throw InvalidArgument("Invoke requires a command payload.");
}
if (request.Command.Kind is MxCommandKind.Unspecified)
{
throw InvalidArgument("Invoke requires a command kind.");
}
ValidateCommandPayload(request.Command);
}
private static void RequireSessionId(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
throw InvalidArgument("Session id is required.");
}
}
private static void ValidateCommandPayload(MxCommand command)
{
MxCommand.PayloadOneofCase expectedPayload = ExpectedPayload(command.Kind);
if (command.PayloadCase != expectedPayload)
{
throw InvalidArgument(
$"Command kind {command.Kind} requires payload {expectedPayload} but received {command.PayloadCase}.");
}
}
private static MxCommand.PayloadOneofCase ExpectedPayload(MxCommandKind kind)
{
return kind switch
{
MxCommandKind.Register => MxCommand.PayloadOneofCase.Register,
MxCommandKind.Unregister => MxCommand.PayloadOneofCase.Unregister,
MxCommandKind.AddItem => MxCommand.PayloadOneofCase.AddItem,
MxCommandKind.AddItem2 => MxCommand.PayloadOneofCase.AddItem2,
MxCommandKind.RemoveItem => MxCommand.PayloadOneofCase.RemoveItem,
MxCommandKind.Advise => MxCommand.PayloadOneofCase.Advise,
MxCommandKind.UnAdvise => MxCommand.PayloadOneofCase.UnAdvise,
MxCommandKind.AdviseSupervisory => MxCommand.PayloadOneofCase.AdviseSupervisory,
MxCommandKind.AddBufferedItem => MxCommand.PayloadOneofCase.AddBufferedItem,
MxCommandKind.SetBufferedUpdateInterval => MxCommand.PayloadOneofCase.SetBufferedUpdateInterval,
MxCommandKind.Suspend => MxCommand.PayloadOneofCase.Suspend,
MxCommandKind.Activate => MxCommand.PayloadOneofCase.Activate,
MxCommandKind.Write => MxCommand.PayloadOneofCase.Write,
MxCommandKind.Write2 => MxCommand.PayloadOneofCase.Write2,
MxCommandKind.WriteSecured => MxCommand.PayloadOneofCase.WriteSecured,
MxCommandKind.WriteSecured2 => MxCommand.PayloadOneofCase.WriteSecured2,
MxCommandKind.AuthenticateUser => MxCommand.PayloadOneofCase.AuthenticateUser,
MxCommandKind.ArchestraUserToId => MxCommand.PayloadOneofCase.ArchestraUserToId,
MxCommandKind.Ping => MxCommand.PayloadOneofCase.Ping,
MxCommandKind.GetSessionState => MxCommand.PayloadOneofCase.GetSessionState,
MxCommandKind.GetWorkerInfo => MxCommand.PayloadOneofCase.GetWorkerInfo,
MxCommandKind.DrainEvents => MxCommand.PayloadOneofCase.DrainEvents,
MxCommandKind.ShutdownWorker => MxCommand.PayloadOneofCase.ShutdownWorker,
_ => MxCommand.PayloadOneofCase.None,
};
}
private static RpcException InvalidArgument(string detail)
{
return new RpcException(new Status(StatusCode.InvalidArgument, detail));
}
}
@@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
</ItemGroup>
+37 -1
View File
@@ -1,7 +1,43 @@
using MxGateway.Server;
using MxGateway.Server.Configuration;
using MxGateway.Server.Security.Authentication;
var app = GatewayApplication.Build(args);
ApiKeyAdminParseResult apiKeyAdminCommand = ApiKeyAdminCommandLineParser.Parse(args);
if (apiKeyAdminCommand.IsApiKeyCommand)
{
if (apiKeyAdminCommand.Command is null)
{
await Console.Error.WriteLineAsync(apiKeyAdminCommand.Error);
return 2;
}
WebApplicationBuilder builder = GatewayApplication.CreateBuilder([]);
ApplyApiKeyAdminOverrides(builder.Configuration, apiKeyAdminCommand.Command);
await using WebApplication cliApp = builder.Build();
await using AsyncServiceScope scope = cliApp.Services.CreateAsyncScope();
ApiKeyAdminCliRunner runner = scope.ServiceProvider.GetRequiredService<ApiKeyAdminCliRunner>();
return await runner.RunAsync(apiKeyAdminCommand.Command, Console.Out, CancellationToken.None);
}
WebApplication app = GatewayApplication.Build(args);
app.Run();
return 0;
static void ApplyApiKeyAdminOverrides(IConfiguration configuration, ApiKeyAdminCommand command)
{
if (!string.IsNullOrWhiteSpace(command.SqlitePath))
{
configuration[$"{GatewayOptions.SectionName}:Authentication:SqlitePath"] = command.SqlitePath;
}
if (!string.IsNullOrWhiteSpace(command.Pepper))
{
configuration["MxGateway:ApiKeyPepper"] = command.Pepper;
}
}
public partial class Program;
@@ -0,0 +1,180 @@
using System.Text.Json;
namespace MxGateway.Server.Security.Authentication;
public sealed class ApiKeyAdminCliRunner(
IAuthStoreMigrator migrator,
IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore,
IApiKeySecretHasher hasher)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public async Task<int> RunAsync(
ApiKeyAdminCommand command,
TextWriter output,
CancellationToken cancellationToken)
{
ApiKeyAdminOutput result = command.Kind switch
{
ApiKeyAdminCommandKind.InitDb => await InitDbAsync(cancellationToken).ConfigureAwait(false),
ApiKeyAdminCommandKind.CreateKey => await CreateKeyAsync(command, cancellationToken).ConfigureAwait(false),
ApiKeyAdminCommandKind.ListKeys => await ListKeysAsync(cancellationToken).ConfigureAwait(false),
ApiKeyAdminCommandKind.RevokeKey => await RevokeKeyAsync(command, cancellationToken).ConfigureAwait(false),
ApiKeyAdminCommandKind.RotateKey => await RotateKeyAsync(command, cancellationToken).ConfigureAwait(false),
_ => throw new InvalidOperationException($"Unsupported API key command '{command.Kind}'.")
};
await WriteOutputAsync(command, result, output).ConfigureAwait(false);
return 0;
}
private async Task<ApiKeyAdminOutput> InitDbAsync(CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
await AppendAuditAsync(null, "init-db", null, cancellationToken).ConfigureAwait(false);
return new ApiKeyAdminOutput("init-db", "initialized", null, []);
}
private async Task<ApiKeyAdminOutput> CreateKeyAsync(
ApiKeyAdminCommand command,
CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
string keyId = Required(command.KeyId);
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(keyId, secret);
await adminStore.CreateAsync(
new ApiKeyCreateRequest(
KeyId: keyId,
KeyPrefix: $"mxgw_{keyId}",
SecretHash: hasher.HashSecret(secret),
DisplayName: Required(command.DisplayName),
Scopes: command.Scopes,
CreatedUtc: DateTimeOffset.UtcNow),
cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(keyId, "create-key", null, cancellationToken).ConfigureAwait(false);
return new ApiKeyAdminOutput("create-key", "created", apiKey, []);
}
private async Task<ApiKeyAdminOutput> ListKeysAsync(CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
IReadOnlyList<ApiKeyRecord> keys = await adminStore.ListAsync(cancellationToken).ConfigureAwait(false);
await AppendAuditAsync(null, "list-keys", null, cancellationToken).ConfigureAwait(false);
return new ApiKeyAdminOutput(
"list-keys",
"ok",
null,
keys.Select(ToListedKey).ToArray());
}
private async Task<ApiKeyAdminOutput> RevokeKeyAsync(
ApiKeyAdminCommand command,
CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
string keyId = Required(command.KeyId);
bool revoked = await adminStore.RevokeAsync(keyId, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(keyId, "revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", cancellationToken)
.ConfigureAwait(false);
return new ApiKeyAdminOutput("revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", null, []);
}
private async Task<ApiKeyAdminOutput> RotateKeyAsync(
ApiKeyAdminCommand command,
CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
string keyId = Required(command.KeyId);
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(keyId, secret);
bool rotated = await adminStore.RotateAsync(keyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(keyId, "rotate-key", rotated ? "rotated" : "not-found", cancellationToken)
.ConfigureAwait(false);
return new ApiKeyAdminOutput("rotate-key", rotated ? "rotated" : "not-found", rotated ? apiKey : null, []);
}
private static async Task WriteOutputAsync(
ApiKeyAdminCommand command,
ApiKeyAdminOutput result,
TextWriter output)
{
if (command.Json)
{
await output.WriteLineAsync(JsonSerializer.Serialize(result, JsonOptions)).ConfigureAwait(false);
return;
}
await output.WriteLineAsync($"{result.Command}: {result.Status}").ConfigureAwait(false);
if (result.ApiKey is not null)
{
await output.WriteLineAsync($"API key: {result.ApiKey}").ConfigureAwait(false);
}
foreach (ApiKeyAdminListedKey key in result.Keys)
{
string revoked = key.RevokedUtc is null ? "active" : "revoked";
await output.WriteLineAsync($"{key.KeyId}\t{key.DisplayName}\t{revoked}\t{string.Join(',', key.Scopes)}")
.ConfigureAwait(false);
}
}
private async Task AppendAuditAsync(
string? keyId,
string eventType,
string? details,
CancellationToken cancellationToken)
{
await auditStore.AppendAsync(
new ApiKeyAuditEntry(
KeyId: keyId,
EventType: eventType,
RemoteAddress: null,
Details: details),
cancellationToken)
.ConfigureAwait(false);
}
private static ApiKeyAdminListedKey ToListedKey(ApiKeyRecord key)
{
return new ApiKeyAdminListedKey(
KeyId: key.KeyId,
KeyPrefix: key.KeyPrefix,
DisplayName: key.DisplayName,
Scopes: key.Scopes,
CreatedUtc: key.CreatedUtc,
LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc);
}
private static string FormatApiKey(string keyId, string secret)
{
return $"mxgw_{keyId}_{secret}";
}
private static string Required(string? value)
{
return value ?? throw new InvalidOperationException("Required command value was not provided.");
}
}
@@ -0,0 +1,10 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ApiKeyAdminCommand(
ApiKeyAdminCommandKind Kind,
bool Json,
string? SqlitePath,
string? Pepper,
string? KeyId,
string? DisplayName,
IReadOnlySet<string> Scopes);
@@ -0,0 +1,10 @@
namespace MxGateway.Server.Security.Authentication;
public enum ApiKeyAdminCommandKind
{
InitDb,
CreateKey,
ListKeys,
RevokeKey,
RotateKey
}
@@ -0,0 +1,159 @@
namespace MxGateway.Server.Security.Authentication;
public static class ApiKeyAdminCommandLineParser
{
public static ApiKeyAdminParseResult Parse(IReadOnlyList<string> args)
{
if (args.Count == 0 || !string.Equals(args[0], "apikey", StringComparison.OrdinalIgnoreCase))
{
return ApiKeyAdminParseResult.NotApiKeyCommand();
}
if (args.Count < 2)
{
return ApiKeyAdminParseResult.Fail("Missing apikey subcommand.");
}
if (!TryParseKind(args[1], out ApiKeyAdminCommandKind kind))
{
return ApiKeyAdminParseResult.Fail($"Unknown apikey subcommand '{args[1]}'.");
}
Dictionary<string, string?> options = new(StringComparer.OrdinalIgnoreCase);
bool json = false;
for (int index = 2; index < args.Count; index++)
{
string arg = args[index];
if (string.Equals(arg, "--json", StringComparison.OrdinalIgnoreCase))
{
json = true;
continue;
}
if (!arg.StartsWith("--", StringComparison.Ordinal))
{
return ApiKeyAdminParseResult.Fail($"Unexpected argument '{arg}'.");
}
string name = arg[2..];
string? value;
int equalsIndex = name.IndexOf('=', StringComparison.Ordinal);
if (equalsIndex >= 0)
{
value = name[(equalsIndex + 1)..];
name = name[..equalsIndex];
}
else
{
if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value.");
}
value = args[++index];
}
options[name] = value;
}
string? keyId = GetOption(options, "key-id");
string? displayName = GetOption(options, "display-name");
IReadOnlySet<string> scopes = ParseScopes(GetOption(options, "scopes"));
string? validationError = Validate(kind, keyId, displayName);
if (validationError is not null)
{
return ApiKeyAdminParseResult.Fail(validationError);
}
return ApiKeyAdminParseResult.Success(new ApiKeyAdminCommand(
Kind: kind,
Json: json,
SqlitePath: GetOption(options, "sqlite-path"),
Pepper: GetOption(options, "pepper"),
KeyId: keyId,
DisplayName: displayName,
Scopes: scopes));
}
private static bool TryParseKind(string value, out ApiKeyAdminCommandKind kind)
{
switch (value.ToLowerInvariant())
{
case "init-db":
kind = ApiKeyAdminCommandKind.InitDb;
return true;
case "create-key":
kind = ApiKeyAdminCommandKind.CreateKey;
return true;
case "list-keys":
kind = ApiKeyAdminCommandKind.ListKeys;
return true;
case "revoke-key":
kind = ApiKeyAdminCommandKind.RevokeKey;
return true;
case "rotate-key":
kind = ApiKeyAdminCommandKind.RotateKey;
return true;
default:
kind = default;
return false;
}
}
private static string? Validate(ApiKeyAdminCommandKind kind, string? keyId, string? displayName)
{
if (kind is ApiKeyAdminCommandKind.CreateKey or ApiKeyAdminCommandKind.RevokeKey or ApiKeyAdminCommandKind.RotateKey
&& string.IsNullOrWhiteSpace(keyId))
{
return $"Subcommand '{KindName(kind)}' requires --key-id.";
}
if (!string.IsNullOrWhiteSpace(keyId) && !IsValidKeyId(keyId))
{
return "API key id may contain only letters, numbers, periods, and hyphens.";
}
if (kind == ApiKeyAdminCommandKind.CreateKey && string.IsNullOrWhiteSpace(displayName))
{
return "Subcommand 'create-key' requires --display-name.";
}
return null;
}
private static string KindName(ApiKeyAdminCommandKind kind)
{
return kind switch
{
ApiKeyAdminCommandKind.InitDb => "init-db",
ApiKeyAdminCommandKind.CreateKey => "create-key",
ApiKeyAdminCommandKind.ListKeys => "list-keys",
ApiKeyAdminCommandKind.RevokeKey => "revoke-key",
ApiKeyAdminCommandKind.RotateKey => "rotate-key",
_ => kind.ToString()
};
}
private static bool IsValidKeyId(string keyId)
{
return keyId.All(character =>
char.IsAsciiLetterOrDigit(character)
|| character is '.' or '-');
}
private static string? GetOption(Dictionary<string, string?> options, string name)
{
return options.TryGetValue(name, out string? value) ? value : null;
}
private static IReadOnlySet<string> ParseScopes(string? scopes)
{
return new HashSet<string>(
(scopes ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
StringComparer.Ordinal);
}
}
@@ -0,0 +1,10 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ApiKeyAdminListedKey(
string KeyId,
string KeyPrefix,
string DisplayName,
IReadOnlySet<string> Scopes,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc,
DateTimeOffset? RevokedUtc);
@@ -0,0 +1,7 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ApiKeyAdminOutput(
string Command,
string Status,
string? ApiKey,
IReadOnlyList<ApiKeyAdminListedKey> Keys);
@@ -0,0 +1,22 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ApiKeyAdminParseResult(
bool IsApiKeyCommand,
ApiKeyAdminCommand? Command,
string? Error)
{
public static ApiKeyAdminParseResult NotApiKeyCommand()
{
return new ApiKeyAdminParseResult(false, null, null);
}
public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
{
return new ApiKeyAdminParseResult(true, command, null);
}
public static ApiKeyAdminParseResult Fail(string error)
{
return new ApiKeyAdminParseResult(true, null, error);
}
}
@@ -0,0 +1,9 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ApiKeyCreateRequest(
string KeyId,
string KeyPrefix,
byte[] SecretHash,
string DisplayName,
IReadOnlySet<string> Scopes,
DateTimeOffset CreatedUtc);
@@ -0,0 +1,7 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ApiKeyIdentity(
string KeyId,
string KeyPrefix,
string DisplayName,
IReadOnlySet<string> Scopes);
@@ -0,0 +1,45 @@
namespace MxGateway.Server.Security.Authentication;
public sealed class ApiKeyParser : IApiKeyParser
{
private const string BearerPrefix = "Bearer ";
private const string TokenPrefix = "mxgw_";
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
{
apiKey = null;
if (string.IsNullOrWhiteSpace(authorizationHeader)
|| !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string token = authorizationHeader[BearerPrefix.Length..].Trim();
if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string keyPayload = token[TokenPrefix.Length..];
int separatorIndex = keyPayload.IndexOf('_', StringComparison.Ordinal);
if (separatorIndex <= 0 || separatorIndex == keyPayload.Length - 1)
{
return false;
}
string keyId = keyPayload[..separatorIndex];
string secret = keyPayload[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secret))
{
return false;
}
apiKey = new ParsedApiKey(keyId, secret);
return true;
}
}
@@ -0,0 +1,4 @@
namespace MxGateway.Server.Security.Authentication;
public sealed class ApiKeyPepperUnavailableException(string pepperSecretName)
: InvalidOperationException($"API key pepper secret '{pepperSecretName}' is not configured.");
@@ -0,0 +1,26 @@
using Microsoft.Data.Sqlite;
namespace MxGateway.Server.Security.Authentication;
public static class ApiKeyRecordReader
{
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)),
CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture),
LastUsedUtc: ReadNullableDateTimeOffset(reader, 6),
RevokedUtc: ReadNullableDateTimeOffset(reader, 7));
}
private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal)
? null
: DateTimeOffset.Parse(reader.GetString(ordinal), System.Globalization.CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,17 @@
using System.Security.Cryptography;
namespace MxGateway.Server.Security.Authentication;
public static class ApiKeySecretGenerator
{
public static string Generate()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}
@@ -0,0 +1,35 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
namespace MxGateway.Server.Security.Authentication;
public sealed class ApiKeySecretHasher(
IConfiguration configuration,
IOptions<GatewayOptions> options) : IApiKeySecretHasher
{
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);
}
private string GetPepper()
{
string pepperSecretName = options.Value.Authentication.PepperSecretName;
string? pepper = configuration[pepperSecretName];
if (string.IsNullOrWhiteSpace(pepper))
{
throw new ApiKeyPepperUnavailableException(pepperSecretName);
}
return pepper;
}
}
@@ -0,0 +1,11 @@
namespace MxGateway.Server.Security.Authentication;
public enum ApiKeyVerificationFailure
{
None,
MissingOrMalformedCredentials,
PepperUnavailable,
KeyNotFound,
KeyRevoked,
SecretMismatch
}
@@ -0,0 +1,23 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ApiKeyVerificationResult(
bool Succeeded,
ApiKeyIdentity? Identity,
ApiKeyVerificationFailure Failure)
{
public static ApiKeyVerificationResult Success(ApiKeyIdentity identity)
{
return new ApiKeyVerificationResult(
Succeeded: true,
Identity: identity,
Failure: ApiKeyVerificationFailure.None);
}
public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure)
{
return new ApiKeyVerificationResult(
Succeeded: false,
Identity: null,
Failure: failure);
}
}
@@ -0,0 +1,57 @@
using System.Security.Cryptography;
namespace MxGateway.Server.Security.Authentication;
public sealed class ApiKeyVerifier(
IApiKeyParser parser,
IApiKeySecretHasher hasher,
IApiKeyStore keyStore) : IApiKeyVerifier
{
public async Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
{
if (!parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? parsedKey)
|| parsedKey is null)
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.MissingOrMalformedCredentials);
}
ApiKeyRecord? storedKey = await keyStore.FindByKeyIdAsync(parsedKey.KeyId, cancellationToken)
.ConfigureAwait(false);
if (storedKey is null)
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyNotFound);
}
if (storedKey.RevokedUtc is not null)
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyRevoked);
}
byte[] presentedHash;
try
{
presentedHash = hasher.HashSecret(parsedKey.Secret);
}
catch (ApiKeyPepperUnavailableException)
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.PepperUnavailable);
}
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
}
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
KeyId: storedKey.KeyId,
KeyPrefix: storedKey.KeyPrefix,
DisplayName: storedKey.DisplayName,
Scopes: storedKey.Scopes));
}
}
@@ -4,9 +4,14 @@ public static class AuthStoreServiceCollectionExtensions
{
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>();
@@ -0,0 +1,16 @@
namespace MxGateway.Server.Security.Authentication;
public interface IApiKeyAdminStore
{
Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken);
Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken);
Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken);
Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
namespace MxGateway.Server.Security.Authentication;
public interface IApiKeyParser
{
bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey);
}
@@ -0,0 +1,6 @@
namespace MxGateway.Server.Security.Authentication;
public interface IApiKeySecretHasher
{
byte[] HashSecret(string secret);
}
@@ -0,0 +1,8 @@
namespace MxGateway.Server.Security.Authentication;
public interface IApiKeyVerifier
{
Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken);
}
@@ -0,0 +1,3 @@
namespace MxGateway.Server.Security.Authentication;
public sealed record ParsedApiKey(string KeyId, string Secret);
@@ -0,0 +1,116 @@
using Microsoft.Data.Sqlite;
namespace MxGateway.Server.Security.Authentication;
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
{
public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
INSERT INTO api_keys (
key_id,
key_prefix,
secret_hash,
display_name,
scopes,
created_utc,
last_used_utc,
revoked_utc)
VALUES (
$key_id,
$key_prefix,
$secret_hash,
$display_name,
$scopes,
$created_utc,
NULL,
NULL);
""";
AddCreateParameters(command, request);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
FROM api_keys
ORDER BY key_id;
""";
List<ApiKeyRecord> records = [];
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
.ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
records.Add(ApiKeyRecordReader.Read(reader));
}
return records;
}
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET revoked_utc = $revoked_utc
WHERE key_id = $key_id AND revoked_utc IS NULL;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.AddWithValue("$revoked_utc", revokedUtc.ToString("O"));
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET secret_hash = $secret_hash,
last_used_utc = NULL,
revoked_utc = NULL
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = secretHash;
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static void AddCreateParameters(SqliteCommand command, ApiKeyCreateRequest request)
{
command.Parameters.AddWithValue("$key_id", request.KeyId);
command.Parameters.AddWithValue("$key_prefix", request.KeyPrefix);
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash;
command.Parameters.AddWithValue("$display_name", request.DisplayName);
command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes));
command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O"));
}
}
@@ -61,26 +61,6 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
return null;
}
return ReadApiKeyRecord(reader);
}
private static ApiKeyRecord ReadApiKeyRecord(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)),
CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture),
LastUsedUtc: ReadNullableDateTimeOffset(reader, 6),
RevokedUtc: ReadNullableDateTimeOffset(reader, 7));
}
private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal)
? null
: DateTimeOffset.Parse(reader.GetString(ordinal), System.Globalization.CultureInfo.InvariantCulture);
return ApiKeyRecordReader.Read(reader);
}
}
@@ -0,0 +1,74 @@
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Security.Authentication;
namespace MxGateway.Server.Security.Authorization;
public sealed class GatewayGrpcAuthorizationInterceptor(
IApiKeyVerifier apiKeyVerifier,
GatewayGrpcScopeResolver scopeResolver,
IGatewayRequestIdentityAccessor identityAccessor,
IOptions<GatewayOptions> options) : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false);
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
using (identityScope)
{
return await continuation(request, context).ConfigureAwait(false);
}
}
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false);
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
using (identityScope)
{
await continuation(request, responseStream, context).ConfigureAwait(false);
}
}
private async Task<ApiKeyIdentity?> AuthenticateAndAuthorizeAsync<TRequest>(
TRequest request,
ServerCallContext context)
where TRequest : class
{
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
{
return null;
}
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
.VerifyAsync(authorizationHeader, context.CancellationToken)
.ConfigureAwait(false);
if (!verificationResult.Succeeded || verificationResult.Identity is null)
{
throw new RpcException(new Status(
StatusCode.Unauthenticated,
"Missing or invalid API key."));
}
string requiredScope = scopeResolver.ResolveRequiredScope(request);
if (!verificationResult.Identity.Scopes.Contains(requiredScope))
{
throw new RpcException(new Status(
StatusCode.PermissionDenied,
$"API key is missing required scope '{requiredScope}'."));
}
return verificationResult.Identity;
}
}
@@ -0,0 +1,40 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Security.Authorization;
public sealed class GatewayGrpcScopeResolver
{
public string ResolveRequiredScope(object request)
{
return request switch
{
OpenSessionRequest => GatewayScopes.SessionOpen,
CloseSessionRequest => GatewayScopes.SessionClose,
StreamEventsRequest => GatewayScopes.EventsRead,
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
_ => GatewayScopes.Admin
};
}
private static string ResolveCommandScope(MxCommandKind kind)
{
return kind switch
{
MxCommandKind.Write or
MxCommandKind.Write2 => GatewayScopes.InvokeWrite,
MxCommandKind.WriteSecured or
MxCommandKind.WriteSecured2 or
MxCommandKind.AuthenticateUser => GatewayScopes.InvokeSecure,
MxCommandKind.ArchestraUserToId or
MxCommandKind.GetSessionState or
MxCommandKind.GetWorkerInfo => GatewayScopes.MetadataRead,
MxCommandKind.DrainEvents => GatewayScopes.EventsRead,
MxCommandKind.ShutdownWorker => GatewayScopes.Admin,
_ => GatewayScopes.InvokeRead
};
}
}
@@ -0,0 +1,38 @@
using MxGateway.Server.Security.Authentication;
namespace MxGateway.Server.Security.Authorization;
public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAccessor
{
private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new();
public ApiKeyIdentity? Current => currentIdentity.Value;
public IDisposable Push(ApiKeyIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
ApiKeyIdentity? previousIdentity = currentIdentity.Value;
currentIdentity.Value = identity;
return new IdentityScope(this, previousIdentity);
}
private sealed class IdentityScope(
GatewayRequestIdentityAccessor accessor,
ApiKeyIdentity? previousIdentity) : IDisposable
{
private bool disposed;
public void Dispose()
{
if (disposed)
{
return;
}
accessor.currentIdentity.Value = previousIdentity;
disposed = true;
}
}
}
@@ -0,0 +1,13 @@
namespace MxGateway.Server.Security.Authorization;
public static class GatewayScopes
{
public const string SessionOpen = "session:open";
public const string SessionClose = "session:close";
public const string InvokeRead = "invoke:read";
public const string InvokeWrite = "invoke:write";
public const string InvokeSecure = "invoke:secure";
public const string EventsRead = "events:read";
public const string MetadataRead = "metadata:read";
public const string Admin = "admin";
}
@@ -0,0 +1,16 @@
using Grpc.Core.Interceptors;
namespace MxGateway.Server.Security.Authorization;
public static class GrpcAuthorizationServiceCollectionExtensions
{
public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services)
{
services.AddSingleton<GatewayGrpcScopeResolver>();
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
return services;
}
}
@@ -0,0 +1,10 @@
using MxGateway.Server.Security.Authentication;
namespace MxGateway.Server.Security.Authorization;
public interface IGatewayRequestIdentityAccessor
{
ApiKeyIdentity? Current { get; }
IDisposable Push(ApiKeyIdentity identity);
}
@@ -0,0 +1,352 @@
using MxGateway.Contracts.Proto;
using MxGateway.Server.Workers;
namespace MxGateway.Server.Sessions;
public sealed class GatewaySession
{
private readonly object _syncRoot = new();
private readonly SemaphoreSlim _closeLock = new(1, 1);
private IWorkerClient? _workerClient;
private SessionState _state = SessionState.Creating;
private string? _finalFault;
private DateTimeOffset _lastClientActivityAt;
private DateTimeOffset? _leaseExpiresAt;
private bool _closeStarted;
private int _activeEventSubscriberCount;
public GatewaySession(
string sessionId,
string backendName,
string pipeName,
string nonce,
string? clientIdentity,
string? clientSessionName,
string? clientCorrelationId,
TimeSpan commandTimeout,
TimeSpan startupTimeout,
TimeSpan shutdownTimeout,
DateTimeOffset openedAt)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new ArgumentException("Session id is required.", nameof(sessionId));
}
if (string.IsNullOrWhiteSpace(backendName))
{
throw new ArgumentException("Backend name is required.", nameof(backendName));
}
if (string.IsNullOrWhiteSpace(pipeName))
{
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
}
if (string.IsNullOrWhiteSpace(nonce))
{
throw new ArgumentException("Nonce is required.", nameof(nonce));
}
SessionId = sessionId;
BackendName = backendName;
PipeName = pipeName;
Nonce = nonce;
ClientIdentity = clientIdentity;
ClientSessionName = clientSessionName;
ClientCorrelationId = clientCorrelationId;
CommandTimeout = commandTimeout;
StartupTimeout = startupTimeout;
ShutdownTimeout = shutdownTimeout;
OpenedAt = openedAt;
_lastClientActivityAt = openedAt;
}
public string SessionId { get; }
public string BackendName { get; }
public string PipeName { get; }
public string Nonce { get; }
public string? ClientIdentity { get; }
public string? ClientSessionName { get; }
public string? ClientCorrelationId { get; }
public TimeSpan CommandTimeout { get; }
public TimeSpan StartupTimeout { get; }
public TimeSpan ShutdownTimeout { get; }
public DateTimeOffset OpenedAt { get; }
public int? WorkerProcessId => _workerClient?.ProcessId;
public IWorkerClient? WorkerClient => _workerClient;
public SessionState State
{
get
{
lock (_syncRoot)
{
return _state;
}
}
}
public DateTimeOffset LastClientActivityAt
{
get
{
lock (_syncRoot)
{
return _lastClientActivityAt;
}
}
}
public DateTimeOffset? LeaseExpiresAt
{
get
{
lock (_syncRoot)
{
return _leaseExpiresAt;
}
}
}
public string? FinalFault
{
get
{
lock (_syncRoot)
{
return _finalFault;
}
}
}
public int ActiveEventSubscriberCount
{
get
{
lock (_syncRoot)
{
return _activeEventSubscriberCount;
}
}
}
public void AttachWorkerClient(IWorkerClient workerClient)
{
ArgumentNullException.ThrowIfNull(workerClient);
lock (_syncRoot)
{
_workerClient = workerClient;
}
}
public void TransitionTo(SessionState nextState)
{
lock (_syncRoot)
{
if (_state is SessionState.Closed)
{
return;
}
if (_state is SessionState.Faulted && nextState is not SessionState.Closed)
{
return;
}
_state = nextState;
}
}
public void MarkReady()
{
TransitionTo(SessionState.Ready);
}
public void MarkFaulted(string reason)
{
lock (_syncRoot)
{
if (_state is SessionState.Closed)
{
return;
}
_finalFault = reason;
_state = SessionState.Faulted;
}
}
public void TouchClientActivity(DateTimeOffset activityAt)
{
lock (_syncRoot)
{
_lastClientActivityAt = activityAt;
}
}
public void ExtendLease(DateTimeOffset leaseExpiresAt)
{
lock (_syncRoot)
{
_leaseExpiresAt = leaseExpiresAt;
}
}
public bool IsLeaseExpired(DateTimeOffset now)
{
lock (_syncRoot)
{
return _leaseExpiresAt is not null && _leaseExpiresAt <= now;
}
}
public IDisposable AttachEventSubscriber(bool allowMultipleSubscribers)
{
lock (_syncRoot)
{
if (_state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready)
{
throw new SessionManagerException(
SessionManagerErrorCode.SessionNotReady,
$"Session {SessionId} is not ready for event streaming. Current state is {_state}.");
}
if (!allowMultipleSubscribers && _activeEventSubscriberCount > 0)
{
throw new SessionManagerException(
SessionManagerErrorCode.EventSubscriberAlreadyActive,
$"Session {SessionId} already has an active event stream subscriber.");
}
_activeEventSubscriberCount++;
return new EventSubscriberLease(this);
}
}
public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
CancellationToken cancellationToken)
{
IWorkerClient workerClient = GetReadyWorkerClient();
TouchClientActivity(DateTimeOffset.UtcNow);
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
}
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken)
{
IWorkerClient workerClient = GetReadyWorkerClient();
TouchClientActivity(DateTimeOffset.UtcNow);
return workerClient.ReadEventsAsync(cancellationToken);
}
public async Task<SessionCloseResult> CloseAsync(
string reason,
CancellationToken cancellationToken)
{
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_state is SessionState.Closed)
{
return new SessionCloseResult(SessionId, SessionState.Closed, AlreadyClosed: true);
}
bool alreadyClosing = _closeStarted;
_closeStarted = true;
_state = SessionState.Closing;
if (_workerClient is not null)
{
try
{
await _workerClient.ShutdownAsync(ShutdownTimeout, cancellationToken).ConfigureAwait(false);
}
catch
{
_workerClient.Kill(reason);
throw;
}
}
_state = SessionState.Closed;
return new SessionCloseResult(SessionId, SessionState.Closed, alreadyClosing);
}
finally
{
_closeLock.Release();
}
}
public void KillWorker(string reason)
{
_workerClient?.Kill(reason);
TransitionTo(SessionState.Closed);
}
public async ValueTask DisposeAsync()
{
_closeLock.Dispose();
if (_workerClient is not null)
{
await _workerClient.DisposeAsync().ConfigureAwait(false);
}
}
private IWorkerClient GetReadyWorkerClient()
{
lock (_syncRoot)
{
if (_state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready)
{
throw new SessionManagerException(
SessionManagerErrorCode.SessionNotReady,
$"Session {SessionId} is not ready. Current state is {_state}.");
}
return _workerClient;
}
}
private void DetachEventSubscriber()
{
lock (_syncRoot)
{
if (_activeEventSubscriberCount > 0)
{
_activeEventSubscriberCount--;
}
}
}
private sealed class EventSubscriberLease(GatewaySession session) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (_disposed)
{
return;
}
session.DetachEventSubscriber();
_disposed = true;
}
}
}
@@ -0,0 +1,34 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Sessions;
public interface ISessionManager
{
Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
CancellationToken cancellationToken);
bool TryGetSession(
string sessionId,
out GatewaySession session);
Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken);
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken);
Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken);
Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken);
Task ShutdownAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,16 @@
namespace MxGateway.Server.Sessions;
public interface ISessionRegistry
{
int Count { get; }
int ActiveCount { get; }
bool TryAdd(GatewaySession session);
bool TryGet(string sessionId, out GatewaySession session);
bool TryRemove(string sessionId, out GatewaySession session);
IReadOnlyCollection<GatewaySession> Snapshot();
}
@@ -0,0 +1,8 @@
namespace MxGateway.Server.Sessions;
public interface ISessionWorkerClientFactory
{
Task<MxGateway.Server.Workers.IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Sessions;
public sealed record SessionCloseResult(
string SessionId,
SessionState FinalState,
bool AlreadyClosed);

Some files were not shown because too many files have changed in this diff Show More