Compare commits
138 Commits
b4bc2df015
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 410acc92eb | |||
| b40aaeef05 | |||
| 9208225f9c | |||
| c6f17557f6 | |||
| bbbef4d098 | |||
| 4af24b9518 | |||
| 371ce53409 | |||
| 597677025f | |||
| 393e326275 | |||
| 986dcee14a | |||
| a3752799de | |||
| 37aadf72b3 | |||
| 5573f2a229 | |||
| 56abd64c6c | |||
| 5b31e99ab6 | |||
| 64db828d71 | |||
| 1a9367b5de | |||
| 98e997b573 | |||
| 0e8d911fd8 | |||
| e72763d703 | |||
| 3c9becc8d6 | |||
| ec88532fe4 | |||
| 2f30f0c7c0 | |||
| 27f6c9e6b7 | |||
| 29bd504a99 | |||
| e10b252e3a | |||
| bcc54ca56b | |||
| ee459f43e1 | |||
| ebf1d95f72 | |||
| 3ccf0b5f9e | |||
| f7ccfd678e | |||
| 3f5e5fc0b3 | |||
| 7241a4fb9c | |||
| d6c0bb41ca | |||
| 0a54c0bc4b | |||
| fd64b9260c | |||
| 4bd757a136 | |||
| 1e2ed6d1ea | |||
| 5f6655de27 | |||
| fbc9cf56df | |||
| 4c0e14fc5d | |||
| c75920c620 | |||
| a46ce90e6f | |||
| f113ca53a1 | |||
| f3616cc7fa | |||
| 57d5a8725f | |||
| 60d35a914f | |||
| b10e103bcf | |||
| 348ab16456 | |||
| c16f016f0a | |||
| 1d85db7b4e | |||
| 5ea5618315 | |||
| 38a0ad8ab4 | |||
| 5df2ef0d1e | |||
| e5785fd769 | |||
| 22370ca4da | |||
| e0a3fbf35b | |||
| 161ed6f80d | |||
| e57d864ab2 | |||
| 5539ec8542 | |||
| 73e54e252d | |||
| 70d959bd9b | |||
| 0c5b796e2e | |||
| 47dc9d865f | |||
| 4f757e3c0c | |||
| 2f0ee4c961 | |||
| 0859d47f75 | |||
| 7ea8358c06 | |||
| a5944bbe5d | |||
| 04bce3ff9f | |||
| 9572045787 | |||
| 7e1af37eb1 | |||
| 05009d7370 | |||
| f4dc11bae4 | |||
| c3b466e13d | |||
| 792e3f9445 | |||
| ae281d06bb | |||
| 3ca2799c90 | |||
| 459a88b3e7 | |||
| 437ab65fc1 | |||
| 679562e5ed | |||
| dbf550da8b | |||
| 3965a7741e | |||
| abb2cfb84b | |||
| 4e0d8ccfed | |||
| a935aa8b7c | |||
| 9912389fa1 | |||
| f1129b969d | |||
| c51b6f9ce4 | |||
| e39972357b | |||
| 9ad17e2964 | |||
| ef0a883a81 | |||
| 62ba5e9487 | |||
| 136614be94 | |||
| a912bffad5 | |||
| 9bdb899774 | |||
| e5c704de69 | |||
| 4e520f9c0c | |||
| 2eb81379e4 | |||
| ddd5721082 | |||
| 3775f6bf3b | |||
| cdfad420bb | |||
| 330e665f6b | |||
| 5e01ad9c22 | |||
| 77a9108673 | |||
| 192607ab8c | |||
| ba82afe669 | |||
| fe7d1ce1ec | |||
| b8a6695612 | |||
| 6f9188bc8d | |||
| a276f46f81 | |||
| 572b268d81 | |||
| 4c093a64fa | |||
| f47bbaea95 | |||
| c463b49f46 | |||
| 87f86503ef | |||
| e912ef960c | |||
| c4e7ddea70 | |||
| 6bfa4fe884 | |||
| b4a7bac4c0 | |||
| 6df373ae4c | |||
| fe44e3c18a | |||
| 523f944f3e | |||
| c33f1e6047 | |||
| 92cc4688e6 | |||
| a155554038 | |||
| 68f905a344 | |||
| 5abc222c72 | |||
| da3aa7b0b2 | |||
| f0ec068430 | |||
| 1a1d14a9fd | |||
| b2448510ac | |||
| 75610e3f55 | |||
| 5032166106 | |||
| 76a042d663 | |||
| 4a19854eb9 | |||
| a4467e23ef | |||
| eacfeff9fb |
@@ -147,3 +147,8 @@ generated-scratch/
|
|||||||
|
|
||||||
# Keep empty directories with .gitkeep files when needed
|
# Keep empty directories with .gitkeep files when needed
|
||||||
!.gitkeep
|
!.gitkeep
|
||||||
|
|
||||||
|
# Documentation review artifacts (CommentChecker output)
|
||||||
|
*-docs-issues.md
|
||||||
|
*-docs-fixed.md
|
||||||
|
*-docs-final.md
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
|
|||||||
## Design Sources To Consult Before Non-Trivial Changes
|
## Design Sources To Consult Before Non-Trivial Changes
|
||||||
|
|
||||||
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
||||||
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=lmxopcua,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
- `glauth.md` — shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) and the role→capability mapping live there.
|
||||||
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||||
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
||||||
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
|
||||||
|
<Authors>Joseph Doherty</Authors>
|
||||||
|
<Company>ZB MOM WW</Company>
|
||||||
|
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
||||||
|
<Product>MxAccessGateway Client</Product>
|
||||||
|
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
|
||||||
|
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
|
||||||
|
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
|
||||||
|
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<IncludeSymbols>true</IncludeSymbols>
|
||||||
|
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<!-- Default: do NOT pack. Each project opts in. -->
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -107,6 +107,7 @@ public sealed class MxGatewayClientOptions
|
|||||||
public required string ApiKey { get; init; }
|
public required string ApiKey { get; init; }
|
||||||
public bool UseTls { get; init; }
|
public bool UseTls { get; init; }
|
||||||
public string? CaCertificatePath { get; init; }
|
public string? CaCertificatePath { get; init; }
|
||||||
|
public bool RequireCertificateValidation { get; init; }
|
||||||
public string? ServerNameOverride { get; init; }
|
public string? ServerNameOverride { get; init; }
|
||||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
@@ -124,6 +125,24 @@ or subscription changes because those calls can partially succeed in MXAccess.
|
|||||||
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
|
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
|
||||||
library constructor unless a helper explicitly says it does that.
|
library constructor unless a helper explicitly says it does that.
|
||||||
|
|
||||||
|
### TLS trust posture
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). To make that usable, TLS is **lenient by default**: when `UseTls` is set
|
||||||
|
and `CaCertificatePath` is empty, `CreateHttpHandler` installs a
|
||||||
|
`RemoteCertificateValidationCallback` that returns `true`, so the gateway's
|
||||||
|
self-signed certificate is accepted without verification.
|
||||||
|
|
||||||
|
To verify the gateway instead:
|
||||||
|
|
||||||
|
- set `CaCertificatePath` to pin a CA — validated via a `CustomRootTrust`
|
||||||
|
`X509Chain` against that root, and the callback additionally rejects a
|
||||||
|
hostname/SAN mismatch (`RemoteCertificateNameMismatch`); or
|
||||||
|
- set `RequireCertificateValidation` to `true` to keep the default OS/system-trust
|
||||||
|
verification on a connection with no pinned CA.
|
||||||
|
|
||||||
|
Pinning a CA always wins over the lenient default.
|
||||||
|
|
||||||
## Auth Interceptor
|
## Auth Interceptor
|
||||||
|
|
||||||
Use a gRPC call credentials/interceptor layer to attach:
|
Use a gRPC call credentials/interceptor layer to attach:
|
||||||
|
|||||||
@@ -218,6 +218,32 @@ for (int i = 0; i < roots.Children.Count; i++)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
|
||||||
|
new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey });
|
||||||
|
IReadOnlyList<LazyBrowseNode> roots = await repository.BrowseAsync();
|
||||||
|
foreach (LazyBrowseNode root in roots)
|
||||||
|
{
|
||||||
|
if (root.HasChildrenHint)
|
||||||
|
{
|
||||||
|
await root.ExpandAsync();
|
||||||
|
}
|
||||||
|
foreach (LazyBrowseNode child in root.Children)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{child.Object.TagName} ({(child.HasChildrenHint ? "has children" : "leaf")})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ExpandAsync` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`BrowseAsync` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
|
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
|
||||||
@@ -261,6 +287,17 @@ Use TLS options for a secured gateway:
|
|||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### TLS trust
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||||
|
the client is **lenient by default**: a TLS connection (`UseTls` / `--tls`) with
|
||||||
|
no pinned CA accepts whatever certificate the gateway presents. To verify
|
||||||
|
instead, pin a CA with `CaCertificatePath` / `--ca-file` (this path also enforces
|
||||||
|
the certificate hostname/SAN match), or set `RequireCertificateValidation` to
|
||||||
|
force OS/system-trust verification without pinning. Use `ServerNameOverride` /
|
||||||
|
`--server-name` when the dialed host differs from the certificate SAN. See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
## Integration Checks
|
## Integration Checks
|
||||||
|
|
||||||
Run live checks only when a gateway and MXAccess-backed worker are available:
|
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||||
@@ -273,6 +310,29 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
|||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installing as a NuGet Package
|
||||||
|
|
||||||
|
The client publishes to the internal Gitea NuGet feed at
|
||||||
|
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`.
|
||||||
|
|
||||||
|
Add the feed once:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
|
||||||
|
--name dohertj2-gitea \
|
||||||
|
--username <gitea-username> \
|
||||||
|
--password <gitea-token-or-password> \
|
||||||
|
--store-password-in-clear-text
|
||||||
|
````
|
||||||
|
|
||||||
|
Then add the package to your project:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.0
|
||||||
|
````
|
||||||
|
|
||||||
|
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ public sealed class LazyBrowseNodeTests
|
|||||||
new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
|
new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
|
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
|
||||||
|
Assert.False(roots[0].IsExpanded);
|
||||||
|
Assert.Empty(roots[0].Children);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -142,6 +144,37 @@ public sealed class LazyBrowseNodeTests
|
|||||||
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
|
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(1, "Plant", isArea: true)],
|
||||||
|
childHasChildren: [true],
|
||||||
|
cacheSequence: 7));
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(2, "Mixer_001")],
|
||||||
|
childHasChildren: [false],
|
||||||
|
cacheSequence: 7));
|
||||||
|
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
||||||
|
|
||||||
|
// Fire ten concurrent expands of the same node.
|
||||||
|
Task[] tasks = Enumerable.Range(0, 10)
|
||||||
|
.Select(_ => roots[0].ExpandAsync())
|
||||||
|
.ToArray();
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
Assert.True(roots[0].IsExpanded);
|
||||||
|
Assert.Single(roots[0].Children);
|
||||||
|
// 1 roots fetch + exactly 1 expand fetch = 2 total
|
||||||
|
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Security;
|
||||||
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientTlsHandlerTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
|
||||||
|
/// the handler installs an accept-all callback so the gateway's self-signed cert is trusted.
|
||||||
|
/// The callback must return true regardless of chain errors.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions options = new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("https://localhost:5120"),
|
||||||
|
ApiKey = "k",
|
||||||
|
UseTls = true,
|
||||||
|
};
|
||||||
|
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
|
||||||
|
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||||
|
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when RequireCertificateValidation is true, the callback is left null
|
||||||
|
/// so the OS trust store performs validation.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions options = new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("https://localhost:5120"),
|
||||||
|
ApiKey = "k",
|
||||||
|
UseTls = true,
|
||||||
|
RequireCertificateValidation = true,
|
||||||
|
};
|
||||||
|
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
|
||||||
|
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GalaxyRepositoryClientTlsHandlerTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
|
||||||
|
/// the Galaxy client handler installs an accept-all callback so the gateway's self-signed cert is trusted.
|
||||||
|
/// The callback must return true regardless of chain errors.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions options = new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("https://localhost:5120"),
|
||||||
|
ApiKey = "k",
|
||||||
|
UseTls = true,
|
||||||
|
};
|
||||||
|
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
|
||||||
|
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||||
|
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null
|
||||||
|
/// so the OS trust store performs validation.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions options = new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("https://localhost:5120"),
|
||||||
|
ApiKey = "k",
|
||||||
|
UseTls = true,
|
||||||
|
RequireCertificateValidation = true,
|
||||||
|
};
|
||||||
|
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
|
||||||
|
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -490,7 +490,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||||
|
CreateHttpHandlerForTests(options);
|
||||||
|
|
||||||
|
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
{
|
{
|
||||||
@@ -510,6 +513,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||||
{
|
{
|
||||||
|
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (certificate is null)
|
if (certificate is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -525,6 +533,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
return customChain.Build(certificateToValidate);
|
return customChain.Build(certificateToValidate);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (!options.RequireCertificateValidation)
|
||||||
|
{
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler;
|
return handler;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public sealed class LazyBrowseNode
|
|||||||
private readonly GalaxyRepositoryClient _client;
|
private readonly GalaxyRepositoryClient _client;
|
||||||
private readonly BrowseChildrenOptions _options;
|
private readonly BrowseChildrenOptions _options;
|
||||||
private readonly List<LazyBrowseNode> _children = [];
|
private readonly List<LazyBrowseNode> _children = [];
|
||||||
|
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
||||||
private bool _isExpanded;
|
private bool _isExpanded;
|
||||||
|
|
||||||
internal LazyBrowseNode(
|
internal LazyBrowseNode(
|
||||||
@@ -43,6 +44,10 @@ public sealed class LazyBrowseNode
|
|||||||
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
|
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
|
||||||
/// Idempotent: subsequent calls are no-ops.
|
/// Idempotent: subsequent calls are no-ops.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
|
||||||
|
/// (after the first completes) return immediately.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -51,33 +56,46 @@ public sealed class LazyBrowseNode
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
string pageToken = string.Empty;
|
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
try
|
||||||
do
|
|
||||||
{
|
{
|
||||||
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
|
if (_isExpanded)
|
||||||
request.ParentGobjectId = Object.GobjectId;
|
|
||||||
request.PageToken = pageToken;
|
|
||||||
|
|
||||||
BrowseChildrenReply reply = await _client
|
|
||||||
.BrowseChildrenRawAsync(request, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
for (int i = 0; i < reply.Children.Count; i++)
|
|
||||||
{
|
{
|
||||||
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
return;
|
||||||
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pageToken = reply.NextPageToken;
|
string pageToken = string.Empty;
|
||||||
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
|
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||||
|
do
|
||||||
{
|
{
|
||||||
throw new MxGatewayException(
|
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
|
||||||
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
|
request.ParentGobjectId = Object.GobjectId;
|
||||||
|
request.PageToken = pageToken;
|
||||||
|
|
||||||
|
BrowseChildrenReply reply = await _client
|
||||||
|
.BrowseChildrenRawAsync(request, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
for (int i = 0; i < reply.Children.Count; i++)
|
||||||
|
{
|
||||||
|
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
||||||
|
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = reply.NextPageToken;
|
||||||
|
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
|
||||||
|
{
|
||||||
|
throw new MxGatewayException(
|
||||||
|
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
while (!string.IsNullOrWhiteSpace(pageToken));
|
||||||
|
|
||||||
|
_isExpanded = true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_expandLock.Release();
|
||||||
}
|
}
|
||||||
while (!string.IsNullOrWhiteSpace(pageToken));
|
|
||||||
|
|
||||||
_isExpanded = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,7 +315,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||||
|
CreateHttpHandlerForTests(options);
|
||||||
|
|
||||||
|
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
{
|
{
|
||||||
@@ -335,6 +338,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||||
{
|
{
|
||||||
|
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (certificate is null)
|
if (certificate is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -350,6 +358,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
return customChain.Build(certificateToValidate);
|
return customChain.Build(certificateToValidate);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (!options.RequireCertificateValidation)
|
||||||
|
{
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler;
|
return handler;
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ namespace ZB.MOM.WW.MxGateway.Client;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class MxGatewayClientContractInfo
|
public static class MxGatewayClientContractInfo
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
|
||||||
public const uint GatewayProtocolVersion =
|
public const uint GatewayProtocolVersion =
|
||||||
GatewayContractInfo.GatewayProtocolVersion;
|
GatewayContractInfo.GatewayProtocolVersion;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
|
||||||
public const uint WorkerProtocolVersion =
|
public const uint WorkerProtocolVersion =
|
||||||
GatewayContractInfo.WorkerProtocolVersion;
|
GatewayContractInfo.WorkerProtocolVersion;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ public sealed class MxGatewayClientOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CaCertificatePath { get; init; }
|
public string? CaCertificatePath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, TLS connections without a pinned <see cref="CaCertificatePath"/>
|
||||||
|
/// use the OS trust store. When false (default), the gateway certificate is
|
||||||
|
/// accepted without verification — appropriate for this internal tool's
|
||||||
|
/// auto-generated self-signed certificate. Pinning a CA always verifies.
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireCertificateValidation { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the server name override for SNI during TLS handshake.
|
/// Gets the server name override for SNI during TLS handshake.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -16,4 +16,21 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
|
||||||
|
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||||
|
<_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ Support:
|
|||||||
- `credentials.NewClientTLSFromFile`,
|
- `credentials.NewClientTLSFromFile`,
|
||||||
- custom `tls.Config` for advanced callers.
|
- custom `tls.Config` for advanced callers.
|
||||||
|
|
||||||
|
### Trust posture
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). To make that usable, TLS is **lenient by default**: when `Plaintext` is
|
||||||
|
`false` and no `CACertFile`/`TLSConfig`/`TransportCredentials` is supplied,
|
||||||
|
`buildCredentials` dials with `tls.Config{InsecureSkipVerify: true}` (carrying
|
||||||
|
`ServerNameOverride` as the SNI when set), so the gateway's self-signed
|
||||||
|
certificate is accepted without verification.
|
||||||
|
|
||||||
|
To verify the gateway instead:
|
||||||
|
|
||||||
|
- set `CACertFile` to pin a CA (full verification against that root), or
|
||||||
|
- set `RequireCertificateValidation: true` to verify against the OS/system trust
|
||||||
|
roots without pinning.
|
||||||
|
|
||||||
|
Pinning a CA always wins over the lenient default.
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
`Events(ctx)` should return a receive channel of:
|
`Events(ctx)` should return a receive channel of:
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||||
|
the client is **lenient by default**: a TLS connection (`Plaintext: false`) with
|
||||||
|
no `CACertFile`/`TLSConfig` accepts whatever certificate the gateway presents
|
||||||
|
(`InsecureSkipVerify`, with `ServerNameOverride` as the SNI when set). To verify
|
||||||
|
instead, set `CACertFile` to pin a CA, or set `RequireCertificateValidation:
|
||||||
|
true` to verify against the OS/system trust roots without pinning. See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
||||||
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||||
@@ -143,6 +151,46 @@ for i, child := range reply.GetChildren() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```go
|
||||||
|
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
|
||||||
|
Endpoint: "localhost:5000",
|
||||||
|
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
|
||||||
|
Plaintext: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer galaxy.Close()
|
||||||
|
|
||||||
|
roots, err := galaxy.Browse(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, root := range roots {
|
||||||
|
if root.HasChildrenHint() {
|
||||||
|
if err := root.Expand(ctx); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range root.Children() {
|
||||||
|
kind := "leaf"
|
||||||
|
if child.HasChildrenHint() {
|
||||||
|
kind = "has children"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s (%s)\n", child.Object().GetTagName(), kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Expand` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`Browse` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
|
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
|
||||||
@@ -235,6 +283,38 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
|
|||||||
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
|
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installing the Go client
|
||||||
|
|
||||||
|
The module is resolved directly from the git repo — no package registry:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.0
|
||||||
|
````
|
||||||
|
|
||||||
|
Then import:
|
||||||
|
|
||||||
|
````go
|
||||||
|
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||||
|
````
|
||||||
|
|
||||||
|
If your build environment cannot reach `gitea.dohertylan.com` directly,
|
||||||
|
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
|
||||||
|
repo, or use `GONOSUMCHECK` + `GOPRIVATE` to bypass the checksum database
|
||||||
|
for the internal module path.
|
||||||
|
|
||||||
|
## Releasing a new version
|
||||||
|
|
||||||
|
Go modules in monorepo subdirectories use prefixed tags. To tag a release
|
||||||
|
from this repo:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
|
||||||
|
````
|
||||||
|
|
||||||
|
The script validates semver, refuses to tag with uncommitted tracked
|
||||||
|
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
|
||||||
|
pushes it to origin.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -222,10 +222,22 @@ func resolveTransportCredentials(opts Options) (credentials.TransportCredentials
|
|||||||
return credentials.NewTLS(cfg), nil
|
return credentials.NewTLS(cfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return credentials.NewTLS(&tls.Config{
|
return credentials.NewTLS(tlsConfigForOptions(opts)), nil
|
||||||
MinVersion: tls.VersionTLS12,
|
}
|
||||||
ServerName: opts.ServerNameOverride,
|
|
||||||
}), nil
|
// tlsConfigForOptions returns the *tls.Config for the no-CA, no-custom-config TLS path.
|
||||||
|
// It returns nil when the caller should use a different credentials path (CA file or custom TLSConfig).
|
||||||
|
// Exposed as an internal helper so unit tests can assert the InsecureSkipVerify posture.
|
||||||
|
func tlsConfigForOptions(opts Options) *tls.Config {
|
||||||
|
// CA file and custom TLSConfig take their own paths in resolveTransportCredentials.
|
||||||
|
if opts.CACertFile != "" || opts.TLSConfig != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
ServerName: opts.ServerNameOverride,
|
||||||
|
InsecureSkipVerify: !opts.RequireCertificateValidation, //nolint:gosec // internal tool; self-signed gateway cert expected; opt-in strict via RequireCertificateValidation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
|
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tlsConfigFromOptions is the internal helper under test.
|
||||||
|
// It extracts the *tls.Config from the no-CA TLS path of resolveTransportCredentials.
|
||||||
|
// We exercise it directly to avoid needing a real dial target.
|
||||||
|
|
||||||
|
func TestTLSInsecureSkipVerify_DefaultTrue(t *testing.T) {
|
||||||
|
cfg := tlsConfigForOptions(Options{
|
||||||
|
Endpoint: "localhost:5120",
|
||||||
|
})
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("expected non-nil tls.Config")
|
||||||
|
}
|
||||||
|
if !cfg.InsecureSkipVerify {
|
||||||
|
t.Error("InsecureSkipVerify should be true by default when no CA is pinned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSInsecureSkipVerify_FalseWhenRequireCertificateValidation(t *testing.T) {
|
||||||
|
cfg := tlsConfigForOptions(Options{
|
||||||
|
Endpoint: "localhost:5120",
|
||||||
|
RequireCertificateValidation: true,
|
||||||
|
})
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("expected non-nil tls.Config")
|
||||||
|
}
|
||||||
|
if cfg.InsecureSkipVerify {
|
||||||
|
t.Error("InsecureSkipVerify should be false when RequireCertificateValidation is true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSInsecureSkipVerify_FalseWhenCACertFileSet(t *testing.T) {
|
||||||
|
// When a CA file is pinned, the CA-verification path is taken instead.
|
||||||
|
// tlsConfigForOptions should return nil (the CA path does not use our helper).
|
||||||
|
cfg := tlsConfigForOptions(Options{
|
||||||
|
Endpoint: "localhost:5120",
|
||||||
|
CACertFile: "/some/ca.pem",
|
||||||
|
})
|
||||||
|
if cfg != nil {
|
||||||
|
t.Error("expected nil tls.Config when CACertFile is set (CA path taken)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSInsecureSkipVerify_FalseWhenCustomTLSConfig(t *testing.T) {
|
||||||
|
// When TLSConfig is supplied explicitly, our default skip-verify must not overwrite it.
|
||||||
|
custom := &tls.Config{MinVersion: tls.VersionTLS13}
|
||||||
|
cfg := tlsConfigForOptions(Options{
|
||||||
|
Endpoint: "localhost:5120",
|
||||||
|
TLSConfig: custom,
|
||||||
|
})
|
||||||
|
if cfg != nil {
|
||||||
|
t.Error("expected nil tls.Config when TLSConfig is already set (custom config path taken)")
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
-23
@@ -18,6 +18,11 @@ import (
|
|||||||
// browseChildrenPageSize is the per-request page size used by the lazy walker.
|
// browseChildrenPageSize is the per-request page size used by the lazy walker.
|
||||||
const browseChildrenPageSize = 500
|
const browseChildrenPageSize = 500
|
||||||
|
|
||||||
|
// discoverHierarchyPageSize is the per-request page size used by DiscoverHierarchy.
|
||||||
|
// Mirrors the .NET client constant so large galaxies are not silently truncated
|
||||||
|
// by the server's default page cap.
|
||||||
|
const discoverHierarchyPageSize = 5000
|
||||||
|
|
||||||
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
|
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
|
||||||
// Galaxy Repository service exposed for callers that need direct contract
|
// Galaxy Repository service exposed for callers that need direct contract
|
||||||
// access.
|
// access.
|
||||||
@@ -155,16 +160,35 @@ func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool,
|
|||||||
|
|
||||||
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
||||||
// object's dynamic attributes. The objects are returned in the order supplied
|
// object's dynamic attributes. The objects are returned in the order supplied
|
||||||
// by the server.
|
// by the server. The call pages over the server's NextPageToken until the
|
||||||
|
// server signals it has no more results, matching the .NET client.
|
||||||
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
|
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
|
||||||
callCtx, cancel := c.callContext(ctx)
|
var objects []*GalaxyObject
|
||||||
defer cancel()
|
pageToken := ""
|
||||||
|
seen := map[string]struct{}{}
|
||||||
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
|
for {
|
||||||
if err != nil {
|
callCtx, cancel := c.callContext(ctx)
|
||||||
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{
|
||||||
|
PageSize: discoverHierarchyPageSize,
|
||||||
|
PageToken: pageToken,
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
||||||
|
}
|
||||||
|
objects = append(objects, reply.GetObjects()...)
|
||||||
|
pageToken = reply.GetNextPageToken()
|
||||||
|
if pageToken == "" {
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
if _, dup := seen[pageToken]; dup {
|
||||||
|
return nil, &GatewayError{
|
||||||
|
Op: "galaxy discover hierarchy",
|
||||||
|
Err: fmt.Errorf("repeated page token %q", pageToken),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen[pageToken] = struct{}{}
|
||||||
}
|
}
|
||||||
return reply.GetObjects(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
|
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
|
||||||
@@ -249,15 +273,25 @@ func (c *GalaxyClient) Close() error {
|
|||||||
|
|
||||||
// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by
|
// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by
|
||||||
// (*GalaxyClient).Browse. Children are not fetched until Expand is called.
|
// (*GalaxyClient).Browse. Children are not fetched until Expand is called.
|
||||||
// The node is safe for concurrent use; concurrent Expand calls collapse to a
|
// The node is safe for concurrent use; concurrent Expand calls coalesce onto
|
||||||
// single RPC.
|
// a single in-flight RPC and do not block snapshot accessors.
|
||||||
type LazyBrowseNode struct {
|
type LazyBrowseNode struct {
|
||||||
client *GalaxyClient
|
client *GalaxyClient
|
||||||
object *pb.GalaxyObject
|
object *pb.GalaxyObject
|
||||||
hasChildrenHint bool
|
hasChildrenHint bool
|
||||||
options BrowseChildrenOptions
|
options BrowseChildrenOptions
|
||||||
|
|
||||||
mu sync.Mutex
|
// expandLock gates inspection and mutation of expand-coordination state
|
||||||
|
// (expanding, expandDone, expandErr). It is held only briefly; the BrowseChildren
|
||||||
|
// RPC itself runs outside this lock so concurrent readers and waiters are not blocked.
|
||||||
|
expandLock sync.Mutex
|
||||||
|
expanding bool
|
||||||
|
expandDone chan struct{}
|
||||||
|
expandErr error
|
||||||
|
|
||||||
|
// mu protects the children snapshot and isExpanded flag for concurrent
|
||||||
|
// Children() / IsExpanded() readers.
|
||||||
|
mu sync.RWMutex
|
||||||
children []*LazyBrowseNode
|
children []*LazyBrowseNode
|
||||||
isExpanded bool
|
isExpanded bool
|
||||||
}
|
}
|
||||||
@@ -272,8 +306,8 @@ func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint }
|
|||||||
// Children returns a snapshot copy of the currently-loaded child nodes. Returns
|
// Children returns a snapshot copy of the currently-loaded child nodes. Returns
|
||||||
// an empty slice when Expand has not yet been called.
|
// an empty slice when Expand has not yet been called.
|
||||||
func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
|
func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
|
||||||
n.mu.Lock()
|
n.mu.RLock()
|
||||||
defer n.mu.Unlock()
|
defer n.mu.RUnlock()
|
||||||
out := make([]*LazyBrowseNode, len(n.children))
|
out := make([]*LazyBrowseNode, len(n.children))
|
||||||
copy(out, n.children)
|
copy(out, n.children)
|
||||||
return out
|
return out
|
||||||
@@ -281,28 +315,81 @@ func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
|
|||||||
|
|
||||||
// IsExpanded reports whether Expand has completed successfully on this node.
|
// IsExpanded reports whether Expand has completed successfully on this node.
|
||||||
func (n *LazyBrowseNode) IsExpanded() bool {
|
func (n *LazyBrowseNode) IsExpanded() bool {
|
||||||
n.mu.Lock()
|
n.mu.RLock()
|
||||||
defer n.mu.Unlock()
|
defer n.mu.RUnlock()
|
||||||
return n.isExpanded
|
return n.isExpanded
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand fetches this node's direct children via BrowseChildren when they have
|
// Expand fetches this node's direct children via BrowseChildren when they have
|
||||||
// not yet been loaded. Subsequent calls after a successful Expand are a no-op
|
// not yet been loaded. Subsequent calls after a successful Expand are a no-op
|
||||||
// and do not issue another RPC.
|
// and do not issue another RPC.
|
||||||
|
//
|
||||||
|
// Expand is safe to call concurrently from multiple goroutines: callers that
|
||||||
|
// arrive while an expansion is in flight wait on the active RPC and share its
|
||||||
|
// result instead of issuing a second RPC. The RPC itself runs without holding
|
||||||
|
// the snapshot mutex, so concurrent Children() and IsExpanded() callers are
|
||||||
|
// not blocked for the duration of the network round trip.
|
||||||
|
//
|
||||||
|
// Failure semantics: a failed expansion surfaces the same error to every
|
||||||
|
// in-flight waiter, but the node is left in its pre-call state (isExpanded =
|
||||||
|
// false, no in-flight expansion). The next Expand call therefore retries with
|
||||||
|
// a fresh RPC; failures are not sticky.
|
||||||
func (n *LazyBrowseNode) Expand(ctx context.Context) error {
|
func (n *LazyBrowseNode) Expand(ctx context.Context) error {
|
||||||
n.mu.Lock()
|
// Fast path: already expanded.
|
||||||
defer n.mu.Unlock()
|
n.mu.RLock()
|
||||||
if n.isExpanded {
|
if n.isExpanded {
|
||||||
|
n.mu.RUnlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
n.mu.RUnlock()
|
||||||
|
|
||||||
|
// Either start a new expansion or wait on an existing one.
|
||||||
|
n.expandLock.Lock()
|
||||||
|
n.mu.RLock()
|
||||||
|
alreadyExpanded := n.isExpanded
|
||||||
|
n.mu.RUnlock()
|
||||||
|
if alreadyExpanded {
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n.expanding {
|
||||||
|
done := n.expandDone
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
n.expandLock.Lock()
|
||||||
|
err := n.expandErr
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n.expanding = true
|
||||||
|
n.expandDone = make(chan struct{})
|
||||||
|
done := n.expandDone
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
|
||||||
|
// Issue the RPC outside any lock so concurrent readers/waiters are not blocked.
|
||||||
parentID := n.object.GetGobjectId()
|
parentID := n.object.GetGobjectId()
|
||||||
children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
|
children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
|
||||||
if err != nil {
|
|
||||||
return err
|
if err == nil {
|
||||||
|
n.mu.Lock()
|
||||||
|
n.children = children
|
||||||
|
n.isExpanded = true
|
||||||
|
n.mu.Unlock()
|
||||||
}
|
}
|
||||||
n.children = children
|
|
||||||
n.isExpanded = true
|
// Publish result to waiters and clear the in-flight marker so a failed
|
||||||
return nil
|
// expansion can be retried by the next Expand call.
|
||||||
|
n.expandLock.Lock()
|
||||||
|
n.expandErr = err
|
||||||
|
n.expanding = false
|
||||||
|
close(done)
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browse returns the root nodes of the Galaxy hierarchy. The returned nodes
|
// Browse returns the root nodes of the Galaxy hierarchy. The returned nodes
|
||||||
@@ -375,7 +462,10 @@ func (c *GalaxyClient) browseChildrenInner(
|
|||||||
return nodes, nil
|
return nodes, nil
|
||||||
}
|
}
|
||||||
if _, dup := seen[pageToken]; dup {
|
if _, dup := seen[pageToken]; dup {
|
||||||
return nil, fmt.Errorf("mxgateway: galaxy browse children returned repeated page token %q", pageToken)
|
return nil, &GatewayError{
|
||||||
|
Op: "galaxy browse children",
|
||||||
|
Err: fmt.Errorf("repeated page token %q", pageToken),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
seen[pageToken] = struct{}{}
|
seen[pageToken] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -146,6 +147,47 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) {
|
||||||
|
page1 := &pb.DiscoverHierarchyReply{
|
||||||
|
Objects: []*pb.GalaxyObject{
|
||||||
|
{GobjectId: 1, TagName: "A"},
|
||||||
|
{GobjectId: 2, TagName: "B"},
|
||||||
|
},
|
||||||
|
NextPageToken: "page-2",
|
||||||
|
TotalObjectCount: 3,
|
||||||
|
}
|
||||||
|
page2 := &pb.DiscoverHierarchyReply{
|
||||||
|
Objects: []*pb.GalaxyObject{
|
||||||
|
{GobjectId: 3, TagName: "C"},
|
||||||
|
},
|
||||||
|
TotalObjectCount: 3,
|
||||||
|
}
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
objs, err := client.DiscoverHierarchy(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverHierarchy: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(objs), 3; got != want {
|
||||||
|
t.Fatalf("len(objs) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if len(fake.discoverHierarchyCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls))
|
||||||
|
}
|
||||||
|
if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize {
|
||||||
|
t.Fatalf("first call PageSize = %d, want %d",
|
||||||
|
fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize)
|
||||||
|
}
|
||||||
|
if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" {
|
||||||
|
t.Fatalf("second call page token = %q, want %q",
|
||||||
|
fake.discoverHierarchyCalls[1].GetPageToken(), "page-2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
|
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
|
||||||
fake := &fakeGalaxyServer{failTest: true}
|
fake := &fakeGalaxyServer{failTest: true}
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
@@ -372,18 +414,20 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
|
|||||||
type fakeGalaxyServer struct {
|
type fakeGalaxyServer struct {
|
||||||
pb.UnimplementedGalaxyRepositoryServer
|
pb.UnimplementedGalaxyRepositoryServer
|
||||||
|
|
||||||
testReply *pb.TestConnectionReply
|
testReply *pb.TestConnectionReply
|
||||||
testAuth string
|
testAuth string
|
||||||
failTest bool
|
failTest bool
|
||||||
deployReply *pb.GetLastDeployTimeReply
|
deployReply *pb.GetLastDeployTimeReply
|
||||||
discoverReply *pb.DiscoverHierarchyReply
|
discoverReply *pb.DiscoverHierarchyReply
|
||||||
watchEvents []*pb.DeployEvent
|
discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
|
||||||
watchRequest *pb.WatchDeployEventsRequest
|
discoverHierarchyReplies []*pb.DiscoverHierarchyReply
|
||||||
watchSendInterval time.Duration
|
watchEvents []*pb.DeployEvent
|
||||||
watchHoldOpen bool
|
watchRequest *pb.WatchDeployEventsRequest
|
||||||
browseChildrenCalls []*pb.BrowseChildrenRequest
|
watchSendInterval time.Duration
|
||||||
browseChildrenReplies []*pb.BrowseChildrenReply
|
watchHoldOpen bool
|
||||||
browseChildrenError error
|
browseChildrenCalls []*pb.BrowseChildrenRequest
|
||||||
|
browseChildrenReplies []*pb.BrowseChildrenReply
|
||||||
|
browseChildrenError error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
|
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
|
||||||
@@ -405,6 +449,12 @@ func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLas
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
|
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
|
||||||
|
s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req)
|
||||||
|
if len(s.discoverHierarchyReplies) > 0 {
|
||||||
|
reply := s.discoverHierarchyReplies[0]
|
||||||
|
s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:]
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
if s.discoverReply != nil {
|
if s.discoverReply != nil {
|
||||||
return s.discoverReply, nil
|
return s.discoverReply, nil
|
||||||
}
|
}
|
||||||
@@ -738,3 +788,77 @@ func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
|
|||||||
t.Fatal("HistorizedOnly = false, want true")
|
t.Fatal("HistorizedOnly = false, want true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
||||||
|
// roots
|
||||||
|
buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7),
|
||||||
|
// one expand: one child
|
||||||
|
buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
roots, err := client.Browse(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Browse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, 10)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
errs <- roots[0].Expand(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("concurrent Expand: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !roots[0].IsExpanded() {
|
||||||
|
t.Fatal("IsExpanded() = false after 10 concurrent expands")
|
||||||
|
}
|
||||||
|
if got, want := len(roots[0].Children()), 1; got != want {
|
||||||
|
t.Fatalf("len(children) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
// 1 roots fetch + exactly 1 expand fetch.
|
||||||
|
if got, want := len(fake.browseChildrenCalls), 2; got != want {
|
||||||
|
t.Fatalf("RPC count = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) {
|
||||||
|
// Build a reply that carries a non-empty NextPageToken so browseChildrenInner
|
||||||
|
// will request a second page. Queue the same reply twice so the second response
|
||||||
|
// returns the same page token, triggering the duplicate-token guard.
|
||||||
|
page := buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
||||||
|
[]bool{true},
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
page.NextPageToken = "1:abc:1"
|
||||||
|
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{page, page},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.Browse(context.Background(), nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Browse: error = nil, want repeated-page-token error")
|
||||||
|
}
|
||||||
|
var gwErr *GatewayError
|
||||||
|
if !errors.As(err, &gwErr) {
|
||||||
|
t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ type Options struct {
|
|||||||
TransportCredentials credentials.TransportCredentials
|
TransportCredentials credentials.TransportCredentials
|
||||||
// DialOptions are appended to the gRPC dial options after the defaults.
|
// DialOptions are appended to the gRPC dial options after the defaults.
|
||||||
DialOptions []grpc.DialOption
|
DialOptions []grpc.DialOption
|
||||||
|
// RequireCertificateValidation forces TLS certificate verification even when
|
||||||
|
// no CACertFile is pinned. Default false: the gateway's self-signed cert is
|
||||||
|
// accepted without verification (internal-tool posture).
|
||||||
|
RequireCertificateValidation bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by
|
// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by
|
||||||
|
|||||||
@@ -112,6 +112,23 @@ Support:
|
|||||||
- custom CA certificate file,
|
- custom CA certificate file,
|
||||||
- server name override for test environments.
|
- server name override for test environments.
|
||||||
|
|
||||||
|
### Trust posture
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). To make that usable, TLS is **lenient by default**: when the channel is not
|
||||||
|
plaintext and no `caCertificatePath` is set, the client builds
|
||||||
|
`GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)`
|
||||||
|
(grpc-netty-shaded), so the gateway's self-signed certificate is accepted without
|
||||||
|
verification.
|
||||||
|
|
||||||
|
To verify the gateway instead:
|
||||||
|
|
||||||
|
- set `caCertificatePath` to pin a CA (full verification against that root), or
|
||||||
|
- set `requireCertificateValidation` to `true` to verify against the JVM trust
|
||||||
|
store without pinning.
|
||||||
|
|
||||||
|
Pinning a CA always wins over the lenient default.
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
Support both:
|
Support both:
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ try (MxGatewayClient client = MxGatewayClient.connect(options);
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||||
|
the client is **lenient by default**: a TLS connection (`plaintext(false)`) with
|
||||||
|
no `caCertificatePath` accepts whatever certificate the gateway presents (via
|
||||||
|
grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set
|
||||||
|
`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to
|
||||||
|
verify against the JVM trust store without pinning. Use `serverNameOverride` /
|
||||||
|
`--server-name-override` when the dialed host differs from the certificate SAN.
|
||||||
|
See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
||||||
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
||||||
underlying protobuf messages. `MxGatewayCommandException` and
|
underlying protobuf messages. `MxGatewayCommandException` and
|
||||||
@@ -139,6 +149,36 @@ for (int i = 0; i < children.size(); i++) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```java
|
||||||
|
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||||
|
.endpoint("localhost:5000")
|
||||||
|
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
|
||||||
|
.plaintext(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
|
||||||
|
List<LazyBrowseNode> roots = galaxy.browse();
|
||||||
|
for (LazyBrowseNode root : roots) {
|
||||||
|
if (root.hasChildrenHint()) {
|
||||||
|
root.expand();
|
||||||
|
}
|
||||||
|
for (LazyBrowseNode child : root.getChildren()) {
|
||||||
|
String kind = child.hasChildrenHint() ? "has children" : "leaf";
|
||||||
|
System.out.println(child.getObject().getTagName() + " (" + kind + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expand` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`browse` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
||||||
@@ -252,6 +292,37 @@ $env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
|||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installing from the Gitea Maven repository
|
||||||
|
|
||||||
|
The client publishes to the internal Gitea Maven repository at
|
||||||
|
`https://gitea.dohertylan.com/api/packages/dohertj2/maven`.
|
||||||
|
|
||||||
|
In your consumer project's `build.gradle`:
|
||||||
|
|
||||||
|
````groovy
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
||||||
|
credentials {
|
||||||
|
username = System.getenv('GITEA_USERNAME')
|
||||||
|
password = System.getenv('GITEA_TOKEN')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.0'
|
||||||
|
}
|
||||||
|
````
|
||||||
|
|
||||||
|
To publish a new version from this repo:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
export GITEA_USERNAME=dohertj2
|
||||||
|
export GITEA_TOKEN=<your-gitea-token>
|
||||||
|
gradle :zb-mom-ww-mxgateway-client:publish
|
||||||
|
````
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -37,4 +37,44 @@ subprojects {
|
|||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pluginManager.withPlugin('maven-publish') {
|
||||||
|
publishing {
|
||||||
|
publications {
|
||||||
|
maven(MavenPublication) {
|
||||||
|
from components.java
|
||||||
|
pom {
|
||||||
|
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
||||||
|
description = 'MxAccessGateway Java client'
|
||||||
|
scm {
|
||||||
|
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
||||||
|
connection = 'scm:git:https://gitea.dohertylan.com/dohertj2/mxaccessgw.git'
|
||||||
|
}
|
||||||
|
developers {
|
||||||
|
developer {
|
||||||
|
id = 'dohertj2'
|
||||||
|
name = 'Joseph Doherty'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
licenses {
|
||||||
|
license {
|
||||||
|
name = 'Proprietary'
|
||||||
|
distribution = 'repo'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
name = 'GiteaPackages'
|
||||||
|
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
||||||
|
credentials {
|
||||||
|
username = System.getenv('GITEA_USERNAME') ?: ''
|
||||||
|
password = System.getenv('GITEA_TOKEN') ?: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'java-library'
|
id 'java-library'
|
||||||
id 'com.google.protobuf'
|
id 'com.google.protobuf'
|
||||||
|
id 'maven-publish'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -30,6 +31,11 @@ sourceSets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
withSourcesJar()
|
||||||
|
withJavadocJar()
|
||||||
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
protoc {
|
protoc {
|
||||||
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
||||||
|
|||||||
+84
-9
@@ -4,6 +4,9 @@ import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
|
|||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One node in a lazy-loaded Galaxy browse tree. Holds the underlying
|
* One node in a lazy-loaded Galaxy browse tree. Holds the underlying
|
||||||
@@ -16,7 +19,14 @@ public final class LazyBrowseNode {
|
|||||||
private final GalaxyObject object;
|
private final GalaxyObject object;
|
||||||
private final boolean hasChildrenHint;
|
private final boolean hasChildrenHint;
|
||||||
private final BrowseChildrenOptions options;
|
private final BrowseChildrenOptions options;
|
||||||
private final Object lock = new Object();
|
|
||||||
|
// expandLock gates the start of a new expand AND the publish of the in-flight
|
||||||
|
// future. Readers (getChildren / isExpanded) use a separate read-write lock so
|
||||||
|
// they never block on the gRPC call.
|
||||||
|
private final Object expandLock = new Object();
|
||||||
|
private CompletableFuture<Void> inFlight;
|
||||||
|
|
||||||
|
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
|
||||||
private List<LazyBrowseNode> children = Collections.emptyList();
|
private List<LazyBrowseNode> children = Collections.emptyList();
|
||||||
private boolean isExpanded;
|
private boolean isExpanded;
|
||||||
|
|
||||||
@@ -43,15 +53,21 @@ public final class LazyBrowseNode {
|
|||||||
|
|
||||||
/** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */
|
/** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */
|
||||||
public List<LazyBrowseNode> getChildren() {
|
public List<LazyBrowseNode> getChildren() {
|
||||||
synchronized (lock) {
|
readWriteLock.readLock().lock();
|
||||||
|
try {
|
||||||
return List.copyOf(children);
|
return List.copyOf(children);
|
||||||
|
} finally {
|
||||||
|
readWriteLock.readLock().unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return {@code true} after the first {@link #expand()} call completes. */
|
/** @return {@code true} after the first {@link #expand()} call completes. */
|
||||||
public boolean isExpanded() {
|
public boolean isExpanded() {
|
||||||
synchronized (lock) {
|
readWriteLock.readLock().lock();
|
||||||
|
try {
|
||||||
return isExpanded;
|
return isExpanded;
|
||||||
|
} finally {
|
||||||
|
readWriteLock.readLock().unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,17 +75,76 @@ public final class LazyBrowseNode {
|
|||||||
* Fetches direct children from the gateway and populates {@link #getChildren()}.
|
* Fetches direct children from the gateway and populates {@link #getChildren()}.
|
||||||
* Idempotent: subsequent calls are no-ops and do not issue a second RPC.
|
* Idempotent: subsequent calls are no-ops and do not issue a second RPC.
|
||||||
*
|
*
|
||||||
|
* <p>Concurrent callers coalesce onto a single in-flight RPC: the first caller
|
||||||
|
* (the "leader") issues the gRPC call, while any other thread that calls
|
||||||
|
* {@code expand()} during that window blocks on the leader's future and sees
|
||||||
|
* the same result (or the same exception). On failure the in-flight slot is
|
||||||
|
* cleared so a subsequent call can retry.
|
||||||
|
*
|
||||||
|
* <p>Readers ({@link #getChildren()} / {@link #isExpanded()}) take a separate
|
||||||
|
* read lock and are never blocked for the duration of the RPC.
|
||||||
|
*
|
||||||
* @throws MxGatewayException on transport or protocol failure
|
* @throws MxGatewayException on transport or protocol failure
|
||||||
*/
|
*/
|
||||||
public void expand() {
|
public void expand() {
|
||||||
synchronized (lock) {
|
if (isExpanded()) {
|
||||||
if (isExpanded) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CompletableFuture<Void> future;
|
||||||
|
boolean iAmTheLeader;
|
||||||
|
synchronized (expandLock) {
|
||||||
|
if (isExpanded()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<LazyBrowseNode> loaded =
|
if (inFlight != null) {
|
||||||
client.browseChildrenInner(Integer.valueOf(object.getGobjectId()), options);
|
future = inFlight;
|
||||||
this.children = loaded;
|
iAmTheLeader = false;
|
||||||
this.isExpanded = true;
|
} else {
|
||||||
|
future = new CompletableFuture<>();
|
||||||
|
inFlight = future;
|
||||||
|
iAmTheLeader = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iAmTheLeader) {
|
||||||
|
try {
|
||||||
|
List<LazyBrowseNode> loaded =
|
||||||
|
client.browseChildrenInner(object.getGobjectId(), options);
|
||||||
|
readWriteLock.writeLock().lock();
|
||||||
|
try {
|
||||||
|
this.children = loaded;
|
||||||
|
this.isExpanded = true;
|
||||||
|
} finally {
|
||||||
|
readWriteLock.writeLock().unlock();
|
||||||
|
}
|
||||||
|
synchronized (expandLock) {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
future.complete(null);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
synchronized (expandLock) {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
future.completeExceptionally(ex);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
future.get();
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new MxGatewayException("Interrupted waiting for browse-children expand.", ie);
|
||||||
|
} catch (ExecutionException ee) {
|
||||||
|
Throwable cause = ee.getCause();
|
||||||
|
if (cause instanceof MxGatewayException me) {
|
||||||
|
throw me;
|
||||||
|
}
|
||||||
|
if (cause instanceof RuntimeException re) {
|
||||||
|
throw re;
|
||||||
|
}
|
||||||
|
throw new MxGatewayException("BrowseChildren expand failed.", cause);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -384,6 +384,15 @@ public final class MxGatewayClient implements AutoCloseable {
|
|||||||
} catch (SSLException error) {
|
} catch (SSLException error) {
|
||||||
throw new MxGatewayException("failed to configure gateway TLS", error);
|
throw new MxGatewayException("failed to configure gateway TLS", error);
|
||||||
}
|
}
|
||||||
|
} else if (!options.requireCertificateValidation()) {
|
||||||
|
try {
|
||||||
|
builder.sslContext(GrpcSslContexts.forClient()
|
||||||
|
.trustManager(io.grpc.netty.shaded.io.netty.handler.ssl.util
|
||||||
|
.InsecureTrustManagerFactory.INSTANCE)
|
||||||
|
.build());
|
||||||
|
} catch (SSLException error) {
|
||||||
|
throw new MxGatewayException("failed to configure lenient gateway TLS", error);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
builder.useTransportSecurity();
|
builder.useTransportSecurity();
|
||||||
}
|
}
|
||||||
@@ -393,6 +402,19 @@ public final class MxGatewayClient implements AutoCloseable {
|
|||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-visible test seam — creates a raw {@link ManagedChannel} from the
|
||||||
|
* given options without attaching auth interceptors. Used by TLS fixture
|
||||||
|
* tests to verify channel construction behaviour without a full
|
||||||
|
* {@link MxGatewayClient} wrapper.
|
||||||
|
*
|
||||||
|
* @param options the client options
|
||||||
|
* @return a new {@link ManagedChannel}
|
||||||
|
*/
|
||||||
|
static ManagedChannel createChannelForTests(MxGatewayClientOptions options) {
|
||||||
|
return createChannel(options);
|
||||||
|
}
|
||||||
|
|
||||||
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
|
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
|
||||||
if (options.callTimeout().isNegative()) {
|
if (options.callTimeout().isNegative()) {
|
||||||
return stub;
|
return stub;
|
||||||
|
|||||||
+32
@@ -20,6 +20,7 @@ public final class MxGatewayClientOptions {
|
|||||||
private final String apiKey;
|
private final String apiKey;
|
||||||
private final boolean plaintext;
|
private final boolean plaintext;
|
||||||
private final Path caCertificatePath;
|
private final Path caCertificatePath;
|
||||||
|
private final boolean requireCertificateValidation;
|
||||||
private final String serverNameOverride;
|
private final String serverNameOverride;
|
||||||
private final Duration connectTimeout;
|
private final Duration connectTimeout;
|
||||||
private final Duration callTimeout;
|
private final Duration callTimeout;
|
||||||
@@ -31,6 +32,7 @@ public final class MxGatewayClientOptions {
|
|||||||
apiKey = builder.apiKey == null ? "" : builder.apiKey;
|
apiKey = builder.apiKey == null ? "" : builder.apiKey;
|
||||||
plaintext = builder.plaintext;
|
plaintext = builder.plaintext;
|
||||||
caCertificatePath = builder.caCertificatePath;
|
caCertificatePath = builder.caCertificatePath;
|
||||||
|
requireCertificateValidation = builder.requireCertificateValidation;
|
||||||
serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride;
|
serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride;
|
||||||
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
|
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
|
||||||
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
|
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
|
||||||
@@ -95,6 +97,18 @@ public final class MxGatewayClientOptions {
|
|||||||
return caCertificatePath;
|
return caCertificatePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether TLS certificate verification is required even when no CA is pinned.
|
||||||
|
* When {@code false} (default), the gateway's self-signed certificate is accepted
|
||||||
|
* without verification. When {@code true}, the OS trust store is used.
|
||||||
|
* Pinning a CA via {@link #caCertificatePath()} always verifies regardless of this flag.
|
||||||
|
*
|
||||||
|
* @return {@code true} if strict certificate verification is required
|
||||||
|
*/
|
||||||
|
public boolean requireCertificateValidation() {
|
||||||
|
return requireCertificateValidation;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the TLS server-name override, or an empty string when none was supplied.
|
* Returns the TLS server-name override, or an empty string when none was supplied.
|
||||||
*
|
*
|
||||||
@@ -148,6 +162,8 @@ public final class MxGatewayClientOptions {
|
|||||||
+ plaintext
|
+ plaintext
|
||||||
+ ", caCertificatePath="
|
+ ", caCertificatePath="
|
||||||
+ caCertificatePath
|
+ caCertificatePath
|
||||||
|
+ ", requireCertificateValidation="
|
||||||
|
+ requireCertificateValidation
|
||||||
+ ", serverNameOverride='"
|
+ ", serverNameOverride='"
|
||||||
+ serverNameOverride
|
+ serverNameOverride
|
||||||
+ '\''
|
+ '\''
|
||||||
@@ -177,6 +193,7 @@ public final class MxGatewayClientOptions {
|
|||||||
private String apiKey;
|
private String apiKey;
|
||||||
private boolean plaintext;
|
private boolean plaintext;
|
||||||
private Path caCertificatePath;
|
private Path caCertificatePath;
|
||||||
|
private boolean requireCertificateValidation;
|
||||||
private String serverNameOverride;
|
private String serverNameOverride;
|
||||||
private Duration connectTimeout;
|
private Duration connectTimeout;
|
||||||
private Duration callTimeout;
|
private Duration callTimeout;
|
||||||
@@ -230,6 +247,21 @@ public final class MxGatewayClientOptions {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When {@code true}, TLS connections without a pinned CA use the OS trust store
|
||||||
|
* and will reject the gateway's self-signed certificate. When {@code false}
|
||||||
|
* (default), the gateway certificate is accepted without verification —
|
||||||
|
* appropriate for this internal tool's auto-generated self-signed certificate.
|
||||||
|
* Pinning a CA via {@link #caCertificatePath(Path)} always verifies.
|
||||||
|
*
|
||||||
|
* @param value {@code true} to require certificate validation, {@code false} to accept any cert
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder requireCertificateValidation(boolean value) {
|
||||||
|
requireCertificateValidation = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides the TLS server name used during the handshake.
|
* Overrides the TLS server name used during the handshake.
|
||||||
*
|
*
|
||||||
|
|||||||
+112
-1
@@ -40,9 +40,14 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Queue;
|
import java.util.Queue;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -203,6 +208,27 @@ final class GalaxyRepositoryClientTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseChildrenRejectsRepeatedPageToken() throws Exception {
|
||||||
|
// Queue the same BrowseChildrenReply twice with a non-empty NextPageToken.
|
||||||
|
// The client will request a second page and detect that the token repeats.
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService();
|
||||||
|
BrowseChildrenReply repeatedReply = browseReply(
|
||||||
|
List.of(obj(1, "Plant", true)),
|
||||||
|
List.of(true),
|
||||||
|
1L,
|
||||||
|
"1:abc:1");
|
||||||
|
service.replies.add(repeatedReply);
|
||||||
|
service.replies.add(repeatedReply);
|
||||||
|
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
MxGatewayException error = assertThrows(MxGatewayException.class, client::browse);
|
||||||
|
|
||||||
|
assertTrue(error.getMessage().contains("repeated page token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void watchDeployEventsReceivesEventsInOrder() throws Exception {
|
void watchDeployEventsReceivesEventsInOrder() throws Exception {
|
||||||
DeployEvent first = DeployEvent.newBuilder()
|
DeployEvent first = DeployEvent.newBuilder()
|
||||||
@@ -445,6 +471,91 @@ final class GalaxyRepositoryClientTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseExpandConcurrentCallersOnlyFireOneRpc() throws Exception {
|
||||||
|
// Verifies that concurrent expand() calls coalesce onto a single in-flight
|
||||||
|
// BrowseChildren RPC and that readers (isExpanded/getChildren) are not
|
||||||
|
// blocked for the full RPC duration.
|
||||||
|
BrowseChildrenReply rootsReply = browseReply(
|
||||||
|
List.of(obj(1, "Plant", true)),
|
||||||
|
List.of(true),
|
||||||
|
7L,
|
||||||
|
"");
|
||||||
|
BrowseChildrenReply childrenReply = browseReply(
|
||||||
|
List.of(obj(2, "Mixer_001", false)),
|
||||||
|
List.of(false),
|
||||||
|
7L,
|
||||||
|
"");
|
||||||
|
|
||||||
|
// Gate the child fetch behind a latch so multiple expanders can pile up.
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
AtomicInteger childCalls = new AtomicInteger();
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService() {
|
||||||
|
@Override
|
||||||
|
public void browseChildren(
|
||||||
|
BrowseChildrenRequest request, StreamObserver<BrowseChildrenReply> responseObserver) {
|
||||||
|
calls.add(request);
|
||||||
|
BrowseChildrenReply reply;
|
||||||
|
if (!request.hasParentGobjectId()) {
|
||||||
|
reply = rootsReply;
|
||||||
|
} else {
|
||||||
|
// Block the leader until the followers have arrived.
|
||||||
|
try {
|
||||||
|
assertTrue(release.await(5, TimeUnit.SECONDS), "release latch never tripped");
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
responseObserver.onError(Status.CANCELLED.asRuntimeException());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
childCalls.incrementAndGet();
|
||||||
|
reply = childrenReply;
|
||||||
|
}
|
||||||
|
responseObserver.onNext(reply);
|
||||||
|
responseObserver.onCompleted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
List<LazyBrowseNode> roots = client.browse();
|
||||||
|
LazyBrowseNode root = roots.get(0);
|
||||||
|
|
||||||
|
int parallelism = 10;
|
||||||
|
ExecutorService pool = Executors.newFixedThreadPool(parallelism);
|
||||||
|
try {
|
||||||
|
CountDownLatch ready = new CountDownLatch(parallelism);
|
||||||
|
List<Future<Void>> futures = new ArrayList<>();
|
||||||
|
for (int i = 0; i < parallelism; i++) {
|
||||||
|
futures.add(pool.submit(() -> {
|
||||||
|
ready.countDown();
|
||||||
|
root.expand();
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Wait for all callers to be in flight, then release the leader.
|
||||||
|
assertTrue(ready.await(5, TimeUnit.SECONDS), "expander threads did not start");
|
||||||
|
// Readers must not be blocked by an in-flight expand; this should not deadlock
|
||||||
|
// and should return the pre-expand state.
|
||||||
|
assertFalse(root.isExpanded());
|
||||||
|
assertEquals(0, root.getChildren().size());
|
||||||
|
release.countDown();
|
||||||
|
|
||||||
|
for (Future<Void> f : futures) {
|
||||||
|
f.get(10, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pool.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(root.isExpanded());
|
||||||
|
assertEquals(1, root.getChildren().size());
|
||||||
|
// Exactly one expand RPC was issued even though many callers raced.
|
||||||
|
assertEquals(1, childCalls.get());
|
||||||
|
// 1 roots fetch + exactly 1 expand fetch.
|
||||||
|
assertEquals(2, service.calls.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void browseWithFilterForwardsToRequest() throws Exception {
|
void browseWithFilterForwardsToRequest() throws Exception {
|
||||||
BrowseChildrenService service = new BrowseChildrenService();
|
BrowseChildrenService service = new BrowseChildrenService();
|
||||||
@@ -486,7 +597,7 @@ final class GalaxyRepositoryClientTests {
|
|||||||
return b.build();
|
return b.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class BrowseChildrenService extends TestService {
|
private static class BrowseChildrenService extends TestService {
|
||||||
final List<BrowseChildrenRequest> calls =
|
final List<BrowseChildrenRequest> calls =
|
||||||
Collections.synchronizedList(new CopyOnWriteArrayList<>());
|
Collections.synchronizedList(new CopyOnWriteArrayList<>());
|
||||||
final Queue<BrowseChildrenReply> replies = new ArrayDeque<>();
|
final Queue<BrowseChildrenReply> replies = new ArrayDeque<>();
|
||||||
|
|||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
package com.zb.mom.ww.mxgateway.client;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import io.grpc.ManagedChannel;
|
||||||
|
import io.grpc.Server;
|
||||||
|
import io.grpc.StatusRuntimeException;
|
||||||
|
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
|
||||||
|
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.cert.Certificate;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import javax.net.ssl.SSLException;
|
||||||
|
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the Java client connects to a Netty TLS server with a
|
||||||
|
* self-signed certificate when no CA is pinned (lenient default), and that
|
||||||
|
* setting {@code requireCertificateValidation(true)} causes a TLS failure.
|
||||||
|
*
|
||||||
|
* <p>A self-signed certificate is generated using {@code keytool} (always
|
||||||
|
* available in the JDK) to avoid dependencies on internal JDK APIs or
|
||||||
|
* BouncyCastle, and so the test works on all JDK versions used by the project.
|
||||||
|
*/
|
||||||
|
final class MxGatewayClientTlsTests {
|
||||||
|
|
||||||
|
private Server server;
|
||||||
|
private int port;
|
||||||
|
private File certPemFile;
|
||||||
|
private File keyPemFile;
|
||||||
|
private File keystoreFile;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void startTlsServer() throws Exception {
|
||||||
|
keystoreFile = File.createTempFile("gw-test-ks", ".p12");
|
||||||
|
certPemFile = File.createTempFile("gw-test-cert", ".pem");
|
||||||
|
keyPemFile = File.createTempFile("gw-test-key", ".pem");
|
||||||
|
|
||||||
|
// keytool refuses to write to a pre-existing (even empty) file; delete it first.
|
||||||
|
keystoreFile.delete();
|
||||||
|
|
||||||
|
// Use keytool to generate a self-signed PKCS12 keystore.
|
||||||
|
String keytool = ProcessHandle.current().info().command()
|
||||||
|
.map(cmd -> cmd.replace("java", "keytool"))
|
||||||
|
.orElse("keytool");
|
||||||
|
// Fall back to just "keytool" on PATH if the resolved path doesn't exist.
|
||||||
|
if (!new File(keytool).exists()) {
|
||||||
|
keytool = "keytool";
|
||||||
|
}
|
||||||
|
Process p = new ProcessBuilder(
|
||||||
|
keytool,
|
||||||
|
"-genkeypair",
|
||||||
|
"-alias", "server",
|
||||||
|
"-keyalg", "RSA",
|
||||||
|
"-keysize", "2048",
|
||||||
|
"-sigalg", "SHA256withRSA",
|
||||||
|
"-validity", "1",
|
||||||
|
"-dname", "CN=localhost",
|
||||||
|
"-storetype", "PKCS12",
|
||||||
|
"-storepass", "changeit",
|
||||||
|
"-keypass", "changeit",
|
||||||
|
"-keystore", keystoreFile.getAbsolutePath())
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start();
|
||||||
|
int exit = p.waitFor();
|
||||||
|
if (exit != 0) {
|
||||||
|
String out = new String(p.getInputStream().readAllBytes());
|
||||||
|
throw new IllegalStateException("keytool failed (exit " + exit + "): " + out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export cert and private key from the PKCS12 keystore to PEM files.
|
||||||
|
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||||
|
try (var is = Files.newInputStream(keystoreFile.toPath())) {
|
||||||
|
ks.load(is, "changeit".toCharArray());
|
||||||
|
}
|
||||||
|
X509Certificate cert = (X509Certificate) ks.getCertificate("server");
|
||||||
|
PrivateKey privateKey = (PrivateKey) ks.getKey("server", "changeit".toCharArray());
|
||||||
|
|
||||||
|
try (FileOutputStream out = new FileOutputStream(certPemFile)) {
|
||||||
|
out.write("-----BEGIN CERTIFICATE-----\n".getBytes());
|
||||||
|
out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(cert.getEncoded()));
|
||||||
|
out.write("\n-----END CERTIFICATE-----\n".getBytes());
|
||||||
|
}
|
||||||
|
try (FileOutputStream out = new FileOutputStream(keyPemFile)) {
|
||||||
|
out.write("-----BEGIN PRIVATE KEY-----\n".getBytes());
|
||||||
|
out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(privateKey.getEncoded()));
|
||||||
|
out.write("\n-----END PRIVATE KEY-----\n".getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
server = NettyServerBuilder
|
||||||
|
.forAddress(new InetSocketAddress("127.0.0.1", 0))
|
||||||
|
.sslContext(GrpcSslContexts.forServer(certPemFile, keyPemFile).build())
|
||||||
|
.addService(new MinimalGatewayService())
|
||||||
|
.build()
|
||||||
|
.start();
|
||||||
|
port = server.getPort();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stopTlsServer() throws InterruptedException {
|
||||||
|
if (server != null) {
|
||||||
|
server.shutdown();
|
||||||
|
server.awaitTermination(5, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
if (certPemFile != null) {
|
||||||
|
certPemFile.delete();
|
||||||
|
}
|
||||||
|
if (keyPemFile != null) {
|
||||||
|
keyPemFile.delete();
|
||||||
|
}
|
||||||
|
if (keystoreFile != null) {
|
||||||
|
keystoreFile.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void connectsToSelfSignedServer_WhenRequireCertificateValidationIsFalse() throws SSLException {
|
||||||
|
// Default options — requireCertificateValidation defaults to false.
|
||||||
|
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||||
|
.endpoint("127.0.0.1:" + port)
|
||||||
|
.apiKey("test-key")
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.callTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ManagedChannel channel = MxGatewayClient.createChannelForTests(options);
|
||||||
|
try {
|
||||||
|
MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub =
|
||||||
|
MxAccessGatewayGrpc.newBlockingStub(channel);
|
||||||
|
OpenSessionReply reply = stub.openSession(
|
||||||
|
OpenSessionRequest.newBuilder()
|
||||||
|
.setClientSessionName("tls-test")
|
||||||
|
.build());
|
||||||
|
assertTrue(reply.getProtocolStatus().getCode()
|
||||||
|
== ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK);
|
||||||
|
} finally {
|
||||||
|
channel.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failsToConnect_WhenRequireCertificateValidationIsTrue() throws SSLException {
|
||||||
|
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||||
|
.endpoint("127.0.0.1:" + port)
|
||||||
|
.apiKey("test-key")
|
||||||
|
.requireCertificateValidation(true)
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.callTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ManagedChannel channel = MxGatewayClient.createChannelForTests(options);
|
||||||
|
try {
|
||||||
|
MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub =
|
||||||
|
MxAccessGatewayGrpc.newBlockingStub(channel);
|
||||||
|
assertThrows(StatusRuntimeException.class, () ->
|
||||||
|
stub.openSession(OpenSessionRequest.newBuilder()
|
||||||
|
.setClientSessionName("tls-strict-test")
|
||||||
|
.build()));
|
||||||
|
} finally {
|
||||||
|
channel.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal gateway stub that succeeds any OpenSession call. */
|
||||||
|
private static final class MinimalGatewayService
|
||||||
|
extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
|
||||||
|
@Override
|
||||||
|
public void openSession(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
StreamObserver<OpenSessionReply> responseObserver) {
|
||||||
|
responseObserver.onNext(OpenSessionReply.newBuilder()
|
||||||
|
.setSessionId("tls-test-session")
|
||||||
|
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||||
|
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||||
|
.build())
|
||||||
|
.build());
|
||||||
|
responseObserver.onCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,28 @@ Support:
|
|||||||
- TLS channel with default roots,
|
- TLS channel with default roots,
|
||||||
- custom root certificate file.
|
- custom root certificate file.
|
||||||
|
|
||||||
|
### Trust posture (trust-on-first-use)
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). grpc-python exposes no per-channel skip-verify hook, so the client cannot
|
||||||
|
"accept any certificate" the way the other clients do. Instead, when the channel
|
||||||
|
is not plaintext and neither `ca_file` nor `require_certificate_validation` is
|
||||||
|
set, the TLS default is **trust-on-first-use**: the client fetches the server's
|
||||||
|
presented certificate once via `ssl.get_server_certificate` (an unverified
|
||||||
|
probe), pins it as the channel's only trust root, and — because the generated
|
||||||
|
certificate always carries a `localhost` SAN — defaults
|
||||||
|
`grpc.ssl_target_name_override` to `localhost` when no `server_name_override` was
|
||||||
|
supplied (tolerating dial-by-IP or a hostname mismatch). A failed probe is
|
||||||
|
surfaced as a transport error naming the endpoint.
|
||||||
|
|
||||||
|
To verify the gateway instead:
|
||||||
|
|
||||||
|
- set `ca_file` to verify against a specific CA, or
|
||||||
|
- set `require_certificate_validation=True` to verify against the system trust
|
||||||
|
roots.
|
||||||
|
|
||||||
|
Both bypass the TOFU path.
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
Expose `stream_events` as an async iterator. Canceling the task should cancel
|
Expose `stream_events` as an async iterator. Canceling the task should cancel
|
||||||
|
|||||||
@@ -157,6 +157,30 @@ for child, has_children in zip(reply.children, reply.child_has_children):
|
|||||||
print(child.tag_name, "expand=" + str(has_children))
|
print(child.tag_name, "expand=" + str(has_children))
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with await GalaxyRepositoryClient.connect(
|
||||||
|
endpoint="localhost:5000",
|
||||||
|
api_key="<gateway-api-key>",
|
||||||
|
plaintext=True,
|
||||||
|
) as galaxy:
|
||||||
|
roots = await galaxy.browse()
|
||||||
|
for root in roots:
|
||||||
|
if root.has_children_hint:
|
||||||
|
await root.expand()
|
||||||
|
for child in root.children:
|
||||||
|
kind = "has children" if child.has_children_hint else "leaf"
|
||||||
|
print(f"{child.object.tag_name} ({kind})")
|
||||||
|
```
|
||||||
|
|
||||||
|
`expand` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`browse` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
|
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
|
||||||
@@ -206,6 +230,17 @@ The client supports plaintext channels for local development, TLS with system
|
|||||||
roots, TLS with a custom `ca_file`, and an optional test server name override.
|
roots, TLS with a custom `ca_file`, and an optional test server name override.
|
||||||
API keys are redacted from option repr output and CLI error output.
|
API keys are redacted from option repr output and CLI error output.
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI).
|
||||||
|
grpc-python has no per-channel skip-verify, so the lenient TLS default is
|
||||||
|
**trust-on-first-use**: with no `ca_file` and `require_certificate_validation`
|
||||||
|
left `False`, the client fetches the gateway's presented certificate once
|
||||||
|
(unverified) and pins it for the channel, defaulting the SNI/target-name override
|
||||||
|
to `localhost` (the generated certificate always carries a `localhost` SAN) when
|
||||||
|
none was supplied. To verify instead, pass `ca_file` to verify against a specific
|
||||||
|
CA, or set `require_certificate_validation=True` to verify against the system
|
||||||
|
trust roots. See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
The CLI emits deterministic JSON for automation:
|
The CLI emits deterministic JSON for automation:
|
||||||
@@ -244,6 +279,19 @@ $env:MXGATEWAY_TEST_ITEM = 'Object.Attribute'
|
|||||||
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installing from the Gitea PyPI Feed
|
||||||
|
|
||||||
|
The client publishes to the internal Gitea PyPI feed:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
pip install \
|
||||||
|
--index-url https://gitea.dohertylan.com/api/packages/dohertj2/pypi/simple/ \
|
||||||
|
zb-mom-ww-mxaccess-gateway-client
|
||||||
|
````
|
||||||
|
|
||||||
|
If you need authentication (private feed), use `--extra-index-url` and either
|
||||||
|
a `~/.netrc` entry or `PIP_INDEX_URL=https://<user>:<token>@gitea.dohertylan.com/...`.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -13,12 +13,35 @@ dependencies = [
|
|||||||
"grpcio>=1.80,<2",
|
"grpcio>=1.80,<2",
|
||||||
"protobuf>=6.33,<7",
|
"protobuf>=6.33,<7",
|
||||||
]
|
]
|
||||||
|
authors = [
|
||||||
|
{ name = "Joseph Doherty" },
|
||||||
|
]
|
||||||
|
license = { text = "Proprietary" }
|
||||||
|
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"License :: Other/Proprietary License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: System :: Distributed Computing",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
Repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"grpcio-tools>=1.80,<2",
|
"grpcio-tools>=1.80,<2",
|
||||||
"pytest>=9,<10",
|
"pytest>=9,<10",
|
||||||
"pytest-asyncio>=1.3,<2",
|
"pytest-asyncio>=1.3,<2",
|
||||||
|
"build>=1.2,<2",
|
||||||
|
"twine>=5,<6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -140,6 +140,22 @@ class GalaxyRepositoryClient:
|
|||||||
)
|
)
|
||||||
seen_page_tokens.add(page_token)
|
seen_page_tokens.add(page_token)
|
||||||
|
|
||||||
|
async def browse_children_raw(
|
||||||
|
self, request: galaxy_pb.BrowseChildrenRequest
|
||||||
|
) -> galaxy_pb.BrowseChildrenReply:
|
||||||
|
"""Issue one BrowseChildren RPC and return the raw reply.
|
||||||
|
|
||||||
|
Lower-level escape hatch for callers that need direct page-token control
|
||||||
|
or do not want LazyBrowseNode wrapping. Most callers should use
|
||||||
|
:py:meth:`browse` and :py:meth:`LazyBrowseNode.expand` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self._unary(
|
||||||
|
"browse children",
|
||||||
|
self.raw_stub.BrowseChildren,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
async def browse(
|
async def browse(
|
||||||
self,
|
self,
|
||||||
options: BrowseChildrenOptions | None = None,
|
options: BrowseChildrenOptions | None = None,
|
||||||
@@ -292,6 +308,7 @@ class LazyBrowseNode:
|
|||||||
self._options = options
|
self._options = options
|
||||||
self._children: list[LazyBrowseNode] = []
|
self._children: list[LazyBrowseNode] = []
|
||||||
self._is_expanded = False
|
self._is_expanded = False
|
||||||
|
self._expand_lock = asyncio.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def object(self) -> galaxy_pb.GalaxyObject:
|
def object(self) -> galaxy_pb.GalaxyObject:
|
||||||
@@ -317,14 +334,17 @@ class LazyBrowseNode:
|
|||||||
"""Fetch direct children of this node; no-op on subsequent calls."""
|
"""Fetch direct children of this node; no-op on subsequent calls."""
|
||||||
if self._is_expanded:
|
if self._is_expanded:
|
||||||
return
|
return
|
||||||
new_children: list[LazyBrowseNode] = []
|
async with self._expand_lock:
|
||||||
async for child in self._client._iter_browse_children(
|
if self._is_expanded:
|
||||||
parent_gobject_id=self._object.gobject_id,
|
return
|
||||||
options=self._options,
|
new_children: list[LazyBrowseNode] = []
|
||||||
):
|
async for child in self._client._iter_browse_children(
|
||||||
new_children.append(child)
|
parent_gobject_id=self._object.gobject_id,
|
||||||
self._children.extend(new_children)
|
options=self._options,
|
||||||
self._is_expanded = True
|
):
|
||||||
|
new_children.append(child)
|
||||||
|
self._children.extend(new_children)
|
||||||
|
self._is_expanded = True
|
||||||
|
|
||||||
|
|
||||||
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ssl
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,6 +10,7 @@ from pathlib import Path
|
|||||||
import grpc
|
import grpc
|
||||||
|
|
||||||
from .auth import REDACTED, ApiKey
|
from .auth import REDACTED, ApiKey
|
||||||
|
from .errors import MxGatewayTransportError
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -19,6 +21,7 @@ class ClientOptions:
|
|||||||
api_key: str | ApiKey | None = None
|
api_key: str | ApiKey | None = None
|
||||||
plaintext: bool = False
|
plaintext: bool = False
|
||||||
ca_file: str | None = None
|
ca_file: str | None = None
|
||||||
|
require_certificate_validation: bool = False
|
||||||
server_name_override: str | None = None
|
server_name_override: str | None = None
|
||||||
call_timeout: float | None = 30.0
|
call_timeout: float | None = 30.0
|
||||||
stream_timeout: float | None = None
|
stream_timeout: float | None = None
|
||||||
@@ -45,6 +48,7 @@ class ClientOptions:
|
|||||||
f"{type(self).__name__}(endpoint={self.endpoint!r}, "
|
f"{type(self).__name__}(endpoint={self.endpoint!r}, "
|
||||||
f"api_key={api_key!r}, plaintext={self.plaintext!r}, "
|
f"api_key={api_key!r}, plaintext={self.plaintext!r}, "
|
||||||
f"ca_file={self.ca_file!r}, "
|
f"ca_file={self.ca_file!r}, "
|
||||||
|
f"require_certificate_validation={self.require_certificate_validation!r}, "
|
||||||
f"server_name_override={self.server_name_override!r}, "
|
f"server_name_override={self.server_name_override!r}, "
|
||||||
f"call_timeout={self.call_timeout!r}, "
|
f"call_timeout={self.call_timeout!r}, "
|
||||||
f"stream_timeout={self.stream_timeout!r}, "
|
f"stream_timeout={self.stream_timeout!r}, "
|
||||||
@@ -69,8 +73,34 @@ class BrowseChildrenOptions:
|
|||||||
historized_only: bool = False
|
historized_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _split_authority(endpoint: str) -> tuple[str, int]:
|
||||||
|
"""Split a gRPC target (optionally scheme-prefixed) into (host, port).
|
||||||
|
|
||||||
|
Handles bracketed IPv6 literals (e.g. ``[::1]:5120`` or bare ``[::1]``),
|
||||||
|
returning the host without brackets so it is safe to pass to
|
||||||
|
``ssl.get_server_certificate``.
|
||||||
|
"""
|
||||||
|
target = endpoint.split("://", 1)[-1]
|
||||||
|
if target.startswith("["):
|
||||||
|
# Bracketed IPv6: "[::1]:5120" or "[::1]"
|
||||||
|
bracket_end = target.find("]")
|
||||||
|
host = target[1:bracket_end] # strip surrounding brackets
|
||||||
|
remainder = target[bracket_end + 1 :] # ":5120" or ""
|
||||||
|
port_str = remainder.lstrip(":")
|
||||||
|
return (host, int(port_str) if port_str else 443)
|
||||||
|
host, _, port = target.rpartition(":")
|
||||||
|
return (host or "localhost", int(port) if port else 443)
|
||||||
|
|
||||||
|
|
||||||
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
||||||
"""Create a plaintext or TLS `grpc.aio` channel from client options."""
|
"""Create a plaintext or TLS `grpc.aio` channel from client options.
|
||||||
|
|
||||||
|
The TLS default is lenient: grpc-python has no per-channel skip-verify, so
|
||||||
|
the server's presented certificate is fetched once (unverified) and pinned
|
||||||
|
as the channel's only trust root (trust-on-first-use). Set
|
||||||
|
`require_certificate_validation=True` to force system-trust verification, or
|
||||||
|
pass `ca_file` to verify against a specific CA — both bypass the TOFU path.
|
||||||
|
"""
|
||||||
|
|
||||||
channel_options: list[tuple[str, str | int]] = [
|
channel_options: list[tuple[str, str | int]] = [
|
||||||
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
|
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
|
||||||
@@ -82,11 +112,28 @@ def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
|||||||
if options.plaintext:
|
if options.plaintext:
|
||||||
return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
|
return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
|
||||||
|
|
||||||
root_certificates = None
|
|
||||||
if options.ca_file:
|
if options.ca_file:
|
||||||
root_certificates = Path(options.ca_file).read_bytes()
|
root_certificates = Path(options.ca_file).read_bytes()
|
||||||
|
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
|
||||||
|
elif options.require_certificate_validation:
|
||||||
|
credentials = grpc.ssl_channel_credentials()
|
||||||
|
else:
|
||||||
|
# Lenient default: grpc-python has no per-channel skip-verify, so fetch the
|
||||||
|
# server's certificate (unverified) and pin it for this channel (TOFU).
|
||||||
|
host, port = _split_authority(options.endpoint)
|
||||||
|
try:
|
||||||
|
presented = ssl.get_server_certificate((host, port))
|
||||||
|
except OSError as error:
|
||||||
|
raise MxGatewayTransportError(
|
||||||
|
f"failed to fetch TLS certificate from {options.endpoint}: {error}"
|
||||||
|
) from error
|
||||||
|
credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii"))
|
||||||
|
# The gateway self-signed cert always carries a "localhost" SAN, so default
|
||||||
|
# the SNI/target-name override to it when none was supplied, tolerating
|
||||||
|
# dial-by-IP or hostname mismatch.
|
||||||
|
if not options.server_name_override:
|
||||||
|
channel_options.append(("grpc.ssl_target_name_override", "localhost"))
|
||||||
|
|
||||||
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
|
|
||||||
return grpc.aio.secure_channel(
|
return grpc.aio.secure_channel(
|
||||||
options.endpoint,
|
options.endpoint,
|
||||||
credentials,
|
credentials,
|
||||||
|
|||||||
@@ -72,27 +72,83 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch)
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_create_channel_uses_tls_channel_tofu_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
calls: list[tuple[str, object, object]] = []
|
"""Default TLS (no ca_file, no require_certificate_validation) uses TOFU:
|
||||||
|
fetches the server cert unverified, pins it as root_certificates, and adds
|
||||||
|
grpc.ssl_target_name_override = "localhost" automatically.
|
||||||
|
"""
|
||||||
|
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
|
||||||
|
get_cert_calls: list[tuple[str, int]] = []
|
||||||
|
|
||||||
def fake_credentials(*, root_certificates: object) -> str:
|
def fake_get_server_certificate(addr: tuple[str, int]) -> str:
|
||||||
assert root_certificates is None
|
get_cert_calls.append(addr)
|
||||||
|
return _DUMMY_PEM
|
||||||
|
|
||||||
|
cred_calls: list[object] = []
|
||||||
|
|
||||||
|
def fake_credentials(*, root_certificates: object = None) -> str:
|
||||||
|
cred_calls.append(root_certificates)
|
||||||
return "creds"
|
return "creds"
|
||||||
|
|
||||||
|
channel_calls: list[tuple[str, object, object]] = []
|
||||||
|
|
||||||
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||||
calls.append((endpoint, credentials, options))
|
channel_calls.append((endpoint, credentials, options))
|
||||||
return "tls-channel"
|
return "tls-channel"
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||||
options_module.grpc,
|
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||||
"ssl_channel_credentials",
|
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||||
fake_credentials,
|
|
||||||
|
channel = create_channel(
|
||||||
|
ClientOptions(endpoint="gateway.example:5001"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert channel == "tls-channel"
|
||||||
|
# TOFU: should have fetched the cert from the server (host, port)
|
||||||
|
assert get_cert_calls == [("gateway.example", 5001)]
|
||||||
|
# Pinned the fetched PEM bytes as root_certificates
|
||||||
|
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
|
||||||
|
# Auto-injected localhost override (no server_name_override supplied)
|
||||||
|
assert channel_calls == [
|
||||||
|
(
|
||||||
|
"gateway.example:5001",
|
||||||
|
"creds",
|
||||||
|
[
|
||||||
|
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||||
|
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||||
|
("grpc.ssl_target_name_override", "localhost"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_channel_uses_tls_channel_tofu_respects_server_name_override(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""When server_name_override is set, TOFU still runs but does NOT add the
|
||||||
|
auto-localhost override (the explicit override is already in channel_options).
|
||||||
|
"""
|
||||||
|
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
options_module.grpc.aio,
|
options_module.ssl,
|
||||||
"secure_channel",
|
"get_server_certificate",
|
||||||
fake_secure_channel,
|
lambda addr: _DUMMY_PEM,
|
||||||
)
|
)
|
||||||
|
cred_calls: list[object] = []
|
||||||
|
|
||||||
|
def fake_credentials(*, root_certificates: object = None) -> str:
|
||||||
|
cred_calls.append(root_certificates)
|
||||||
|
return "creds"
|
||||||
|
|
||||||
|
channel_calls: list[tuple[str, object, object]] = []
|
||||||
|
|
||||||
|
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||||
|
channel_calls.append((endpoint, credentials, options))
|
||||||
|
return "tls-channel"
|
||||||
|
|
||||||
|
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||||
|
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||||
|
|
||||||
channel = create_channel(
|
channel = create_channel(
|
||||||
ClientOptions(
|
ClientOptions(
|
||||||
@@ -102,14 +158,121 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert channel == "tls-channel"
|
assert channel == "tls-channel"
|
||||||
assert calls == [
|
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
|
||||||
(
|
assert channel_calls == [
|
||||||
"gateway.example:5001",
|
(
|
||||||
"creds",
|
"gateway.example:5001",
|
||||||
[
|
"creds",
|
||||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
[
|
||||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||||
("grpc.ssl_target_name_override", "gateway.test"),
|
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||||
],
|
# Explicit override from ClientOptions — not the auto-localhost one
|
||||||
),
|
("grpc.ssl_target_name_override", "gateway.test"),
|
||||||
]
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_channel_uses_tls_channel_require_cert_validation(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""require_certificate_validation=True uses system trust (no TOFU, no root_certificates)."""
|
||||||
|
get_cert_called = False
|
||||||
|
|
||||||
|
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
|
||||||
|
nonlocal get_cert_called
|
||||||
|
get_cert_called = True
|
||||||
|
return "SHOULD_NOT_BE_CALLED"
|
||||||
|
|
||||||
|
cred_calls: list[object] = []
|
||||||
|
|
||||||
|
def fake_credentials(**kwargs: object) -> str:
|
||||||
|
cred_calls.append(kwargs)
|
||||||
|
return "creds"
|
||||||
|
|
||||||
|
channel_calls: list[tuple[str, object, object]] = []
|
||||||
|
|
||||||
|
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||||
|
channel_calls.append((endpoint, credentials, options))
|
||||||
|
return "tls-channel"
|
||||||
|
|
||||||
|
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||||
|
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||||
|
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||||
|
|
||||||
|
channel = create_channel(
|
||||||
|
ClientOptions(
|
||||||
|
endpoint="gateway.example:5001",
|
||||||
|
require_certificate_validation=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel == "tls-channel"
|
||||||
|
# Must NOT call TOFU prefetch
|
||||||
|
assert not get_cert_called
|
||||||
|
# ssl_channel_credentials() called with NO keyword args (system trust)
|
||||||
|
assert cred_calls == [{}]
|
||||||
|
assert channel_calls == [
|
||||||
|
(
|
||||||
|
"gateway.example:5001",
|
||||||
|
"creds",
|
||||||
|
[
|
||||||
|
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||||
|
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_channel_uses_tls_channel_ca_file(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: pytest.TempPathFactory,
|
||||||
|
) -> None:
|
||||||
|
"""ca_file path: reads the PEM file, passes bytes as root_certificates, skips TOFU."""
|
||||||
|
ca_pem = b"-----BEGIN CERTIFICATE-----\nY2FkYXRh\n-----END CERTIFICATE-----\n"
|
||||||
|
ca_file = tmp_path / "ca.pem"
|
||||||
|
ca_file.write_bytes(ca_pem)
|
||||||
|
|
||||||
|
get_cert_called = False
|
||||||
|
|
||||||
|
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
|
||||||
|
nonlocal get_cert_called
|
||||||
|
get_cert_called = True
|
||||||
|
return "SHOULD_NOT_BE_CALLED"
|
||||||
|
|
||||||
|
cred_calls: list[object] = []
|
||||||
|
|
||||||
|
def fake_credentials(*, root_certificates: object = None) -> str:
|
||||||
|
cred_calls.append(root_certificates)
|
||||||
|
return "creds"
|
||||||
|
|
||||||
|
channel_calls: list[tuple[str, object, object]] = []
|
||||||
|
|
||||||
|
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||||
|
channel_calls.append((endpoint, credentials, options))
|
||||||
|
return "tls-channel"
|
||||||
|
|
||||||
|
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||||
|
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||||
|
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||||
|
|
||||||
|
channel = create_channel(
|
||||||
|
ClientOptions(
|
||||||
|
endpoint="gateway.example:5001",
|
||||||
|
ca_file=str(ca_file),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel == "tls-channel"
|
||||||
|
assert not get_cert_called
|
||||||
|
assert cred_calls == [ca_pem]
|
||||||
|
assert channel_calls == [
|
||||||
|
(
|
||||||
|
"gateway.example:5001",
|
||||||
|
"creds",
|
||||||
|
[
|
||||||
|
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||||
|
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|||||||
@@ -391,6 +391,28 @@ async def test_browse_expand_idempotent_no_second_rpc() -> None:
|
|||||||
assert len(roots[0].children) == 1
|
assert len(roots[0].children) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_expand_concurrent_callers_only_fire_one_rpc() -> None:
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
stub.browse_children.replies = [
|
||||||
|
_build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7),
|
||||||
|
_build_browse_reply([_obj(2, "Mixer_001")], [False], 7),
|
||||||
|
]
|
||||||
|
client = await GalaxyRepositoryClient.connect(
|
||||||
|
ClientOptions(endpoint="fake", plaintext=True),
|
||||||
|
stub=stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
roots = await client.browse()
|
||||||
|
# Ten concurrent expand calls on the same node should issue exactly one RPC.
|
||||||
|
await asyncio.gather(*(roots[0].expand() for _ in range(10)))
|
||||||
|
|
||||||
|
assert roots[0].is_expanded
|
||||||
|
assert len(roots[0].children) == 1
|
||||||
|
# 1 roots fetch + exactly 1 expand fetch = 2 total
|
||||||
|
assert len(stub.browse_children.requests) == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None:
|
async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None:
|
||||||
stub = FakeGalaxyStub()
|
stub = FakeGalaxyStub()
|
||||||
@@ -485,6 +507,35 @@ async def test_browse_with_filter_forwards_to_request() -> None:
|
|||||||
assert request.historized_only is True
|
assert request.historized_only is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_children_raw_returns_reply_unwrapped() -> None:
|
||||||
|
"""browse_children_raw forwards the request to the stub and returns the raw reply."""
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
expected = _build_browse_reply(
|
||||||
|
children=[_obj(1, "Plant", is_area=True)],
|
||||||
|
child_has_children=[True],
|
||||||
|
cache_sequence=42,
|
||||||
|
)
|
||||||
|
stub.browse_children.replies = [expected]
|
||||||
|
|
||||||
|
async with await GalaxyRepositoryClient.connect(
|
||||||
|
endpoint="fake",
|
||||||
|
plaintext=True,
|
||||||
|
stub=stub,
|
||||||
|
) as client:
|
||||||
|
request = galaxy_pb.BrowseChildrenRequest(
|
||||||
|
page_size=10,
|
||||||
|
tag_name_glob="Plant*",
|
||||||
|
)
|
||||||
|
reply = await client.browse_children_raw(request)
|
||||||
|
|
||||||
|
assert reply.cache_sequence == 42
|
||||||
|
assert len(reply.children) == 1
|
||||||
|
assert reply.children[0].tag_name == "Plant"
|
||||||
|
assert len(stub.browse_children.requests) == 1
|
||||||
|
assert stub.browse_children.requests[0].tag_name_glob == "Plant*"
|
||||||
|
|
||||||
|
|
||||||
class FakeGalaxyStub:
|
class FakeGalaxyStub:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
|
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
|
||||||
@@ -506,7 +557,8 @@ class FakeUnary:
|
|||||||
def __init__(self, replies: list[Any]) -> None:
|
def __init__(self, replies: list[Any]) -> None:
|
||||||
self.replies = replies
|
self.replies = replies
|
||||||
self.requests: list[Any] = []
|
self.requests: list[Any] = []
|
||||||
self.exceptions: list[BaseException] = []
|
# None entries mean "no exception on this call"; aligns with the replies queue index-by-index.
|
||||||
|
self.exceptions: list[BaseException | None] = []
|
||||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""TLS behaviour tests for ``create_channel``.
|
||||||
|
|
||||||
|
These spin up a real loopback ``grpc.aio`` server with a freshly generated
|
||||||
|
self-signed certificate (carrying a ``localhost`` SAN, mirroring the gateway's
|
||||||
|
auto-generated cert) and assert the lenient TOFU default lets a client connect
|
||||||
|
without any CA configured.
|
||||||
|
|
||||||
|
Marked ``tls`` and skipped unless ``MXGATEWAY_RUN_TLS_TESTS=1`` because loopback
|
||||||
|
TLS handshakes can be timing-flaky on shared CI runners. This mirrors how the
|
||||||
|
suite gates anything that depends on real sockets rather than fakes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import grpc
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from zb_mom_ww_mxgateway import ClientOptions
|
||||||
|
from zb_mom_ww_mxgateway.errors import MxGatewayTransportError
|
||||||
|
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||||
|
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
||||||
|
from zb_mom_ww_mxgateway.options import create_channel
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.tls
|
||||||
|
|
||||||
|
_RUN_TLS_TESTS = os.environ.get("MXGATEWAY_RUN_TLS_TESTS") == "1"
|
||||||
|
_OPENSSL = shutil.which("openssl")
|
||||||
|
|
||||||
|
requires_tls = pytest.mark.skipif(
|
||||||
|
not _RUN_TLS_TESTS,
|
||||||
|
reason="set MXGATEWAY_RUN_TLS_TESTS=1 to run loopback TLS tests",
|
||||||
|
)
|
||||||
|
requires_openssl = pytest.mark.skipif(
|
||||||
|
_OPENSSL is None,
|
||||||
|
reason="openssl CLI is required to generate a self-signed test certificate",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_self_signed_cert(directory: Path) -> tuple[Path, Path]:
|
||||||
|
"""Generate a self-signed cert/key pair with a ``localhost`` SAN."""
|
||||||
|
key_path = directory / "server.key"
|
||||||
|
cert_path = directory / "server.crt"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
str(_OPENSSL),
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-nodes",
|
||||||
|
"-keyout",
|
||||||
|
str(key_path),
|
||||||
|
"-out",
|
||||||
|
str(cert_path),
|
||||||
|
"-days",
|
||||||
|
"1",
|
||||||
|
"-subj",
|
||||||
|
"/CN=mxgateway-test",
|
||||||
|
"-addext",
|
||||||
|
"subjectAltName=DNS:localhost,IP:127.0.0.1",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
return cert_path, key_path
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
class _StaticGatewayServicer(pb_grpc.MxAccessGatewayServicer):
|
||||||
|
"""Minimal servicer answering ``OpenSession`` with a fixed session id."""
|
||||||
|
|
||||||
|
async def OpenSession( # noqa: N802 - generated gRPC method name
|
||||||
|
self, request: pb.OpenSessionRequest, context: object
|
||||||
|
) -> pb.OpenSessionReply:
|
||||||
|
return pb.OpenSessionReply(session_id="tls-session-1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def tls_server() -> AsyncIterator[int]:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cert_path, key_path = _generate_self_signed_cert(Path(tmp))
|
||||||
|
credentials = grpc.ssl_server_credentials(
|
||||||
|
[(key_path.read_bytes(), cert_path.read_bytes())]
|
||||||
|
)
|
||||||
|
server = grpc.aio.server()
|
||||||
|
pb_grpc.add_MxAccessGatewayServicer_to_server(_StaticGatewayServicer(), server)
|
||||||
|
port = _free_port()
|
||||||
|
server.add_secure_port(f"127.0.0.1:{port}", credentials)
|
||||||
|
await server.start()
|
||||||
|
try:
|
||||||
|
yield port
|
||||||
|
finally:
|
||||||
|
await server.stop(grace=None)
|
||||||
|
|
||||||
|
|
||||||
|
@requires_tls
|
||||||
|
@requires_openssl
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_tls_connects_via_tofu(tls_server: int) -> None:
|
||||||
|
"""Default TLS options (no CA) connect by pinning the presented cert."""
|
||||||
|
options = ClientOptions(
|
||||||
|
endpoint=f"127.0.0.1:{tls_server}",
|
||||||
|
api_key="mxgw_test_secret",
|
||||||
|
)
|
||||||
|
channel = create_channel(options)
|
||||||
|
try:
|
||||||
|
stub = pb_grpc.MxAccessGatewayStub(channel)
|
||||||
|
reply = await stub.OpenSession(pb.OpenSessionRequest(), timeout=10)
|
||||||
|
assert reply.session_id == "tls-session-1"
|
||||||
|
finally:
|
||||||
|
await channel.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_authority_parses_host_and_port() -> None:
|
||||||
|
from zb_mom_ww_mxgateway.options import _split_authority
|
||||||
|
|
||||||
|
assert _split_authority("https://10.0.0.5:5120") == ("10.0.0.5", 5120)
|
||||||
|
assert _split_authority("localhost:5120") == ("localhost", 5120)
|
||||||
|
assert _split_authority(":5120") == ("localhost", 5120)
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_authority_strips_ipv6_brackets() -> None:
|
||||||
|
from zb_mom_ww_mxgateway.options import _split_authority
|
||||||
|
|
||||||
|
# Bracketed IPv6 with port — brackets must be removed for ssl.get_server_certificate
|
||||||
|
assert _split_authority("[::1]:5120") == ("::1", 5120)
|
||||||
|
# Bare bracketed IPv6 (no port) — default port 443
|
||||||
|
assert _split_authority("[::1]") == ("::1", 443)
|
||||||
|
# Scheme-prefixed bracketed IPv6
|
||||||
|
assert _split_authority("grpc://[::1]:5120") == ("::1", 5120)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tofu_connect_failure_raises_transport_error() -> None:
|
||||||
|
"""A failed cert pre-fetch surfaces the client's transport error type."""
|
||||||
|
options = ClientOptions(endpoint=f"127.0.0.1:{_free_port()}")
|
||||||
|
with pytest.raises(MxGatewayTransportError) as excinfo:
|
||||||
|
create_channel(options)
|
||||||
|
assert options.endpoint in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_certificate_validation_uses_system_trust() -> None:
|
||||||
|
"""``require_certificate_validation`` must not attempt a TOFU pre-fetch."""
|
||||||
|
# Pointing at a closed port: with system-trust the channel is created lazily
|
||||||
|
# (no eager pre-fetch), so create_channel must succeed without connecting.
|
||||||
|
options = ClientOptions(
|
||||||
|
endpoint=f"127.0.0.1:{_free_port()}",
|
||||||
|
require_certificate_validation=True,
|
||||||
|
)
|
||||||
|
channel = create_channel(options)
|
||||||
|
assert isinstance(channel, grpc.aio.Channel)
|
||||||
@@ -17,3 +17,6 @@
|
|||||||
# args through the GNU linker and reject `/STACK:`, are unaffected.
|
# args through the GNU linker and reject `/STACK:`, are unaffected.
|
||||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||||
rustflags = ["-C", "link-arg=/STACK:8388608"]
|
rustflags = ["-C", "link-arg=/STACK:8388608"]
|
||||||
|
|
||||||
|
[registries.dohertj2-gitea]
|
||||||
|
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
|
||||||
|
|||||||
+14
-2
@@ -2,7 +2,16 @@
|
|||||||
name = "zb-mom-ww-mxgateway-client"
|
name = "zb-mom-ww-mxgateway-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
authors = ["Joseph Doherty"]
|
||||||
|
description = "Async Rust client for the MxAccessGateway gRPC service, including a lazy-browse walker over the Galaxy Repository hierarchy."
|
||||||
|
license = "Proprietary"
|
||||||
|
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
documentation = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||||
|
categories = ["api-bindings", "asynchronous"]
|
||||||
|
publish = ["dohertj2-gitea"]
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
@@ -12,7 +21,10 @@ resolver = "2"
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
publish = false
|
authors = ["Joseph Doherty"]
|
||||||
|
license = "Proprietary"
|
||||||
|
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
publish = ["dohertj2-gitea"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
clap = { version = "4.5.53", features = ["derive"] }
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ types.
|
|||||||
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
|
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### TLS trust (pin-only)
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI).
|
||||||
|
Unlike the other clients, the Rust client is **not** lenient: tonic 0.13.1
|
||||||
|
exposes no public hook to inject a custom certificate verifier, so TLS over Rust
|
||||||
|
is pin-only. A TLS connection requires either `--ca-file` /
|
||||||
|
`ClientOptions::with_ca_file(...)` to pin a CA (export the gateway's self-signed
|
||||||
|
certificate and pin it), or `--require-certificate-validation` /
|
||||||
|
`with_require_certificate_validation(true)` to verify against the system trust
|
||||||
|
roots. TLS with neither set fails `connect` with a clear, actionable error rather
|
||||||
|
than accepting the certificate. See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
## Library Surface
|
## Library Surface
|
||||||
|
|
||||||
`ClientOptions` configures endpoint, API key, plaintext or TLS transport,
|
`ClientOptions` configures endpoint, API key, plaintext or TLS transport,
|
||||||
@@ -157,6 +170,31 @@ for (child, has_children) in reply.children.iter().zip(reply.child_has_children.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut client = GalaxyClient::connect(
|
||||||
|
ClientOptions::new("http://localhost:5000").with_api_key(ApiKey::new(api_key)),
|
||||||
|
).await?;
|
||||||
|
let roots = client.browse(None).await?;
|
||||||
|
for root in &roots {
|
||||||
|
if root.has_children_hint() {
|
||||||
|
root.expand().await?;
|
||||||
|
}
|
||||||
|
for child in root.children().await {
|
||||||
|
let kind = if child.has_children_hint() { "has children" } else { "leaf" };
|
||||||
|
println!("{} ({kind})", child.object().tag_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expand` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`browse` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
|
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
|
||||||
@@ -211,3 +249,27 @@ cargo run -p mxgw-cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --
|
|||||||
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||||
- [Rust Client Detailed Design](./RustClientDesign.md)
|
- [Rust Client Detailed Design](./RustClientDesign.md)
|
||||||
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
||||||
|
|
||||||
|
## Installing from the Gitea Cargo registry
|
||||||
|
|
||||||
|
The crate publishes to the internal Gitea Cargo registry. Register the
|
||||||
|
registry once in your global `~/.cargo/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[registries.dohertj2-gitea]
|
||||||
|
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Authentication: cargo reads credentials from `~/.cargo/credentials.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[registries.dohertj2-gitea]
|
||||||
|
token = "Bearer <your-gitea-token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the dependency:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
zb-mom-ww-mxgateway-client = { version = "0.1.0", registry = "dohertj2-gitea" }
|
||||||
|
```
|
||||||
|
|||||||
@@ -189,6 +189,25 @@ Support:
|
|||||||
- custom CA file,
|
- custom CA file,
|
||||||
- domain override.
|
- domain override.
|
||||||
|
|
||||||
|
### Trust posture (pin-only)
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). Rust is the **exception** to the lenient-by-default posture the other
|
||||||
|
clients use: tonic 0.13.1 exposes no public hook to inject a custom certificate
|
||||||
|
verifier, so the Rust client cannot accept an arbitrary certificate. TLS over the
|
||||||
|
Rust client is therefore **pin-only** — it requires either:
|
||||||
|
|
||||||
|
- `ClientOptions::with_ca_file(...)` to pin a CA (the supported path for the
|
||||||
|
gateway's self-signed certificate; export the certificate and pin it), or
|
||||||
|
- `ClientOptions::with_require_certificate_validation(true)` to verify against the
|
||||||
|
system trust roots.
|
||||||
|
|
||||||
|
With TLS enabled (`with_plaintext(false)`), no pinned CA, and certificate
|
||||||
|
validation not required, `GatewayClient::connect` rejects the connection with a
|
||||||
|
clear, actionable error pointing at `with_ca_file` /
|
||||||
|
`require_certificate_validation` rather than silently accepting the certificate.
|
||||||
|
The CLI exposes `--ca-file` and `--require-certificate-validation`.
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the
|
Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name = "mxgw-cli"
|
name = "mxgw-cli"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
publish.workspace = true
|
publish = false
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "mxgw"
|
name = "mxgw"
|
||||||
|
|||||||
@@ -426,6 +426,11 @@ struct ConnectionArgs {
|
|||||||
ca_file: Option<PathBuf>,
|
ca_file: Option<PathBuf>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
server_name_override: Option<String>,
|
server_name_override: Option<String>,
|
||||||
|
/// Verify the server certificate against the system trust roots even
|
||||||
|
/// without a pinned CA. The Rust client's default is to require a CA
|
||||||
|
/// file (see `--ca-file`); set this flag to use system roots instead.
|
||||||
|
#[arg(long)]
|
||||||
|
require_certificate_validation: bool,
|
||||||
#[arg(long, default_value_t = 10)]
|
#[arg(long, default_value_t = 10)]
|
||||||
connect_timeout_seconds: u64,
|
connect_timeout_seconds: u64,
|
||||||
#[arg(long, default_value_t = 30)]
|
#[arg(long, default_value_t = 30)]
|
||||||
@@ -453,6 +458,9 @@ impl ConnectionArgs {
|
|||||||
if let Some(server_name_override) = &self.server_name_override {
|
if let Some(server_name_override) = &self.server_name_override {
|
||||||
options = options.with_server_name_override(server_name_override);
|
options = options.with_server_name_override(server_name_override);
|
||||||
}
|
}
|
||||||
|
if self.require_certificate_validation {
|
||||||
|
options = options.with_require_certificate_validation(true);
|
||||||
|
}
|
||||||
|
|
||||||
options
|
options
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,8 @@
|
|||||||
//! code should prefer [`GatewayClient::open_session`] and the [`Session`]
|
//! code should prefer [`GatewayClient::open_session`] and the [`Session`]
|
||||||
//! handle it returns, rather than the `*_raw` methods.
|
//! handle it returns, rather than the `*_raw` methods.
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use tonic::codegen::InterceptedService;
|
use tonic::codegen::InterceptedService;
|
||||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
use tonic::transport::Channel;
|
||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
|
|
||||||
use crate::auth::AuthInterceptor;
|
use crate::auth::AuthInterceptor;
|
||||||
@@ -21,7 +19,7 @@ use crate::generated::mxaccess_gateway::v1::{
|
|||||||
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
|
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
|
||||||
StreamEventsRequest,
|
StreamEventsRequest,
|
||||||
};
|
};
|
||||||
use crate::options::ClientOptions;
|
use crate::options::{build_tls_config, ClientOptions};
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
/// Generated gateway client wrapped in the auth interceptor that
|
/// Generated gateway client wrapped in the auth interceptor that
|
||||||
@@ -78,18 +76,7 @@ impl GatewayClient {
|
|||||||
})?;
|
})?;
|
||||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||||
|
|
||||||
if !options.plaintext() {
|
if let Some(tls) = build_tls_config(&options)? {
|
||||||
let mut tls = ClientTlsConfig::new();
|
|
||||||
if let Some(server_name) = options.server_name_override() {
|
|
||||||
tls = tls.domain_name(server_name.to_owned());
|
|
||||||
}
|
|
||||||
if let Some(ca_file) = options.ca_file() {
|
|
||||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
|
||||||
endpoint: options.endpoint().to_owned(),
|
|
||||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
|
||||||
})?;
|
|
||||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
|
||||||
}
|
|
||||||
endpoint = endpoint.tls_config(tls)?;
|
endpoint = endpoint.tls_config(tls)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,12 @@
|
|||||||
//! re-exported through [`crate::generated::galaxy_repository::v1`].
|
//! re-exported through [`crate::generated::galaxy_repository::v1`].
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use prost_types::Timestamp;
|
use prost_types::Timestamp;
|
||||||
use tokio::sync::Mutex as AsyncMutex;
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
use tonic::codegen::InterceptedService;
|
use tonic::codegen::InterceptedService;
|
||||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
use tonic::transport::Channel;
|
||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
|
|
||||||
use crate::auth::AuthInterceptor;
|
use crate::auth::AuthInterceptor;
|
||||||
@@ -23,7 +22,7 @@ use crate::generated::galaxy_repository::v1::{
|
|||||||
DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest,
|
DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest,
|
||||||
WatchDeployEventsRequest,
|
WatchDeployEventsRequest,
|
||||||
};
|
};
|
||||||
use crate::options::ClientOptions;
|
use crate::options::{build_tls_config, ClientOptions};
|
||||||
|
|
||||||
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
|
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
|
||||||
const BROWSE_CHILDREN_PAGE_SIZE: i32 = 500;
|
const BROWSE_CHILDREN_PAGE_SIZE: i32 = 500;
|
||||||
@@ -183,18 +182,7 @@ impl GalaxyClient {
|
|||||||
})?;
|
})?;
|
||||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||||
|
|
||||||
if !options.plaintext() {
|
if let Some(tls) = build_tls_config(&options)? {
|
||||||
let mut tls = ClientTlsConfig::new();
|
|
||||||
if let Some(server_name) = options.server_name_override() {
|
|
||||||
tls = tls.domain_name(server_name.to_owned());
|
|
||||||
}
|
|
||||||
if let Some(ca_file) = options.ca_file() {
|
|
||||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
|
||||||
endpoint: options.endpoint().to_owned(),
|
|
||||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
|
||||||
})?;
|
|
||||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
|
||||||
}
|
|
||||||
endpoint = endpoint.tls_config(tls)?;
|
endpoint = endpoint.tls_config(tls)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
//! chain of `with_*` setters; the `Debug` impl redacts the API key.
|
//! chain of `with_*` setters; the `Debug` impl redacts the API key.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tonic::transport::{Certificate, ClientTlsConfig};
|
||||||
|
|
||||||
use crate::auth::ApiKey;
|
use crate::auth::ApiKey;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ pub struct ClientOptions {
|
|||||||
api_key: Option<ApiKey>,
|
api_key: Option<ApiKey>,
|
||||||
plaintext: bool,
|
plaintext: bool,
|
||||||
ca_file: Option<PathBuf>,
|
ca_file: Option<PathBuf>,
|
||||||
|
require_certificate_validation: bool,
|
||||||
server_name_override: Option<String>,
|
server_name_override: Option<String>,
|
||||||
connect_timeout: Duration,
|
connect_timeout: Duration,
|
||||||
call_timeout: Duration,
|
call_timeout: Duration,
|
||||||
@@ -38,6 +43,7 @@ impl ClientOptions {
|
|||||||
api_key: None,
|
api_key: None,
|
||||||
plaintext: true,
|
plaintext: true,
|
||||||
ca_file: None,
|
ca_file: None,
|
||||||
|
require_certificate_validation: false,
|
||||||
server_name_override: None,
|
server_name_override: None,
|
||||||
connect_timeout: Duration::from_secs(10),
|
connect_timeout: Duration::from_secs(10),
|
||||||
call_timeout: Duration::from_secs(30),
|
call_timeout: Duration::from_secs(30),
|
||||||
@@ -67,6 +73,22 @@ impl ClientOptions {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Require TLS certificate verification even without a pinned CA. Default
|
||||||
|
/// false: the gateway's self-signed certificate is accepted (internal-tool
|
||||||
|
/// posture). Setting a CA file always verifies.
|
||||||
|
///
|
||||||
|
/// Note for Rust: tonic 0.13's `ClientTlsConfig` exposes no hook for a
|
||||||
|
/// custom rustls verifier, so the Rust client cannot accept an arbitrary
|
||||||
|
/// self-signed certificate the way the other clients do. With the default
|
||||||
|
/// (false) and no pinned CA, [`crate::client::GatewayClient::connect`]
|
||||||
|
/// rejects the TLS connection and asks for a CA file. Either pin a CA via
|
||||||
|
/// [`ClientOptions::with_ca_file`] (the supported lenient path on Rust) or
|
||||||
|
/// set this `true` to verify against the system trust roots.
|
||||||
|
pub fn with_require_certificate_validation(mut self, require: bool) -> Self {
|
||||||
|
self.require_certificate_validation = require;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Override the SNI/server name used during the TLS handshake. Useful
|
/// Override the SNI/server name used during the TLS handshake. Useful
|
||||||
/// when the dial-target host name does not match the certificate.
|
/// when the dial-target host name does not match the certificate.
|
||||||
pub fn with_server_name_override(mut self, server_name_override: impl Into<String>) -> Self {
|
pub fn with_server_name_override(mut self, server_name_override: impl Into<String>) -> Self {
|
||||||
@@ -121,6 +143,12 @@ impl ClientOptions {
|
|||||||
self.ca_file.as_ref()
|
self.ca_file.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether TLS certificate verification is required even without a pinned
|
||||||
|
/// CA. See [`ClientOptions::with_require_certificate_validation`].
|
||||||
|
pub fn require_certificate_validation(&self) -> bool {
|
||||||
|
self.require_certificate_validation
|
||||||
|
}
|
||||||
|
|
||||||
/// Optional SNI / server-name override for TLS handshakes.
|
/// Optional SNI / server-name override for TLS handshakes.
|
||||||
pub fn server_name_override(&self) -> Option<&str> {
|
pub fn server_name_override(&self) -> Option<&str> {
|
||||||
self.server_name_override.as_deref()
|
self.server_name_override.as_deref()
|
||||||
@@ -147,6 +175,68 @@ impl ClientOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the [`ClientTlsConfig`] for a non-plaintext connection described by
|
||||||
|
/// `options`, applying the lenient-default guard that is the **Rust
|
||||||
|
/// pin-only exception**.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when `options.plaintext()` is `true` (no TLS needed).
|
||||||
|
/// Returns `Ok(Some(tls))` when a valid TLS config can be assembled.
|
||||||
|
/// Returns `Err(Error::InvalidEndpoint)` when TLS is requested but no pinned
|
||||||
|
/// CA was provided and `require_certificate_validation` is `false`.
|
||||||
|
///
|
||||||
|
/// # Why this guard exists
|
||||||
|
///
|
||||||
|
/// `tonic` 0.13's `ClientTlsConfig` builds its rustls verifier inside a
|
||||||
|
/// crate-private connector and exposes no hook for a custom
|
||||||
|
/// `ServerCertVerifier`. The Rust client therefore cannot accept an arbitrary
|
||||||
|
/// self-signed certificate the way the other language clients do. Rather than
|
||||||
|
/// silently falling back to system-root verification (which always fails
|
||||||
|
/// against a self-signed gateway certificate), we reject the configuration
|
||||||
|
/// early with an actionable error.
|
||||||
|
pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientTlsConfig>, Error> {
|
||||||
|
if options.plaintext() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tls = ClientTlsConfig::new();
|
||||||
|
if let Some(server_name) = options.server_name_override() {
|
||||||
|
tls = tls.domain_name(server_name.to_owned());
|
||||||
|
}
|
||||||
|
if let Some(ca_file) = options.ca_file() {
|
||||||
|
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
||||||
|
endpoint: options.endpoint().to_owned(),
|
||||||
|
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||||
|
})?;
|
||||||
|
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
||||||
|
} else if !options.require_certificate_validation() {
|
||||||
|
// Lenient-default fallback (Rust pin-only exception): tonic
|
||||||
|
// 0.13's `ClientTlsConfig` builds its rustls verifier inside a
|
||||||
|
// crate-private connector and exposes no hook for a custom
|
||||||
|
// `ServerCertVerifier`, so — unlike the other clients — the
|
||||||
|
// Rust client cannot accept an arbitrary self-signed cert. Pin
|
||||||
|
// the gateway's CA instead, or opt into strict verification
|
||||||
|
// against the system trust roots. We reject here rather than
|
||||||
|
// silently verifying against system roots (which would fail a
|
||||||
|
// self-signed gateway with a confusing handshake error).
|
||||||
|
//
|
||||||
|
// Note: a server-name override affects SNI (the hostname sent
|
||||||
|
// in the TLS ClientHello) but does NOT pin trust. Overriding
|
||||||
|
// the server name alone does not bypass certificate validation.
|
||||||
|
return Err(Error::InvalidEndpoint {
|
||||||
|
endpoint: options.endpoint().to_owned(),
|
||||||
|
detail: "TLS requested without a pinned CA. The Rust client cannot accept an \
|
||||||
|
arbitrary self-signed certificate (tonic 0.13 exposes no custom \
|
||||||
|
rustls verifier). Pin the gateway certificate with \
|
||||||
|
ClientOptions::with_ca_file, or call \
|
||||||
|
ClientOptions::with_require_certificate_validation(true) to verify \
|
||||||
|
against the system trust roots. Note: a server-name override \
|
||||||
|
affects SNI but does not pin trust."
|
||||||
|
.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Some(tls))
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ClientOptions {
|
impl Default for ClientOptions {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new("http://127.0.0.1:5000")
|
Self::new("http://127.0.0.1:5000")
|
||||||
@@ -161,6 +251,10 @@ impl fmt::Debug for ClientOptions {
|
|||||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||||
.field("plaintext", &self.plaintext)
|
.field("plaintext", &self.plaintext)
|
||||||
.field("ca_file", &self.ca_file)
|
.field("ca_file", &self.ca_file)
|
||||||
|
.field(
|
||||||
|
"require_certificate_validation",
|
||||||
|
&self.require_certificate_validation,
|
||||||
|
)
|
||||||
.field("server_name_override", &self.server_name_override)
|
.field("server_name_override", &self.server_name_override)
|
||||||
.field("connect_timeout", &self.connect_timeout)
|
.field("connect_timeout", &self.connect_timeout)
|
||||||
.field("call_timeout", &self.call_timeout)
|
.field("call_timeout", &self.call_timeout)
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! TLS posture coverage for the Rust client.
|
||||||
|
//!
|
||||||
|
//! tonic 0.13.1's `ClientTlsConfig` exposes no hook for a custom rustls
|
||||||
|
//! `ServerCertVerifier` (the verifier is built internally inside the
|
||||||
|
//! crate-private `TlsConnector`), so the Rust client cannot implement the
|
||||||
|
//! "accept any server certificate" lenient default the other clients use.
|
||||||
|
//! Rust is therefore the documented **pin-only exception**: TLS without a
|
||||||
|
//! pinned CA is rejected up front with a clear, actionable error, and
|
||||||
|
//! supplying a CA file is the supported path. These tests pin that contract.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use zb_mom_ww_mxgateway_client::{ClientOptions, Error, GalaxyClient, GatewayClient};
|
||||||
|
|
||||||
|
/// Drive `connect` to its error without requiring `GatewayClient: Debug`
|
||||||
|
/// (the success arm is dropped explicitly so `unwrap_err` is unnecessary).
|
||||||
|
async fn connect_err(options: ClientOptions) -> Error {
|
||||||
|
match GatewayClient::connect(options).await {
|
||||||
|
Ok(_client) => panic!("connect unexpectedly succeeded against a dead TLS address"),
|
||||||
|
Err(error) => error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tls_without_ca_is_rejected_with_actionable_error_by_default() {
|
||||||
|
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||||
|
.with_plaintext(false)
|
||||||
|
.with_connect_timeout(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let error = connect_err(options).await;
|
||||||
|
|
||||||
|
let Error::InvalidEndpoint { detail, .. } = error else {
|
||||||
|
panic!("expected InvalidEndpoint, got {error:?}");
|
||||||
|
};
|
||||||
|
// The message must point the caller at the supported remedy (pin a CA)
|
||||||
|
// and name the opt-in escape hatch.
|
||||||
|
assert!(
|
||||||
|
detail.contains("ca_file") || detail.contains("CA"),
|
||||||
|
"error should instruct the user to pass a CA file: {detail}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
detail.contains("require_certificate_validation"),
|
||||||
|
"error should mention the require_certificate_validation opt-in: {detail}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tls_with_require_certificate_validation_does_not_short_circuit() {
|
||||||
|
// With strict verification opted in, the no-CA guard must not fire; the
|
||||||
|
// connect attempt instead proceeds to the transport (and fails to reach
|
||||||
|
// the dead address) rather than returning the "CA required" guard error.
|
||||||
|
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||||
|
.with_plaintext(false)
|
||||||
|
.with_require_certificate_validation(true)
|
||||||
|
.with_connect_timeout(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let error = connect_err(options).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!matches!(&error, Error::InvalidEndpoint { detail, .. }
|
||||||
|
if detail.contains("require_certificate_validation")),
|
||||||
|
"strict verification must bypass the no-CA guard, got {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tls_with_ca_file_is_permitted_and_proceeds_past_the_guard() {
|
||||||
|
// Pinning a CA is the supported TLS path: the no-CA guard must not fire.
|
||||||
|
// We hand it a readable PEM file; construction proceeds past the guard
|
||||||
|
// and only fails later at the transport (dead address / handshake).
|
||||||
|
let ca_path = std::env::temp_dir().join("mxgw-rust-tls-ca-fixture.pem");
|
||||||
|
std::fs::write(&ca_path, SELF_SIGNED_CA_PEM).unwrap();
|
||||||
|
|
||||||
|
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||||
|
.with_plaintext(false)
|
||||||
|
.with_ca_file(&ca_path)
|
||||||
|
.with_connect_timeout(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let error = connect_err(options).await;
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&ca_path);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!matches!(&error, Error::InvalidEndpoint { detail, .. }
|
||||||
|
if detail.contains("require_certificate_validation")),
|
||||||
|
"pinning a CA must bypass the no-CA guard, got {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive `GalaxyClient::connect` to its error (mirrors `connect_err` above).
|
||||||
|
async fn galaxy_connect_err(options: ClientOptions) -> Error {
|
||||||
|
match GalaxyClient::connect(options).await {
|
||||||
|
Ok(_client) => {
|
||||||
|
panic!("GalaxyClient::connect unexpectedly succeeded against a dead TLS address")
|
||||||
|
}
|
||||||
|
Err(error) => error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn galaxy_tls_without_ca_is_rejected_with_actionable_error_by_default() {
|
||||||
|
// GalaxyClient::connect must apply the same TLS guard as GatewayClient —
|
||||||
|
// TLS without a pinned CA (and without require_certificate_validation)
|
||||||
|
// returns a clear, actionable InvalidEndpoint error.
|
||||||
|
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||||
|
.with_plaintext(false)
|
||||||
|
.with_connect_timeout(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let error = galaxy_connect_err(options).await;
|
||||||
|
|
||||||
|
let Error::InvalidEndpoint { detail, .. } = error else {
|
||||||
|
panic!("expected InvalidEndpoint, got {error:?}");
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
detail.contains("ca_file") || detail.contains("CA"),
|
||||||
|
"error should instruct the user to pass a CA file: {detail}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
detail.contains("require_certificate_validation"),
|
||||||
|
"error should mention the require_certificate_validation opt-in: {detail}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A throwaway self-signed CA certificate (PEM). Only needs to parse as a
|
||||||
|
/// PEM trust root so the CA-pinning path is exercised past the guard.
|
||||||
|
const SELF_SIGNED_CA_PEM: &str = "-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
|
||||||
|
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
|
||||||
|
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
|
||||||
|
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
|
||||||
|
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
|
||||||
|
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
|
||||||
|
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
|
||||||
|
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
|
||||||
|
6MF9+Yw1Yy0t
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
";
|
||||||
@@ -790,3 +790,159 @@ Post-ack transition: kind=Clear …
|
|||||||
|
|
||||||
10s cadence held throughout; full proto fields populated correctly;
|
10s cadence held throughout; full proto fields populated correctly;
|
||||||
ack registered server-side without errors.
|
ack registered server-side without errors.
|
||||||
|
|
||||||
|
## Subtag-monitoring fallback provider
|
||||||
|
|
||||||
|
When the wnwrap alarm-manager source fails, the gateway worker switches to
|
||||||
|
`SubtagAlarmConsumer` — a synthetic alarm source that advises each alarm
|
||||||
|
attribute's subtags via the existing MXAccess `AddItem`/`Advise` pipeline and
|
||||||
|
derives alarm transitions from the resulting value-change stream. This is a
|
||||||
|
non-parity, degraded-mode source; every transition and snapshot it produces
|
||||||
|
carries `degraded = true`.
|
||||||
|
|
||||||
|
### Watch-list discovery
|
||||||
|
|
||||||
|
`GatewayAlarmMonitor` resolves the subtag watch-list at subscribe time by
|
||||||
|
calling `IAlarmWatchListResolver.GetAlarmAttributesAsync`. The resolver merges:
|
||||||
|
|
||||||
|
1. Galaxy Repository SQL (`GetAlarmAttributesAsync`) — objects that have alarm
|
||||||
|
extensions in the configured area.
|
||||||
|
2. Config overrides — `IncludeAttributes` adds explicit entries;
|
||||||
|
`ExcludeAttributes` removes Repository-derived ones. The config list takes
|
||||||
|
effect even when `UseGalaxyRepository` is `false`.
|
||||||
|
|
||||||
|
The resolved list is a set of `AlarmSubtagTarget` messages sent to the worker
|
||||||
|
inside `SubscribeAlarmsCommand.watch_list`. Each target carries the composed
|
||||||
|
MXAccess item addresses for the `InAlarm`, `Acked`, `AckMsg`, and `Priority`
|
||||||
|
subtags (confirmed AVEVA `AlarmExtension` field names, verified against the live
|
||||||
|
ZB Galaxy `attribute_definition` rows). The gateway re-runs discovery on its
|
||||||
|
reconcile cadence and pushes an updated watch-list when the model changes.
|
||||||
|
|
||||||
|
Each target's canonical `AlarmFullReference` is composed as
|
||||||
|
`Galaxy!{area}.{reference}` (literal `Galaxy` provider). The `{area}` is the
|
||||||
|
alarm object's **real Galaxy area** — discovered per object via
|
||||||
|
`gobject.area_gobject_id` (`GetAlarmAttributesAsync` projects it as `area_name`)
|
||||||
|
— so the synthesized reference's group matches exactly the area the native
|
||||||
|
alarmmgr (wnwrap) emits for the same alarm (e.g. `TestMachine_001` in `TestArea`
|
||||||
|
yields `Galaxy!TestArea.TestMachine_001.TestAlarm001`). The configured
|
||||||
|
`Discovery.Area` / `DefaultArea` is **only** the fallback for explicit
|
||||||
|
`IncludeAttributes` entries, which carry no discovered area.
|
||||||
|
|
||||||
|
### Subtag advise and `LmxSubtagAlarmSource`
|
||||||
|
|
||||||
|
`LmxSubtagAlarmSource` (implements `ISubtagAlarmSource`) owns a separate
|
||||||
|
`LMXProxyServerClass` instance on the worker STA — it does not share the
|
||||||
|
session's main MXAccess object. For each watch-list target it calls
|
||||||
|
`AddItem`/`Advise` on the configured subtag addresses. When a subtag value
|
||||||
|
changes, it raises `ValueChanged` on the STA and `SubtagAlarmConsumer`
|
||||||
|
forwards it to `SubtagAlarmStateMachine`.
|
||||||
|
|
||||||
|
`PollOnce()` on the subtag consumer is a no-op — the path is event-driven
|
||||||
|
through `Advise`, not poll-driven.
|
||||||
|
|
||||||
|
### Synthesis rules
|
||||||
|
|
||||||
|
`SubtagAlarmStateMachine` tracks `(active, acked)` per watch-list entry and
|
||||||
|
emits `MxAlarmTransitionEvent` records on change:
|
||||||
|
|
||||||
|
| Subtag change | Emitted transition | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `InAlarm` false → true | Raise (`UNACK_ALM`) | `original_raise_timestamp` = first observed active time for this episode |
|
||||||
|
| `Acked` false → true, while `InAlarm` | Acknowledge (`ACK_ALM`) | `AckedDuringEpisode` latch set |
|
||||||
|
| `InAlarm` true → false | Clear | `AckRtn` if `AckedDuringEpisode` is set, else `UnackRtn` |
|
||||||
|
| `Acked` true → false, while `InAlarm` | (none) | Latch is NOT cleared; the episode retains its acknowledged status at clear |
|
||||||
|
|
||||||
|
The `AckedDuringEpisode` latch addresses out-of-order subtag delivery:
|
||||||
|
MXAccess does not guarantee the `Acked = false` update arrives before the
|
||||||
|
`InAlarm = false` update. The latch ensures a clear always emits `ACK_RTN`
|
||||||
|
when the alarm was acknowledged at any point during the active episode.
|
||||||
|
|
||||||
|
`SnapshotActive()` returns one `MxAlarmSnapshotRecord` per currently-active
|
||||||
|
alarm. State mapping:
|
||||||
|
|
||||||
|
- `InAlarm && !Acked` → `UNACK_ALM`
|
||||||
|
- `InAlarm && Acked` → `ACK_ALM`
|
||||||
|
- `!InAlarm` → not included in the snapshot
|
||||||
|
|
||||||
|
### Synthetic GUID
|
||||||
|
|
||||||
|
The alarmmgr provider supplies a native GUID per alarm record. The subtag
|
||||||
|
provider has no native GUID. `SubtagAlarmConsumer` derives a deterministic
|
||||||
|
GUID by hashing `alarm_full_reference` (via `SyntheticAlarmGuid.ForReference`).
|
||||||
|
The same reference always produces the same GUID within a session, so
|
||||||
|
GUID-based ack routing resolves correctly. The GUID is not stable across
|
||||||
|
different alarm references or gateway restarts in the sense of matching any
|
||||||
|
AVEVA-internal GUID.
|
||||||
|
|
||||||
|
### Acknowledge in subtag mode
|
||||||
|
|
||||||
|
`AlarmDispatcher` routes ack calls by active provider mode:
|
||||||
|
|
||||||
|
- **Alarm-manager mode:** `AlarmAckByName` on `wwAlarmConsumerClass` (unchanged).
|
||||||
|
- **Subtag mode:** `SubtagAlarmConsumer.AcknowledgeByName` resolves the
|
||||||
|
watch-list entry's `ack_comment_subtag` and issues a `Write(comment)` on
|
||||||
|
the STA via `LmxSubtagAlarmSource`. Writing the `AckMsg` subtag performs
|
||||||
|
the acknowledge in AVEVA (`AckMsg` is the confirmed `AlarmExtension` ack-comment
|
||||||
|
write target).
|
||||||
|
|
||||||
|
If the alarm has no writable ack-comment subtag (`AckComment` config key is
|
||||||
|
empty, or the entry's `ack_comment_subtag` field is empty), the ack call
|
||||||
|
returns a failure code that the gateway surfaces as `FailedPrecondition`.
|
||||||
|
`AcknowledgeByGuid` maps the synthetic GUID back to its reference via an
|
||||||
|
internal dictionary, then calls the same write path.
|
||||||
|
|
||||||
|
`SubtagAlarmConsumer.Subscribe` advises the ack-comment subtag alongside the
|
||||||
|
observed ones (active/acked/priority). This is required: MXAccess rejects a
|
||||||
|
write to an item that has been added but not advised with `E_INVALIDARG`
|
||||||
|
("Value does not fall within the expected range"). Advising it at subscribe
|
||||||
|
time makes it an active item so the later ack write succeeds — its value
|
||||||
|
changes carry no transition (the state machine ignores unmapped addresses).
|
||||||
|
|
||||||
|
### Live validation
|
||||||
|
|
||||||
|
The subtag path was validated against live MXAccess on the dev rig
|
||||||
|
(`DESKTOP-6JL3KKO`, Galaxy `DEV`, `TestMachine_001.TestAlarm001`):
|
||||||
|
|
||||||
|
- `….InAlarm` → `True` (Boolean), `….Acked` → `False` (Boolean),
|
||||||
|
`….Priority` → `500` (Int32), `….AckMsg` → string — confirming the field
|
||||||
|
names **and** the runtime reference shape `<Object>.<AlarmAttr>.<field>`
|
||||||
|
with **no** intermediate alarm-condition segment.
|
||||||
|
- `AcknowledgeByName` (AckMsg write) returned `0` once the ack-comment subtag
|
||||||
|
was advised — confirming the ack-by-comment-write mechanism end to end.
|
||||||
|
|
||||||
|
### Fidelity limitations
|
||||||
|
|
||||||
|
The following fields are not available or have lower quality in subtag mode:
|
||||||
|
|
||||||
|
| Field | Subtag-mode behavior |
|
||||||
|
|-------|---------------------|
|
||||||
|
| `alarm_guid` | Synthetic deterministic GUID from `alarm_full_reference`; not an AVEVA-native GUID |
|
||||||
|
| `original_raise_timestamp` | First observed `active = true` time; no AVEVA-native raise time |
|
||||||
|
| `transition_timestamp` | `OnDataChange` source timestamp from MXAccess |
|
||||||
|
| `severity` | From priority subtag if advised; 0 otherwise |
|
||||||
|
| `category` / `description` | Not populated (no subtag for these) |
|
||||||
|
| `current_value` / `limit_value` | Not populated unless corresponding subtags are in the watch-list |
|
||||||
|
| `alarm_type_name` | Not populated |
|
||||||
|
| `operator_user` / `operator_comment` | Not populated on synthesized raise/clear transitions |
|
||||||
|
| `retrigger` transition | Not synthesized (no re-alarm counter subtag is observed) |
|
||||||
|
|
||||||
|
Every transition and snapshot record carries `degraded = true` and
|
||||||
|
`source_provider = ALARM_PROVIDER_MODE_SUBTAG`. Clients that require full
|
||||||
|
fidelity must wait for failback to the alarm manager.
|
||||||
|
|
||||||
|
### Provider mode reflection
|
||||||
|
|
||||||
|
When `FailoverAlarmConsumer` switches between providers, it raises
|
||||||
|
`ProviderModeChanged`. `AlarmDispatcher` enqueues an
|
||||||
|
`OnAlarmProviderModeChangedEvent` (carried as an `MxEvent`), which the
|
||||||
|
gateway receives and reflects into:
|
||||||
|
|
||||||
|
- `AlarmFeedMessage.provider_status` emitted to every `StreamAlarms`
|
||||||
|
subscriber.
|
||||||
|
- The `/hubs/alarms` SignalR hub for the dashboard.
|
||||||
|
- Metrics: `mxgateway.alarms.provider_mode` gauge and
|
||||||
|
`mxgateway.alarms.provider_switches` counter.
|
||||||
|
|
||||||
|
On every switch `GatewayAlarmMonitor` also forces a reconcile
|
||||||
|
(`QueryActiveAlarms`) against the now-active provider so the gateway cache
|
||||||
|
reflects the post-switch state without a spurious raise/clear storm.
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ The shared inputs are:
|
|||||||
The commands in the matrix use `MXGATEWAY_API_KEY` through each CLI's
|
The commands in the matrix use `MXGATEWAY_API_KEY` through each CLI's
|
||||||
`api-key-env` flag. They must not embed bearer tokens or raw API keys.
|
`api-key-env` flag. They must not embed bearer tokens or raw API keys.
|
||||||
|
|
||||||
|
### TLS variant
|
||||||
|
|
||||||
|
The matrix runs over plaintext (`h2c`) by default. A TLS variant exists but stays
|
||||||
|
a manual/opt-in run, consistent with the gate above, because it needs the gateway
|
||||||
|
started with an HTTPS endpoint (an `https://` `MXGATEWAY_ENDPOINT`) and each CLI
|
||||||
|
switched to its TLS flag (`--tls` / `-tls` / `--plaintext=false` /
|
||||||
|
`plaintext=False`). The clients are lenient by default and accept the gateway's
|
||||||
|
auto-generated self-signed certificate without extra trust setup, except the Rust
|
||||||
|
CLI, which is pin-only and needs `--ca-file` or `--require-certificate-validation`
|
||||||
|
(and Python uses trust-on-first-use). See
|
||||||
|
[Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
||||||
|
and each client README for the per-client TLS flags.
|
||||||
|
|
||||||
## JSON Comparison
|
## JSON Comparison
|
||||||
|
|
||||||
Every command in the matrix requests JSON output. A runner can compare the
|
Every command in the matrix requests JSON output. A runner can compare the
|
||||||
|
|||||||
@@ -375,6 +375,94 @@ deployment-heavy box, multiply per-session SQL connections, and complicate the
|
|||||||
cold-start path. Wire-side laziness solves the actual pain (oversized gRPC
|
cold-start path. Wire-side laziness solves the actual pain (oversized gRPC
|
||||||
replies and a heavy DOM) without disturbing the materialization model.
|
replies and a heavy DOM) without disturbing the materialization model.
|
||||||
|
|
||||||
|
## TLS Auto-Certificate and Lenient Client Trust
|
||||||
|
|
||||||
|
Decision: when a Kestrel `https://` endpoint is configured without a certificate
|
||||||
|
of its own (and no `Kestrel:Certificates:Default` is set), the gateway generates
|
||||||
|
and persists a self-signed certificate rather than failing to start. Clients
|
||||||
|
connecting over TLS without a pinned CA accept whatever certificate the server
|
||||||
|
presents by default; pinning a CA restores full verification.
|
||||||
|
|
||||||
|
Rationale: `mxaccessgw` is an internal tool with no PKI to issue or distribute
|
||||||
|
certificates. The prior behavior — an `https` endpoint with no certificate
|
||||||
|
fails at startup with Kestrel's opaque "no server certificate was specified"
|
||||||
|
error — pushed operators toward plaintext (`h2c`), exposing the API key and
|
||||||
|
request payloads on the wire. Auto-generating a long-lived, persisted, reused
|
||||||
|
certificate lets TLS "just work" with zero certificate management, while the
|
||||||
|
lenient client default means clients connect to that self-signed certificate
|
||||||
|
without a manual trust step. Both choices are deliberate, not oversights:
|
||||||
|
strict-by-default would force PKI work this tool does not warrant. Plaintext-only
|
||||||
|
deployments are untouched — no certificate or key material is written for them —
|
||||||
|
and an operator who supplies a real certificate transparently overrides the
|
||||||
|
generated one.
|
||||||
|
|
||||||
|
Two clients diverge from "accept any certificate" because their gRPC stacks lack
|
||||||
|
a per-channel skip-verify hook:
|
||||||
|
|
||||||
|
- Python uses trust-on-first-use: it fetches the server's presented certificate
|
||||||
|
over a separate unverified probe and pins it for the channel, and defaults the
|
||||||
|
SNI/target-name override to `localhost` (the generated certificate always
|
||||||
|
carries a `localhost` SAN).
|
||||||
|
- Rust is pin-only: tonic exposes no public hook to inject a custom certificate
|
||||||
|
verifier, so TLS over Rust requires either a pinned CA or an explicit opt-in to
|
||||||
|
system-trust verification; otherwise connecting returns a clear, actionable
|
||||||
|
error.
|
||||||
|
|
||||||
|
See [Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
||||||
|
and the per-client READMEs for the as-built behavior.
|
||||||
|
|
||||||
|
## Alarm-Manager to Subtag Fallback
|
||||||
|
|
||||||
|
Decision: add a second alarm provider (subtag monitoring) that the worker
|
||||||
|
activates automatically when the native wnwrap alarm manager fails, and fails
|
||||||
|
back to automatically when the manager recovers.
|
||||||
|
|
||||||
|
### Worker-side synthesis
|
||||||
|
|
||||||
|
Synthesis of alarm transitions from subtag value changes happens entirely in
|
||||||
|
the worker (`SubtagAlarmConsumer` / `SubtagAlarmStateMachine`). The gateway
|
||||||
|
still forwards only events the worker emits and synthesizes nothing itself.
|
||||||
|
This satisfies the parity rule even though the subtag path is inherently
|
||||||
|
non-parity: the parity rule governs where synthesis lives, not whether
|
||||||
|
synthesis is permitted when the native source is unavailable.
|
||||||
|
|
||||||
|
### Degraded is explicit
|
||||||
|
|
||||||
|
Every subtag-mode transition carries `degraded = true` on the
|
||||||
|
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` proto messages, and the
|
||||||
|
`AlarmFeedMessage` feed carries an `AlarmProviderStatus` payload on stream
|
||||||
|
open and on every switch. No client can mistake a subtag-mode alarm for an
|
||||||
|
authoritative alarmmgr record. Subtag mode has lower fidelity: synthetic
|
||||||
|
deterministic GUID (SHA-derived from the alarm reference), best-effort
|
||||||
|
original-raise timestamp, narrower field set. Clients that need full fidelity
|
||||||
|
must wait for failback.
|
||||||
|
|
||||||
|
### Failover trigger
|
||||||
|
|
||||||
|
The failover trigger is N consecutive wnwrap COM failures — a `COMException`
|
||||||
|
thrown by `Subscribe` or `PollOnce`, or a failure HRESULT from
|
||||||
|
`GetXmlCurrentAlarms2`. A single poll failure does not trigger a switch; the
|
||||||
|
threshold (default 3, floored at 1) guards against transient COM hiccups. The
|
||||||
|
counter resets on any clean poll so a flapping provider does not permanently
|
||||||
|
latch in subtag mode.
|
||||||
|
|
||||||
|
### Acknowledge via ack-comment write
|
||||||
|
|
||||||
|
In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm
|
||||||
|
attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write
|
||||||
|
performs the native ack in AVEVA. This differs from alarmmgr mode, where
|
||||||
|
`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment`
|
||||||
|
subtag name is empty by default; configuring it is required for ack to work in
|
||||||
|
subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags`
|
||||||
|
config block exists precisely so names are not guessed without validation
|
||||||
|
against the live MXAccess attribute set.
|
||||||
|
|
||||||
|
### Related documentation
|
||||||
|
|
||||||
|
- [Gateway Configuration — Alarm Fallback options](./GatewayConfiguration.md#alarm-fallback-options)
|
||||||
|
- [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md)
|
||||||
|
- [gRPC Contract — provider_status and degraded fields](./Grpc.md)
|
||||||
|
|
||||||
## Later Revisit Items
|
## Later Revisit Items
|
||||||
|
|
||||||
These are explicit post-v1 revisit items, not open blockers:
|
These are explicit post-v1 revisit items, not open blockers:
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ the affected stream while the MXAccess session remains active.
|
|||||||
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
||||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
||||||
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
||||||
|
| `MxGateway:Dashboard:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
|
||||||
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
||||||
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
||||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||||
@@ -229,6 +230,254 @@ behavior.
|
|||||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||||
|
|
||||||
|
### Alarm fallback options
|
||||||
|
|
||||||
|
The `Fallback` sub-section controls how the alarm feed selects between the
|
||||||
|
native wnwrap alarm-manager provider and the subtag-monitoring fallback.
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `MxGateway:Alarms:Fallback:Mode` | `Auto` | Provider selection mode. `Auto` uses the alarm manager as primary and fails over to subtag monitoring after consecutive COM failures, then fails back automatically. `ForceAlarmManager` disables failover. `ForceSubtag` forces subtag monitoring on from startup. Values are case-insensitive. |
|
||||||
|
| `MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold` | `3` | Number of consecutive wnwrap COM failures (`COMException` or failure HRESULT from `Subscribe` / `GetXmlCurrentAlarms2`) before the monitor switches to subtag mode. Floored at 1. |
|
||||||
|
| `MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds` | `30` | While in subtag mode, how often (in seconds) the monitor probes the wnwrap provider to detect recovery. Floored at 1. |
|
||||||
|
| `MxGateway:Alarms:Fallback:FailbackStableProbes` | `3` | Number of consecutive clean wnwrap probes required before the monitor switches back to the alarm manager. Floored at 1. |
|
||||||
|
| `MxGateway:Alarms:Fallback:Discovery:UseGalaxyRepository` | `true` | When `true`, the monitor queries the Galaxy Repository SQL database to build the subtag watch-list for the configured area. |
|
||||||
|
| `MxGateway:Alarms:Fallback:Discovery:Area` | _(empty)_ | Galaxy area to scope the Repository query to. Falls back to `MxGateway:Alarms:DefaultArea` when empty. Ignored when `UseGalaxyRepository` is `false`. This area is **not** used to compose a Repository-derived alarm's canonical `Galaxy!{area}.{reference}`: each discovered alarm uses its object's real Galaxy area (discovered via `gobject.area_gobject_id`), so the reference's group matches what the native alarmmgr emits. `Discovery:Area` / `DefaultArea` is used as the composition area only for explicit `IncludeAttributes` entries, which carry no discovered area. |
|
||||||
|
| `MxGateway:Alarms:Fallback:Discovery:IncludeAttributes` | _(empty)_ | Explicit MXAccess attribute paths to add to the subtag watch-list, supplementing (or replacing, when `UseGalaxyRepository` is `false`) the Repository-derived list. |
|
||||||
|
| `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the Repository-derived watch-list. Ignored when `UseGalaxyRepository` is `false`. |
|
||||||
|
| `MxGateway:Alarms:Fallback:Subtags:Active` | `InAlarm` | Subtag name for the in-alarm boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
||||||
|
| `MxGateway:Alarms:Fallback:Subtags:Acked` | `Acked` | Subtag name for the acknowledged boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
||||||
|
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the ack-comment write path is disabled. |
|
||||||
|
| `MxGateway:Alarms:Fallback:Subtags:Priority` | `Priority` | Subtag name for the alarm priority / severity value. Confirmed AVEVA `AlarmExtension` field name. |
|
||||||
|
|
||||||
|
Validation rules:
|
||||||
|
|
||||||
|
- `Mode` must be `Auto`, `ForceAlarmManager`, or `ForceSubtag` (case-insensitive).
|
||||||
|
- `Mode = ForceSubtag` with both `UseGalaxyRepository = false` and an empty
|
||||||
|
`IncludeAttributes` list produces a startup validation warning: the subtag
|
||||||
|
provider has no attributes to advise.
|
||||||
|
- `ConsecutiveFailureThreshold`, `FailbackProbeIntervalSeconds`, and
|
||||||
|
`FailbackStableProbes` are floored at 1 by `GatewayOptionsValidator`.
|
||||||
|
|
||||||
|
Full example with non-default fallback settings:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"MxGateway": {
|
||||||
|
"Alarms": {
|
||||||
|
"Enabled": true,
|
||||||
|
"SubscriptionExpression": "\\\\SCADA01\\Galaxy!PlantArea",
|
||||||
|
"DefaultArea": "PlantArea",
|
||||||
|
"ReconcileIntervalSeconds": 30,
|
||||||
|
"Fallback": {
|
||||||
|
"Mode": "Auto",
|
||||||
|
"ConsecutiveFailureThreshold": 3,
|
||||||
|
"FailbackProbeIntervalSeconds": 30,
|
||||||
|
"FailbackStableProbes": 3,
|
||||||
|
"Discovery": {
|
||||||
|
"UseGalaxyRepository": true,
|
||||||
|
"Area": "",
|
||||||
|
"IncludeAttributes": [],
|
||||||
|
"ExcludeAttributes": []
|
||||||
|
},
|
||||||
|
"Subtags": {
|
||||||
|
"Active": "InAlarm",
|
||||||
|
"Acked": "Acked",
|
||||||
|
"AckComment": "AckMsg",
|
||||||
|
"Priority": "Priority"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The defaults (`InAlarm`/`Acked`/`AckMsg`/`Priority`) are the confirmed AVEVA
|
||||||
|
`AlarmExtension` primitive field names, verified by querying the live ZB Galaxy
|
||||||
|
`attribute_definition` rows. The `Subtags` block exists so names can be
|
||||||
|
overridden without a code change if a site's alarm template uses different
|
||||||
|
attribute names. See `docs/AlarmClientDiscovery.md` for the synthesis rules that
|
||||||
|
depend on these names.
|
||||||
|
|
||||||
|
## Host Endpoints and Transport Security (Kestrel)
|
||||||
|
|
||||||
|
The listening endpoints are **not** part of the `MxGateway` section. The gateway
|
||||||
|
uses the stock ASP.NET Core host (`WebApplication.CreateBuilder`) with no
|
||||||
|
`ConfigureKestrel` call in code, so endpoints come entirely from the standard
|
||||||
|
`Kestrel` configuration section. On the deployed hosts these values are supplied
|
||||||
|
as NSSM environment variables (`Kestrel__Endpoints__...`), not from
|
||||||
|
`appsettings.json`.
|
||||||
|
|
||||||
|
Two named endpoints are bound:
|
||||||
|
|
||||||
|
| Endpoint name | Purpose | Protocol requirement |
|
||||||
|
|---|---|---|
|
||||||
|
| `Http` | Public gRPC API (sessions, invoke, events, Galaxy browse) | HTTP/2 |
|
||||||
|
| `Dashboard` | Blazor dashboard and SignalR hubs | HTTP/1.1 (HTTP/2 optional) |
|
||||||
|
|
||||||
|
Both endpoints share one routing pipeline; the names only select which TCP port
|
||||||
|
serves which traffic. The gRPC endpoint must negotiate **HTTP/2**, which drives
|
||||||
|
the protocol settings below.
|
||||||
|
|
||||||
|
### Plaintext (current deployments)
|
||||||
|
|
||||||
|
Both running hosts (`10.100.0.48` and `wonder-app-vd03`) serve the gRPC port in
|
||||||
|
**cleartext HTTP/2 (`h2c`)**. Because cleartext HTTP/2 has no ALPN to negotiate
|
||||||
|
the protocol, the gRPC endpoint must be pinned to `Http2` with prior knowledge:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Kestrel__Endpoints__Http__Url=http://0.0.0.0:5120
|
||||||
|
Kestrel__Endpoints__Http__Protocols=Http2
|
||||||
|
Kestrel__Endpoints__Dashboard__Url=http://0.0.0.0:5130
|
||||||
|
```
|
||||||
|
|
||||||
|
In this mode all client↔gateway traffic — including the
|
||||||
|
`authorization: Bearer mxgw_...` API key and any `WriteSecured` / `AuthenticateUser`
|
||||||
|
payloads — crosses the network **unencrypted**. This is acceptable only on a
|
||||||
|
trusted/isolated network segment. Prefer TLS for anything else.
|
||||||
|
|
||||||
|
### TLS
|
||||||
|
|
||||||
|
To encrypt the gRPC channel, give the `Http` endpoint an `https://` URL and a
|
||||||
|
certificate. Over TLS, ALPN negotiates HTTP/2, so the explicit `Protocols=Http2`
|
||||||
|
pin is no longer required (the default `Http1AndHttp2` works for gRPC over TLS).
|
||||||
|
|
||||||
|
`appsettings.json` form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Kestrel": {
|
||||||
|
"Endpoints": {
|
||||||
|
"Http": {
|
||||||
|
"Url": "https://0.0.0.0:5120",
|
||||||
|
"Certificate": {
|
||||||
|
"Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx",
|
||||||
|
"Password": "<pfx-password>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Dashboard": {
|
||||||
|
"Url": "https://0.0.0.0:5130",
|
||||||
|
"Certificate": {
|
||||||
|
"Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx",
|
||||||
|
"Password": "<pfx-password>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent NSSM environment-variable form (how config is delivered on the hosts —
|
||||||
|
see [server deploy mechanics in the project notes]):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Kestrel__Endpoints__Http__Url=https://0.0.0.0:5120
|
||||||
|
Kestrel__Endpoints__Http__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx
|
||||||
|
Kestrel__Endpoints__Http__Certificate__Password=<pfx-password>
|
||||||
|
Kestrel__Endpoints__Dashboard__Url=https://0.0.0.0:5130
|
||||||
|
Kestrel__Endpoints__Dashboard__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx
|
||||||
|
Kestrel__Endpoints__Dashboard__Certificate__Password=<pfx-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Certificate sourcing options (any standard ASP.NET Core form is accepted):
|
||||||
|
|
||||||
|
| Form | Keys |
|
||||||
|
|---|---|
|
||||||
|
| PFX file | `Certificate:Path` (+ `Certificate:Password` if encrypted) |
|
||||||
|
| PEM pair | `Certificate:Path` (cert) + `Certificate:KeyPath` (private key) |
|
||||||
|
| Windows cert store | `Certificate:Subject`, `Certificate:Store` (e.g. `My`), `Certificate:Location` (`LocalMachine`), `Certificate:AllowInvalid` |
|
||||||
|
|
||||||
|
The certificate's CN/SAN must cover the host name clients dial (or clients must
|
||||||
|
set a server-name override — see below). The dashboard endpoint can keep its own
|
||||||
|
certificate independent of the gRPC endpoint; pair this with
|
||||||
|
`MxGateway:Dashboard:RequireHttpsCookie` (`true`) for production HTTPS.
|
||||||
|
|
||||||
|
### Automatic self-signed certificate
|
||||||
|
|
||||||
|
`mxaccessgw` is an internal tool with no PKI to issue certificates, so requiring
|
||||||
|
an operator to supply one before TLS works pushed deployments toward plaintext.
|
||||||
|
To avoid that, the gateway fills in a self-signed certificate when an HTTPS
|
||||||
|
endpoint is configured without one.
|
||||||
|
|
||||||
|
**Trigger.** At startup the gateway inspects `Kestrel:Endpoints:*`. If any
|
||||||
|
endpoint has an `https://` URL and no `Certificate` subsection of its own, and no
|
||||||
|
`Kestrel:Certificates:Default` is set, the gateway generates (or loads) a
|
||||||
|
persisted self-signed certificate and wires it in as the HTTPS *default* via
|
||||||
|
`ConfigureHttpsDefaults`. All-plaintext deployments are untouched: when no HTTPS
|
||||||
|
endpoint is configured, no certificate or key material is generated or written.
|
||||||
|
|
||||||
|
**Generated certificate.** ECDSA P-256, `serverAuth` EKU, validity ≈
|
||||||
|
`ValidityYears` (default 10 years, with one day of clock-skew slack before
|
||||||
|
`notBefore`). SANs cover `localhost`, the machine name (and its FQDN when
|
||||||
|
resolvable), each entry in `AdditionalDnsNames`, and the loopback addresses
|
||||||
|
`127.0.0.1` and `::1`.
|
||||||
|
|
||||||
|
**`MxGateway:Tls:*` options.** All optional; the zero-config path needs none of
|
||||||
|
them.
|
||||||
|
|
||||||
|
| Option | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated certificate is persisted |
|
||||||
|
| `Tls:ValidityYears` | `10` | Lifetime of the generated certificate (validated 1–100) |
|
||||||
|
| `Tls:AdditionalDnsNames` | `[]` | Extra DNS SANs (e.g. a load-balancer name) |
|
||||||
|
| `Tls:RegenerateIfExpired` | `true` | Replace an expired persisted certificate instead of failing |
|
||||||
|
|
||||||
|
`ValidityYears` is validated by `GatewayOptionsValidator` (range 1–100); the
|
||||||
|
"HTTPS endpoint configured but no certificate available" fail-fast lives in the
|
||||||
|
bootstrap/provider, because the validator only sees the `MxGateway` section, not
|
||||||
|
`Kestrel:Endpoints`.
|
||||||
|
|
||||||
|
**Persistence.** The PFX is written with an **empty** export password — a random
|
||||||
|
in-memory password could not be reused across restarts, which the
|
||||||
|
persist-and-reuse model requires. The private key is instead protected at rest by
|
||||||
|
filesystem permissions: a restrictive ACL on Windows (SYSTEM + Administrators,
|
||||||
|
inherited ACEs stripped) on the `certs` directory and file, and mode `0600` on
|
||||||
|
non-Windows. The write is atomic (hardened temp file, then move). The persisted
|
||||||
|
certificate is reused across restarts (stable thumbprint, so CA-pinning clients
|
||||||
|
keep working) and regenerated only when it is missing, expired (and
|
||||||
|
`RegenerateIfExpired` is `true`), or unreadable/corrupt. If the directory is not
|
||||||
|
writable or the ACL cannot be applied, the gateway fails fast with a diagnostic
|
||||||
|
naming the path rather than falling back to an in-memory certificate.
|
||||||
|
|
||||||
|
**Logging.** On generate or load, the gateway logs the certificate thumbprint,
|
||||||
|
SAN list, and `notAfter` at Information. The PFX bytes, export password, and
|
||||||
|
private key are never logged.
|
||||||
|
|
||||||
|
**Operator override.** The generated certificate is only the HTTPS *default*. To
|
||||||
|
use a real certificate, configure one explicitly — either per endpoint via
|
||||||
|
`Kestrel:Endpoints:<name>:Certificate` (`Path`/`Subject`/`Thumbprint`, etc., as
|
||||||
|
in the table above) or globally via `Kestrel:Certificates:Default`. An
|
||||||
|
explicitly-configured certificate takes precedence, and the gateway then writes
|
||||||
|
no self-signed material.
|
||||||
|
|
||||||
|
### Client side
|
||||||
|
|
||||||
|
Each official client opts into TLS explicitly. For the .NET client
|
||||||
|
(`MxGatewayClientOptions`):
|
||||||
|
|
||||||
|
| Option | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `UseTls` (default `false`) | Enables TLS. Requires an `https://` endpoint; an `https://` endpoint without `UseTls` fails validation, and vice versa. |
|
||||||
|
| `CaCertificatePath` | Pins a custom root (self-signed / private CA) using `CustomRootTrust` chain validation instead of the OS trust store; the .NET client also enforces the certificate hostname/SAN match on this path. |
|
||||||
|
| `RequireCertificateValidation` (default `false`) | Forces OS/system-trust verification on a TLS connection with no pinned CA. Leave `false` for the lenient default. |
|
||||||
|
| `ServerNameOverride` | SNI / certificate host name override when the dialed host differs from the certificate CN/SAN. |
|
||||||
|
|
||||||
|
To pair with the auto-generated self-signed certificate above, the clients are
|
||||||
|
**lenient by default**: a TLS connection with no pinned CA accepts whatever
|
||||||
|
certificate the gateway presents. Pin `CaCertificatePath` to verify, or set
|
||||||
|
`RequireCertificateValidation` to force system-trust verification without
|
||||||
|
pinning. The other language clients expose the equivalent options; the exact
|
||||||
|
behavior differs per stack — Python uses trust-on-first-use and Rust is pin-only.
|
||||||
|
See each client README for the as-built behavior.
|
||||||
|
|
||||||
|
### Gateway↔worker IPC
|
||||||
|
|
||||||
|
Transport security here applies only to the public gRPC channel. The
|
||||||
|
gateway↔worker link is a per-session **named pipe**
|
||||||
|
(`mxaccess-gateway-{gatewayPid}-{sessionId}`), not a network socket. It is not
|
||||||
|
TLS-encrypted and does not need to be: it never leaves the local Windows host and
|
||||||
|
is secured by the OS pipe ACL. See [Worker Frame Protocol](./WorkerFrameProtocol.md).
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
||||||
|
|||||||
@@ -94,6 +94,73 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
|
|||||||
|
|
||||||
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
|
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
|
||||||
|
|
||||||
|
#### Provider status on the alarm feed
|
||||||
|
|
||||||
|
`AlarmFeedMessage` has a fourth `payload` case, `provider_status`, carrying
|
||||||
|
an `AlarmProviderStatus` message:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message AlarmProviderStatus {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
bool degraded = 2; // true whenever mode == SUBTAG
|
||||||
|
string reason = 3; // human-readable switch reason
|
||||||
|
google.protobuf.Timestamp since = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway emits `provider_status` once when a client first subscribes
|
||||||
|
(immediately after the initial snapshot and before the first live transition)
|
||||||
|
and again on every failover or failback. A late-joining client therefore
|
||||||
|
always learns the current provider mode without waiting for the next switch.
|
||||||
|
|
||||||
|
`AlarmProviderMode` is an enum with three values:
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `ALARM_PROVIDER_MODE_UNSPECIFIED` (0) | Default / unset |
|
||||||
|
| `ALARM_PROVIDER_MODE_ALARMMGR` (1) | Native wnwrap alarm-manager source |
|
||||||
|
| `ALARM_PROVIDER_MODE_SUBTAG` (2) | Subtag-monitoring fallback (degraded) |
|
||||||
|
|
||||||
|
#### Degraded and source-provider fields on transitions and snapshots
|
||||||
|
|
||||||
|
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` both carry two new fields:
|
||||||
|
|
||||||
|
- `bool degraded` (field 14) — `true` when the record came from the subtag
|
||||||
|
fallback, not the native alarmmgr.
|
||||||
|
- `AlarmProviderMode source_provider` (field 15) — which provider produced
|
||||||
|
this record (`ALARMMGR` or `SUBTAG`).
|
||||||
|
|
||||||
|
Both fields are proto3 defaults (`false` / `UNSPECIFIED`) in alarmmgr mode,
|
||||||
|
so existing clients that do not read them continue to function without change.
|
||||||
|
Clients that care about provenance — for example, an OPC UA server that
|
||||||
|
applies different quality flags to degraded alarms — should inspect `degraded`
|
||||||
|
before consuming the transition.
|
||||||
|
|
||||||
|
Subtag-mode records are a non-parity source. They carry synthetic GUIDs,
|
||||||
|
best-effort timestamps, and reduced field coverage. See
|
||||||
|
`docs/AlarmClientDiscovery.md` for the full fidelity table.
|
||||||
|
|
||||||
|
#### Provider-mode-changed event
|
||||||
|
|
||||||
|
The worker emits `OnAlarmProviderModeChangedEvent` (family
|
||||||
|
`MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED`) on each switch between
|
||||||
|
providers:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message OnAlarmProviderModeChangedEvent {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
string reason = 2;
|
||||||
|
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
|
||||||
|
google.protobuf.Timestamp at = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This event arrives on the `StreamEvents` stream of the alarm monitor's
|
||||||
|
internal gateway session (not on client sessions). `GatewayAlarmMonitor`
|
||||||
|
consumes it and reflects the new mode into the `StreamAlarms` feed's
|
||||||
|
`provider_status`, the dashboard hub, and metrics. Client sessions do not
|
||||||
|
receive this event directly.
|
||||||
|
|
||||||
## Validation Rules
|
## Validation Rules
|
||||||
|
|
||||||
`MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free.
|
`MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free.
|
||||||
@@ -243,9 +310,27 @@ services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInt
|
|||||||
|
|
||||||
Because the interceptor runs before any handler, `MxAccessGatewayService` can safely assume the call has been authorized and that `IGatewayRequestIdentityAccessor.Current` is populated. The handler's only responsibility is to read the identity for `OpenSession` so the session is owned by the authenticated principal; it does not perform any authorization checks of its own. See [Authorization](./Authorization.md) for the policy and identity model.
|
Because the interceptor runs before any handler, `MxAccessGatewayService` can safely assume the call has been authorized and that `IGatewayRequestIdentityAccessor.Current` is populated. The handler's only responsibility is to read the identity for `OpenSession` so the session is owned by the authenticated principal; it does not perform any authorization checks of its own. See [Authorization](./Authorization.md) for the policy and identity model.
|
||||||
|
|
||||||
|
## Transport Security
|
||||||
|
|
||||||
|
The gRPC endpoint runs over HTTP/2, in cleartext (`h2c`) or TLS depending on the
|
||||||
|
Kestrel endpoint configuration. The current deployments serve it in cleartext, so
|
||||||
|
the API key and request payloads cross the network unencrypted. The endpoint,
|
||||||
|
protocol pinning, and TLS certificate configuration — plus the corresponding
|
||||||
|
client `UseTls` / `CaCertificatePath` options — are documented in
|
||||||
|
[Host Endpoints and Transport Security](./GatewayConfiguration.md#host-endpoints-and-transport-security-kestrel).
|
||||||
|
|
||||||
|
To make TLS usable without PKI, the gateway can auto-generate and persist a
|
||||||
|
self-signed certificate when an HTTPS endpoint is configured without one, and the
|
||||||
|
language clients are lenient by default — a TLS connection with no pinned CA
|
||||||
|
accepts the presented certificate (with per-stack nuances: Python is
|
||||||
|
trust-on-first-use, Rust is pin-only). See
|
||||||
|
[Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
||||||
|
and each client README for the as-built behavior.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Contracts](./Contracts.md)
|
- [Contracts](./Contracts.md)
|
||||||
- [Sessions](./Sessions.md)
|
- [Sessions](./Sessions.md)
|
||||||
- [Authorization](./Authorization.md)
|
- [Authorization](./Authorization.md)
|
||||||
|
- [Gateway Configuration](./GatewayConfiguration.md)
|
||||||
- [Gateway Process Design](./GatewayProcessDesign.md)
|
- [Gateway Process Design](./GatewayProcessDesign.md)
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@ The metrics subsystem exposes counters, histograms, and observable gauges that d
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
||||||
|
|
||||||
## Meter and OpenTelemetry Compatibility
|
## Meter and OpenTelemetry Compatibility
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ The meter name is exposed as a constant so that hosting code can register it wit
|
|||||||
```csharp
|
```csharp
|
||||||
public sealed class GatewayMetrics : IDisposable
|
public sealed class GatewayMetrics : IDisposable
|
||||||
{
|
{
|
||||||
public const string MeterName = "ZB.MOM.WW.MxGateway.Server";
|
public const string MeterName = "ZB.MOM.WW.MxGateway";
|
||||||
|
|
||||||
public GatewayMetrics()
|
public GatewayMetrics()
|
||||||
{
|
{
|
||||||
@@ -50,12 +50,12 @@ All counters are `Counter<long>`. Tag values come from the call sites listed und
|
|||||||
|
|
||||||
### Histograms
|
### Histograms
|
||||||
|
|
||||||
Histograms record durations in milliseconds (the `unit` argument on `CreateHistogram`):
|
Histograms record durations in seconds (the `unit` argument on `CreateHistogram`):
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms");
|
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms");
|
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms");
|
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||||
```
|
```
|
||||||
|
|
||||||
| Instrument | Tags | What it measures |
|
| Instrument | Tags | What it measures |
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Gateway TLS Auto-Certificate and Lenient Client Trust — Design
|
||||||
|
|
||||||
|
Date: 2026-06-01
|
||||||
|
Status: Approved (brainstorming), pending implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The gateway can serve gRPC and the dashboard over TLS, but only if an operator
|
||||||
|
supplies a certificate via the Kestrel `https://` endpoint config. With no cert,
|
||||||
|
an `https` endpoint fails at startup with Kestrel's opaque "No server certificate
|
||||||
|
was specified" error. Both current deployments therefore run plaintext (`h2c`),
|
||||||
|
exposing the API key and request payloads on the wire.
|
||||||
|
|
||||||
|
`mxaccessgw` is an internal tool. The goal is for TLS to "just work" with zero PKI
|
||||||
|
management: the gateway fabricates its own long-lived certificate when an HTTPS
|
||||||
|
endpoint is configured without one, and clients accept whatever certificate is
|
||||||
|
presented unless an operator explicitly opts into pinning.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Gateway = fill-missing-cert-only.** No new "enable TLS" switch. TLS is still
|
||||||
|
driven by configuring a Kestrel `https://` endpoint. New behavior: when an
|
||||||
|
HTTPS endpoint has no `Certificate` section, the gateway generates/loads a
|
||||||
|
persisted self-signed cert instead of failing. Plaintext-only hosts are
|
||||||
|
untouched — no certificate or key material is ever written for them.
|
||||||
|
2. **Persist & reuse.** The self-signed cert is saved as a PFX under
|
||||||
|
`C:\ProgramData\MxGateway\certs`, reused across restarts, regenerated only if
|
||||||
|
missing, expired, or unreadable. Stable thumbprint; survives restarts; any
|
||||||
|
CA-pinning client keeps working.
|
||||||
|
3. **Clients = lenient TLS, plaintext default.** When a client connects over TLS
|
||||||
|
without a pinned CA, it skips verification (accepts any cert). Pinning a CA file
|
||||||
|
restores full verification. The per-client connection default (mostly
|
||||||
|
plaintext/`http`) does not change — TLS is still opt-in via the endpoint scheme.
|
||||||
|
|
||||||
|
**Scope boundary:** the gateway↔worker named-pipe IPC is unchanged (local,
|
||||||
|
OS-secured by the pipe ACL). This work touches only the public gRPC/dashboard
|
||||||
|
transport and the five language clients.
|
||||||
|
|
||||||
|
## Gateway component
|
||||||
|
|
||||||
|
New type `SelfSignedCertificateProvider` in
|
||||||
|
`src/ZB.MOM.WW.MxGateway.Server/Security/Tls/`.
|
||||||
|
|
||||||
|
1. **Detect need.** Inspect `Kestrel:Endpoints:*` configuration at startup. If any
|
||||||
|
endpoint has an `https://` URL and no `Certificate` subsection, a default cert
|
||||||
|
is needed. If none do, the provider is a no-op (no file written).
|
||||||
|
2. **Load-or-create.** Look for the persisted PFX. If present, valid, and
|
||||||
|
unexpired, load it. Otherwise generate and persist.
|
||||||
|
3. **Generate.** `CertificateRequest` with **ECDSA P-256**, `notBefore = now - 1
|
||||||
|
day` (clock-skew slack), `notAfter = now + ValidityYears`. SANs: `DNS=localhost`,
|
||||||
|
`DNS=<MachineName>`, `DNS=<MachineName.FQDN>` when resolvable, plus
|
||||||
|
`IP=127.0.0.1` and `IP=::1`. Server-auth EKU.
|
||||||
|
4. **Persist securely.** Write the PFX with an **empty** export password (a random
|
||||||
|
in-memory password cannot be reused across restarts, which the persist-and-reuse
|
||||||
|
decision requires); protect the private key with a restrictive ACL (SYSTEM +
|
||||||
|
Administrators + service account) on the `certs` directory and file on Windows,
|
||||||
|
and `0600` on non-Windows; atomic write (temp + rename). After generating, the
|
||||||
|
cert is reloaded from the persisted PFX so Kestrel always serves the on-disk key.
|
||||||
|
5. **Wire into Kestrel.** In `GatewayApplication.CreateBuilder`, add
|
||||||
|
`builder.WebHost.ConfigureKestrel(o => o.ConfigureHttpsDefaults(h =>
|
||||||
|
h.ServerCertificate = cert))`. `ConfigureHttpsDefaults` supplies the cert only
|
||||||
|
for HTTPS endpoints that did not specify their own, so an operator-configured
|
||||||
|
`Kestrel:Endpoints:*:Certificate` transparently overrides it. One hook covers
|
||||||
|
both the gRPC and dashboard ports.
|
||||||
|
|
||||||
|
### New config block `MxGateway:Tls`
|
||||||
|
|
||||||
|
All optional; the zero-config path needs none of them.
|
||||||
|
|
||||||
|
| Option | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated cert lives |
|
||||||
|
| `Tls:ValidityYears` | `10` | Lifetime of the generated cert |
|
||||||
|
| `Tls:AdditionalDnsNames` | `[]` | Extra SANs (e.g. a load-balancer name) |
|
||||||
|
| `Tls:RegenerateIfExpired` | `true` | Auto-replace an expired persisted cert |
|
||||||
|
|
||||||
|
Validated by `GatewayOptionsValidator`: `ValidityYears` in 1–100,
|
||||||
|
`SelfSignedCertPath` is a valid path shape when non-blank, and
|
||||||
|
`AdditionalDnsNames` entries are non-blank. (The "https endpoint exists but cert
|
||||||
|
path is blank" fail-fast lives in the bootstrap/provider, not the validator,
|
||||||
|
because the validator only sees the `MxGateway` section, not `Kestrel:Endpoints`.)
|
||||||
|
|
||||||
|
**Logging:** on generate/load, log thumbprint + SAN list + `notAfter` at
|
||||||
|
Information. Never log the PFX password or private key.
|
||||||
|
|
||||||
|
## Client lenient-TLS behavior
|
||||||
|
|
||||||
|
Uniform rule: **TLS on + no CA pinned ⇒ skip verification; CA pinned ⇒ full
|
||||||
|
verification.** No transport default changes. Each client also exposes an explicit
|
||||||
|
switch to force-disable leniency (strict-without-pinning) for the future.
|
||||||
|
|
||||||
|
| Client | Mechanism | Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| .NET | In `CreateHttpHandler`, when `UseTls` and `CaCertificatePath` empty, set `SslOptions.RemoteCertificateValidationCallback = (_,_,_,_) => true`. CA path keeps existing custom-root validation. | trivial |
|
||||||
|
| Go | In `buildCredentials`, when TLS and no `CACertFile`/`TLSConfig`, use `tls.Config{InsecureSkipVerify: true, ServerName: override}`. | trivial |
|
||||||
|
| Java | grpc-netty-shaded 1.76.0 ships `InsecureTrustManagerFactory`. When TLS and no CA, build `GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)`. | easy |
|
||||||
|
| Python | grpc-python has no per-channel skip-verify. Fetch the server leaf cert at connect via `ssl.get_server_certificate((host, port))`, pass it as `root_certificates` to `ssl_channel_credentials`, plus `grpc.ssl_target_name_override`. Effectively trusts what is presented (TOFU). | moderate, special-cased |
|
||||||
|
| Rust | tonic 0.13.1 + rustls (`tls-ring`). Implement a custom `rustls::client::danger::ServerCertVerifier` that accepts everything, build a `rustls::ClientConfig` via `.dangerous().with_custom_certificate_verifier(...)`, feed it to the channel. May require a custom hyper-rustls connector if `ClientTlsConfig` will not take a raw rustls config. **Needs an API spike.** | highest |
|
||||||
|
|
||||||
|
### Honesty caveats
|
||||||
|
|
||||||
|
- **Python** is not literally "ignore the cert"; it pins whatever the server
|
||||||
|
presents on first contact via a separate unverified TLS probe. For a self-signed
|
||||||
|
internal cert this is the intended outcome. Documented as a difference.
|
||||||
|
- **Rust** leniency depends on the tonic 0.13 TLS surface. If a custom verifier is
|
||||||
|
disproportionately invasive, the fallback is to require a CA file for Rust TLS
|
||||||
|
(pin-only) and document Rust as the exception.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Gateway:
|
||||||
|
- Cert dir not writable / ACL fails ⇒ fail fast at startup with a diagnostic naming
|
||||||
|
the path and required permission. No silent in-memory fallback.
|
||||||
|
- Persisted PFX corrupt/unreadable ⇒ warn, regenerate, overwrite.
|
||||||
|
- Persisted cert expired ⇒ regenerate if `RegenerateIfExpired` (default), else fail
|
||||||
|
fast instructing the operator to delete it or enable regeneration.
|
||||||
|
- HTTPS endpoint configured but generation disabled / path empty ⇒ validator
|
||||||
|
rejects at startup rather than letting Kestrel throw its opaque error.
|
||||||
|
|
||||||
|
Clients: surface unchanged. Skip-verify cannot itself raise. Python's pre-fetch
|
||||||
|
wraps connect failure into the existing connect-error type with the endpoint in the
|
||||||
|
message. Rust pin-only fallback surfaces the existing CA-file error.
|
||||||
|
|
||||||
|
## Documentation (same commit as source, per CLAUDE.md)
|
||||||
|
|
||||||
|
- `docs/GatewayConfiguration.md` — extend the TLS section: auto-generation, the
|
||||||
|
`MxGateway:Tls:*` block, persistence location/ACL, thumbprint logging, operator
|
||||||
|
override via `Kestrel:Endpoints:*:Certificate`.
|
||||||
|
- Each client README + `*ClientDesign.md` — "TLS is lenient by default; pin a CA to
|
||||||
|
verify," with Python TOFU and any Rust caveat noted.
|
||||||
|
- `docs/DesignDecisions.md` — record both posture choices and the why (internal
|
||||||
|
tool, no PKI) so they are not mistaken for an oversight.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Gateway (`MxGateway.Tests`, no MXAccess):
|
||||||
|
- `SelfSignedCertificateProvider`: SANs, server-auth EKU, `notAfter ≈ now +
|
||||||
|
ValidityYears`, ECDSA P-256.
|
||||||
|
- Load-or-create: valid persisted PFX reused (same thumbprint); expired regenerates
|
||||||
|
when enabled; corrupt regenerates with a warning.
|
||||||
|
- Detection: HTTPS-without-cert engages; all-plaintext no-ops and writes no file;
|
||||||
|
endpoint with its own cert is not overridden.
|
||||||
|
- `GatewayOptionsValidator`: new `Tls:*` rules.
|
||||||
|
- Host integration: `Kestrel:Endpoints:Http:Url=https://127.0.0.1:0` builds and
|
||||||
|
binds (today it throws "no certificate specified").
|
||||||
|
|
||||||
|
Clients: each test project gets a lenient-TLS test against a throwaway self-signed
|
||||||
|
cert — connect with no CA succeeds; pinning a wrong CA fails (proves pinning still
|
||||||
|
verifies). Python exercises the pre-fetch path; mark opt-in if loopback timing is
|
||||||
|
flaky. Standard (non-live) tests; no MXAccess or external services.
|
||||||
|
|
||||||
|
Cross-language: add a TLS variant note to `docs/CrossLanguageSmokeMatrix.md`;
|
||||||
|
running the matrix over TLS stays manual/opt-in, consistent with the existing gate.
|
||||||
|
|
||||||
|
Per-component verification follows CLAUDE.md's source-update table (build + test
|
||||||
|
each touched component independently).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-01-gateway-cert-autogen-implementation.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 1, "subject": "Task 1: Add TlsOptions config + bind into GatewayOptions", "status": "pending"},
|
||||||
|
{"id": 2, "subject": "Task 2: Validate MxGateway:Tls in GatewayOptionsValidator", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 3, "subject": "Task 3: SelfSignedCertificateProvider.GenerateCertificate", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 4, "subject": "Task 4: SelfSignedCertificateProvider.LoadOrCreate (persist/reuse/regenerate/ACL)", "status": "pending", "blockedBy": [3]},
|
||||||
|
{"id": 5, "subject": "Task 5: KestrelTlsInspector (detect HTTPS-without-cert)", "status": "pending"},
|
||||||
|
{"id": 6, "subject": "Task 6: Wire auto-cert into GatewayApplication.CreateBuilder", "status": "pending", "blockedBy": [1, 4, 5]},
|
||||||
|
{"id": 7, "subject": "Task 7: .NET client lenient TLS by default", "status": "pending"},
|
||||||
|
{"id": 8, "subject": "Task 8: Go client lenient TLS by default", "status": "pending"},
|
||||||
|
{"id": 9, "subject": "Task 9: Java client lenient TLS by default", "status": "pending"},
|
||||||
|
{"id": 10, "subject": "Task 10: Python client lenient TLS via TOFU pre-fetch", "status": "pending"},
|
||||||
|
{"id": 11, "subject": "Task 11: Rust client lenient TLS via rustls verifier (spike + fallback)", "status": "pending"},
|
||||||
|
{"id": 12, "subject": "Task 12: Documentation", "status": "pending", "blockedBy": [6, 7, 8, 9, 10, 11]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-01"
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
# Alarm Subtag-Monitoring Fallback — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Superseded by implementation (merged to `main`). This is the original
|
||||||
|
brainstorming design; a few details below were refined during implementation —
|
||||||
|
see the inline **Superseded** notes. The shipped behaviour is documented in
|
||||||
|
`docs/AlarmClientDiscovery.md`, the client READMEs, and the contracts.
|
||||||
|
**Branch:** `feat/alarm-subtag-fallback`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The gateway's central alarm feed (`GatewayAlarmMonitor` → worker
|
||||||
|
`WnWrapAlarmConsumer`) depends on the AVEVA wnwrap COM consumer
|
||||||
|
(`WNWRAPCONSUMERLib.wwAlarmConsumerClass`), which polls `GetXmlCurrentAlarms2`
|
||||||
|
on the worker STA. That provider can fail at the COM boundary (the older
|
||||||
|
`aaAlarmManagedClient` crashed on FILETIME marshaling; wnwrap can still return
|
||||||
|
failure HRESULTs or throw `COMException`). When it does, the gateway loses all
|
||||||
|
alarm visibility.
|
||||||
|
|
||||||
|
This design adds a **second alarm source** — direct monitoring of each alarm
|
||||||
|
attribute's subtags (`.active`, `.acked`, …) via the existing MXAccess
|
||||||
|
`AddItem`/`Advise` pipeline — and **fails over to it automatically when the
|
||||||
|
wnwrap provider breaks, then fails back automatically when it recovers**. The
|
||||||
|
subtag source can also be forced on by config.
|
||||||
|
|
||||||
|
## Decisions (locked during brainstorming)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Failover model | **Auto-failover + auto-failback** (both directions, runtime) |
|
||||||
|
| Watch-list source | **Galaxy Repository SQL discovery + config override** |
|
||||||
|
| Acknowledge in subtag mode | **Write the operator comment to the alarm's ack-comment subtag** (the write performs the ack) |
|
||||||
|
| Failure signal | **N consecutive wnwrap COM failures** (Subscribe / `GetXmlCurrentAlarms2` throws or returns a failure HRESULT) |
|
||||||
|
| Degraded-state visibility | **Both** — explicit field in the gRPC contract **and** dashboard + metrics |
|
||||||
|
| Synthesis location | **Worker-side** (`Approach A`) — keeps the parity rule "the gateway forwards only events the worker emits; it never synthesizes events" |
|
||||||
|
|
||||||
|
## Core principle
|
||||||
|
|
||||||
|
Subtag monitoring is, by definition, a **non-parity, lower-fidelity** alarm
|
||||||
|
source: it synthesizes alarm transitions from raw data changes, has no native
|
||||||
|
alarm GUID, no native original-raise timestamp, and a narrower field set. Per
|
||||||
|
`CLAUDE.md`, synthesizing events is allowed only as an explicit opt-in
|
||||||
|
non-parity mode. This design satisfies that by (a) doing the synthesis **inside
|
||||||
|
the worker** (so the gateway still only forwards worker-emitted events) and
|
||||||
|
(b) marking every degraded event and the whole feed as degraded so no client
|
||||||
|
mistakes it for the authoritative alarmmgr feed.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
GATEWAY (.NET 10, x64)
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ GatewayAlarmMonitor (BackgroundService) │
|
||||||
|
│ • resolves watch-list: Galaxy Repository SQL + config override │
|
||||||
|
│ • arms the worker with the watch-list at subscribe time │
|
||||||
|
│ • consumes AlarmProviderModeChanged → reflects mode into feed, │
|
||||||
|
│ /hubs/alarms dashboard hub, and metrics │
|
||||||
|
│ • forces a cache reconcile (QueryActiveAlarms) on every switch │
|
||||||
|
└───────────────────────────────┬───────────────────────────────────┘
|
||||||
|
│ IPC (WorkerEnvelope frames)
|
||||||
|
│ · SubscribeAlarms{ watch_list, failover cfg }
|
||||||
|
│ · AlarmProviderModeChanged{ mode, reason, hresult }
|
||||||
|
│ · OnAlarmTransitionEvent (degraded flag set in subtag mode)
|
||||||
|
▼
|
||||||
|
WORKER (.NET FW 4.8, x86, STA)
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AlarmDispatcher → FailoverAlarmConsumer : IMxAccessAlarmConsumer │
|
||||||
|
│ ├─ primary : WnWrapAlarmConsumer (wnwrap COM poll, unchanged) │
|
||||||
|
│ └─ standby : SubtagAlarmConsumer (AddItem/Advise on subtags) │
|
||||||
|
│ │
|
||||||
|
│ FailoverAlarmConsumer owns the state machine: │
|
||||||
|
│ PrimaryActive ──(N consecutive wnwrap COM failures)──▶ Degraded │
|
||||||
|
│ Degraded ──(M consecutive clean wnwrap probe polls)──▶ Primary │
|
||||||
|
│ on each switch: snapshot the now-active provider, hand off │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The failover state machine lives **worker-local** so the switch is instant — no
|
||||||
|
IPC round-trip at the moment alarmmgr dies. The gateway *arms* the standby
|
||||||
|
consumer up front (passes the watch-list at subscribe time) so it is ready
|
||||||
|
before it is ever needed.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Worker (`src/ZB.MOM.WW.MxGateway.Worker/MxAccess/`)
|
||||||
|
|
||||||
|
**`SubtagAlarmConsumer : IMxAccessAlarmConsumer` (new)** — the standby provider.
|
||||||
|
|
||||||
|
- On `Subscribe`, instead of wnwrap registration it `AddItem`/`Advise`s the
|
||||||
|
configured subtags for each watch-list entry on the existing STA (reuses the
|
||||||
|
worker's item-subscription machinery). Per attribute it advises at minimum
|
||||||
|
`.active` and `.acked`; optionally `.priority`/severity, `.descr`, value/limit
|
||||||
|
if present.
|
||||||
|
- Converts each `OnDataChange` into the same `MxAlarmTransitionEvent` the wnwrap
|
||||||
|
consumer emits, via the synthesis rules below, and raises
|
||||||
|
`AlarmTransitionEmitted`. Marks each as **degraded**.
|
||||||
|
- `SnapshotActiveAlarms()` returns the currently-active set computed from
|
||||||
|
last-known subtag values.
|
||||||
|
- `AcknowledgeByName(...)` resolves the watch-list entry's ack-comment subtag and
|
||||||
|
issues a `Write(comment)` on the STA. `AcknowledgeByGuid(...)` maps the
|
||||||
|
synthetic GUID (see below) back to a reference, then does the same. If the
|
||||||
|
attribute exposes no writable ack-comment subtag, returns a failure code that
|
||||||
|
the gateway surfaces as `FailedPrecondition`.
|
||||||
|
- `PollOnce()` is a no-op (subtag mode is event-driven via Advise).
|
||||||
|
|
||||||
|
**`FailoverAlarmConsumer : IMxAccessAlarmConsumer` (new)** — composite + state
|
||||||
|
machine. Owns the wnwrap consumer (primary) and the subtag consumer (standby),
|
||||||
|
forwards `AlarmTransitionEmitted` from whichever child is active, and raises a
|
||||||
|
new `ProviderModeChanged` event on every switch.
|
||||||
|
|
||||||
|
- **Failure counting:** wraps `Subscribe`/`PollOnce` on the primary; a thrown
|
||||||
|
`COMException` or a failure HRESULT increments a consecutive-failure counter,
|
||||||
|
reset to zero on any clean poll.
|
||||||
|
- **Failover** (`PrimaryActive → Degraded`): at `ConsecutiveFailureThreshold`
|
||||||
|
(default 3), ensures the standby is subscribed (it was armed at startup), sets
|
||||||
|
active = standby, snapshots the standby's active set for hand-off, and emits
|
||||||
|
`ProviderModeChanged(SUBTAG, reason, hresult)`.
|
||||||
|
- **Failback probe** (`Degraded → PrimaryActive`): while degraded, every
|
||||||
|
`FailbackProbeIntervalSeconds` (default 30) it re-attempts wnwrap
|
||||||
|
`Subscribe`+`PollOnce` on the STA. After `FailbackStableProbes` (default 3)
|
||||||
|
consecutive clean polls it switches active = primary, returns the standby to
|
||||||
|
standby, and emits `ProviderModeChanged(ALARMMGR, "recovered")`.
|
||||||
|
- **Hand-off:** on every switch it takes `SnapshotActiveAlarms()` from the
|
||||||
|
now-active provider so the gateway can reconcile and avoid spurious
|
||||||
|
raise/clear storms.
|
||||||
|
|
||||||
|
**`AlarmDispatcher` / `MxAccessAlarmEventSink` / `AlarmCommandHandler`
|
||||||
|
(changed, minimal)** — `AlarmDispatcher` holds a `FailoverAlarmConsumer` instead
|
||||||
|
of a bare `WnWrapAlarmConsumer`; it subscribes to `ProviderModeChanged` and
|
||||||
|
enqueues a mode-changed worker event. The ack path routes by active mode (native
|
||||||
|
wnwrap ack in alarmmgr mode; ack-comment write in subtag mode), but that routing
|
||||||
|
is entirely inside the consumer — the dispatcher just calls
|
||||||
|
`AcknowledgeByName`/`AcknowledgeByGuid`.
|
||||||
|
|
||||||
|
### Gateway (`src/ZB.MOM.WW.MxGateway.Server/`)
|
||||||
|
|
||||||
|
**Galaxy Repository discovery (new query)** — alongside the existing GR SQL
|
||||||
|
browse RPCs, a query "attributes that have alarms configured, with their
|
||||||
|
ack-comment subtag and area", scoped to the configured area. Merged with the
|
||||||
|
config override (explicit includes/excludes). Produces the watch-list of
|
||||||
|
`AlarmSubtagTarget`s.
|
||||||
|
|
||||||
|
**`GatewayAlarmMonitor` (changed)** — resolves the watch-list at subscribe time
|
||||||
|
and passes it to the worker; consumes `AlarmProviderModeChanged` and reflects
|
||||||
|
the current provider mode into (a) the `AlarmFeedMessage` provider-status,
|
||||||
|
(b) the `/hubs/alarms` dashboard hub, and (c) metrics; forces a reconcile
|
||||||
|
(`QueryActiveAlarms`) on every switch. Re-runs discovery on its existing
|
||||||
|
reconcile cadence and pushes an updated watch-list when the model changes.
|
||||||
|
|
||||||
|
**`AlarmsOptions` (extended)** — new `Fallback` sub-section (below).
|
||||||
|
|
||||||
|
### Contract (`src/ZB.MOM.WW.MxGateway.Contracts/Protos/`)
|
||||||
|
|
||||||
|
**`mxaccess_gateway.proto`:**
|
||||||
|
|
||||||
|
- `enum AlarmProviderMode { ALARM_PROVIDER_MODE_UNSPECIFIED = 0; ALARMMGR = 1; SUBTAG = 2; }`
|
||||||
|
- New `AlarmFeedMessage` oneof case `AlarmProviderStatus provider_status`,
|
||||||
|
carrying `{ AlarmProviderMode mode; bool degraded; string reason;
|
||||||
|
google.protobuf.Timestamp since; }`. Emitted on stream open and on every
|
||||||
|
change so a late-joining client immediately learns the mode.
|
||||||
|
- Add `bool degraded` + `AlarmProviderMode source_provider` to
|
||||||
|
`OnAlarmTransitionEvent` **and** `ActiveAlarmSnapshot`, so per-item provenance
|
||||||
|
is visible even mid-stream. All additions are new field numbers — backward
|
||||||
|
compatible; existing clients ignore them and keep seeing alarms.
|
||||||
|
|
||||||
|
**`mxaccess_worker.proto`:**
|
||||||
|
|
||||||
|
> **Superseded:** these additions shipped in `mxaccess_gateway.proto`, not
|
||||||
|
> `mxaccess_worker.proto` — the worker imports the gateway proto and the alarm
|
||||||
|
> commands/events live there (`AlarmSubtagTarget`,
|
||||||
|
> `OnAlarmProviderModeChangedEvent`, the extended subscribe command).
|
||||||
|
|
||||||
|
- Extend the alarm-subscribe command with: `AlarmProviderMode forced_mode`
|
||||||
|
(`UNSPECIFIED` = auto), `int32 consecutive_failure_threshold`,
|
||||||
|
`int32 failback_probe_interval_seconds`, `int32 failback_stable_probes`, and
|
||||||
|
`repeated AlarmSubtagTarget watch_list`, where `AlarmSubtagTarget =
|
||||||
|
{ string alarm_full_reference; string source_object_reference;
|
||||||
|
string active_subtag; string acked_subtag; string ack_comment_subtag;
|
||||||
|
string priority_subtag; }`.
|
||||||
|
- New worker→gateway event `AlarmProviderModeChanged { AlarmProviderMode mode;
|
||||||
|
string reason; int32 hresult; google.protobuf.Timestamp at; }`.
|
||||||
|
|
||||||
|
> Generated code under `Generated/` and `clients/*/generated*/` is rebuilt from
|
||||||
|
> these `.proto` files — never hand-edited. Every generated client touched by
|
||||||
|
> the contract is rebuilt per the source-update workflow.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### Subtag synthesis rules
|
||||||
|
|
||||||
|
`SubtagAlarmConsumer` keeps last-known `(active, acked)` per watch-list entry and
|
||||||
|
emits transitions on change:
|
||||||
|
|
||||||
|
| Subtag change | Emitted transition | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `active` false → true | `RAISE` (state `UNACK_ALM`) | `original_raise_timestamp` = first-observed active time |
|
||||||
|
| `acked` false → true while `active` | `ACKNOWLEDGE` | `operator_user`/`operator_comment` from ack-comment subtag if advised |
|
||||||
|
| `active` true → false | `CLEAR` | maps to `AckRtn` if acked at clear, else `UnackRtn` |
|
||||||
|
| `active` stays true, re-alarm | `RETRIGGER` | **only** if a re-alarm counter subtag exists; otherwise not synthesized (documented limitation) |
|
||||||
|
|
||||||
|
Snapshot state mapping for `ActiveAlarmSnapshot.current_state`:
|
||||||
|
`active && !acked → ACTIVE`, `active && acked → ACTIVE_ACKED`,
|
||||||
|
`!active → INACTIVE`.
|
||||||
|
|
||||||
|
Field degradation in subtag mode:
|
||||||
|
- `alarm_full_reference` — from the watch-list entry (stable, drives ack-by-ref).
|
||||||
|
- Synthetic, deterministic GUID derived by hashing `alarm_full_reference` so
|
||||||
|
GUID-based ack still resolves; flagged `degraded = true`.
|
||||||
|
- `severity` — from the priority subtag if advised, else 0.
|
||||||
|
- `original_raise_timestamp` — first-observed active time (best effort).
|
||||||
|
- `transition_timestamp` — the `OnDataChange` timestamp.
|
||||||
|
- `category`/`description`/`current_value`/`limit_value` — populated only if the
|
||||||
|
corresponding subtag is advised; otherwise empty.
|
||||||
|
|
||||||
|
### Acknowledge
|
||||||
|
|
||||||
|
`AcknowledgeAlarm`/`AcknowledgeAlarmByName` are unchanged at the RPC surface.
|
||||||
|
`AlarmDispatcher` routes by active provider mode:
|
||||||
|
- **alarmmgr mode:** native wnwrap `AlarmAckByName`/`AlarmAckByGUID` (unchanged).
|
||||||
|
- **subtag mode:** resolve the target's `ack_comment_subtag`, `Write` the
|
||||||
|
operator comment via the existing worker write path on the STA. No writable
|
||||||
|
ack-comment subtag → `FailedPrecondition`.
|
||||||
|
|
||||||
|
### Provider-mode reflection
|
||||||
|
|
||||||
|
Worker `AlarmProviderModeChanged` → `GatewayAlarmMonitor` → (a) emit/refresh
|
||||||
|
`AlarmFeedMessage.provider_status` to every `StreamAlarms` subscriber, (b) push
|
||||||
|
to `/hubs/alarms`, (c) update metrics, (d) force a reconcile.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- **Both providers down** (subtag advise also failing): the monitor stays
|
||||||
|
faulted and keeps retrying both; acknowledge returns `Unavailable`. No silent
|
||||||
|
data loss — the feed reports degraded with reason.
|
||||||
|
- **Empty watch-list in subtag mode** (GR SQL unavailable, no config override):
|
||||||
|
log + metric `alarm_fallback_watchlist_empty`; the feed reports degraded +
|
||||||
|
empty; the gateway keeps re-running discovery on its reconcile cadence and
|
||||||
|
pushes an updated watch-list when one becomes available.
|
||||||
|
- **Switch hand-off:** every switch snapshots the now-active provider and
|
||||||
|
reconciles against the gateway cache to avoid a raise/clear storm.
|
||||||
|
- **STA affinity:** all subtag advise/write and wnwrap probe calls run on the
|
||||||
|
worker STA (reuse the existing affinity guard) to satisfy
|
||||||
|
`ThreadingModel=Apartment`.
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
- `mxgateway_alarm_provider_mode` (gauge: 1 = alarmmgr, 2 = subtag)
|
||||||
|
- `mxgateway_alarm_provider_switch_total{from,to,reason}` (counter)
|
||||||
|
- `mxgateway_alarm_fallback_watchlist_size` (gauge)
|
||||||
|
|
||||||
|
> **Superseded:** the shipped meter names are `mxgateway.alarms.provider_mode`
|
||||||
|
> (gauge) and `mxgateway.alarms.provider_switches{from,to,reason}` (counter,
|
||||||
|
> `reason` bounded to `failover`/`failback`/`unknown`). The watch-list-size /
|
||||||
|
> watch-list-empty gauges were not implemented; an empty watch-list is surfaced
|
||||||
|
> via a warning log and the feed's degraded `ProviderStatus` instead.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"MxGateway": {
|
||||||
|
"Alarms": {
|
||||||
|
"Enabled": true,
|
||||||
|
"SubscriptionExpression": "\\\\DESKTOP-6JL3KKO\\Galaxy!DEV",
|
||||||
|
"DefaultArea": "DEV",
|
||||||
|
"ReconcileIntervalSeconds": 30,
|
||||||
|
"Fallback": {
|
||||||
|
"Mode": "Auto", // Auto | ForceAlarmManager | ForceSubtag
|
||||||
|
"ConsecutiveFailureThreshold": 3,
|
||||||
|
"FailbackProbeIntervalSeconds": 30,
|
||||||
|
"FailbackStableProbes": 3,
|
||||||
|
"Discovery": {
|
||||||
|
"UseGalaxyRepository": true,
|
||||||
|
"Area": "", // defaults to Alarms.DefaultArea
|
||||||
|
"IncludeAttributes": [], // explicit additions
|
||||||
|
"ExcludeAttributes": []
|
||||||
|
},
|
||||||
|
"Subtags": {
|
||||||
|
"Active": "active",
|
||||||
|
"Acked": "acked",
|
||||||
|
"AckComment": "", // verified against MXAccess analysis
|
||||||
|
"Priority": "priority"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GatewayOptionsValidator` additions: `Mode = ForceSubtag` with empty discovery
|
||||||
|
result and no explicit `IncludeAttributes` → startup validation warning;
|
||||||
|
threshold/interval/probe values floored at sane minimums.
|
||||||
|
|
||||||
|
## Open item to confirm during implementation
|
||||||
|
|
||||||
|
The exact AVEVA subtag names (`.active`, `.acked`, the ack-comment attribute,
|
||||||
|
priority) must be confirmed against the MXAccess analysis project
|
||||||
|
(`C:\Users\dohertj2\Desktop\mxaccess`, `docs/MXAccess-Public-API.md`) and the
|
||||||
|
live Galaxy before wiring `SubtagAlarmConsumer`. The config `Subtags` block
|
||||||
|
exists precisely so the resolved names are not hard-coded.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
| Layer | Tests |
|
||||||
|
|---|---|
|
||||||
|
| Worker unit (`MxGateway.Worker.Tests`, x86) | `SubtagAlarmConsumer` synthesis — feed `OnDataChange` sequences, assert raise/ack/clear transitions, snapshot states, degraded flag, synthetic-GUID stability, ack-comment write routing |
|
||||||
|
| Worker unit | `FailoverAlarmConsumer` state machine — fake wnwrap throwing after K polls: assert switch at threshold, failback after stable probes, `ProviderModeChanged` emitted, no duplicate transitions across switch (hand-off reconcile) |
|
||||||
|
| Gateway unit (`MxGateway.Tests`, fake worker) | discovery + config-override merge; `GatewayAlarmMonitor` reflects mode into feed + hub; metrics increment on switch |
|
||||||
|
| Contract | proto round-trip for new fields; existing alarm tests unchanged (alarmmgr-mode regression — parity preserved) |
|
||||||
|
| Live (opt-in, `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`) | real subtag advise + ack-comment write against a live alarm; GR SQL discovery query against the `ZB` DB (gated like existing GR tests) |
|
||||||
|
|
||||||
|
## Docs to update in the same change
|
||||||
|
|
||||||
|
`gateway.md` (alarm provider section), `docs/DesignDecisions.md` (record the
|
||||||
|
fallback decision), `docs/GatewayConfiguration.md` (the `Fallback` block),
|
||||||
|
`docs/AlarmClientDiscovery.md` (subtag provider + synthesis rules),
|
||||||
|
`docs/Grpc.md` (the new `provider_status` / `degraded` fields), and any client
|
||||||
|
READMEs whose generated alarm types gain fields.
|
||||||
@@ -0,0 +1,860 @@
|
|||||||
|
# Alarm Subtag-Monitoring Fallback — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a second alarm source — direct MXAccess subtag monitoring — that the gateway auto-fails-over to when the wnwrap alarmmgr provider breaks, auto-fails-back to when it recovers, and can be forced on by config.
|
||||||
|
|
||||||
|
**Architecture:** Worker-side synthesis (parity rule preserved). A new `SubtagAlarmConsumer` (own `LMXProxyServerClass`, `AddItem`/`Advise` on alarm subtags) and a `FailoverAlarmConsumer` composite (state machine over the wnwrap primary + subtag standby) both implement the existing `IMxAccessAlarmConsumer` seam. The gateway resolves the subtag watch-list (Galaxy Repository SQL + config override), arms the worker at subscribe time, and reflects the live provider mode into the gRPC alarm feed, the dashboard hub, and metrics.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10 (gateway, x64) + .NET Framework 4.8 (worker, x86, STA), protobuf/gRPC, `Microsoft.Data.SqlClient` (Galaxy Repository), SignalR (dashboard), `System.Diagnostics.Metrics`, xUnit (plain `Assert`, no FluentAssertions).
|
||||||
|
|
||||||
|
**Design source:** `docs/plans/2026-06-13-alarm-subtag-fallback-design.md`
|
||||||
|
|
||||||
|
**Branch:** `feat/alarm-subtag-fallback` (already created)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions for every task
|
||||||
|
|
||||||
|
- **TDD:** write the failing test, run it red, implement, run it green, commit.
|
||||||
|
- **xUnit, plain `Assert.*`**, naming `Subject_Condition_Expected`. Worker fakes are sealed private nested classes that raise events.
|
||||||
|
- **Build/test commands:**
|
||||||
|
- Contracts regen: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
||||||
|
- Gateway: `dotnet build src/ZB.MOM.WW.MxGateway.Server` ; `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj`
|
||||||
|
- Worker (x86): `dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86` ; `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86`
|
||||||
|
- Single test: append `--filter FullyQualifiedName~<ClassOrMethod>`
|
||||||
|
- **Build is strict:** `TreatWarningsAsErrors=true`, nullable enabled. Add XML doc comments on public members (the repo runs a doc checker).
|
||||||
|
- **Generated code** under `Generated/` is never hand-edited — rebuild the contracts project to regenerate.
|
||||||
|
- **Namespaces:** worker MxAccess types live in `ZB.MOM.WW.MxGateway.Worker.MxAccess`; proto C# types in `ZB.MOM.WW.MxGateway.Contracts.Proto`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Contracts
|
||||||
|
|
||||||
|
### Task 1: Worker proto — subtag watch-list, failover config, provider-mode enum
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none (Task 2 imports these types)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (real `SubscribeAlarmsCommand` at ~line 324; `MxCommand` references it at 123-125)
|
||||||
|
|
||||||
|
> **CORRECTION (execution):** The alarm command messages and `MxCommand` live in **`mxaccess_gateway.proto`**, not the worker proto. `mxaccess_worker.proto` *imports* the gateway proto (`WorkerCommand.command` is `mxaccess_gateway.v1.MxCommand`), so the gateway proto is the base and the worker proto needs **no** change. `AlarmProviderMode` and the new types are added to the gateway proto and are visible to worker code as `mxaccess_gateway.v1` types. Tasks 1 and 2 are executed as a single combined edit on this one file.
|
||||||
|
|
||||||
|
**Step 1: Add the enum and messages.** In `mxaccess_gateway.proto`, extend the existing `SubscribeAlarmsCommand` message (line 324) and add the new types after it:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// Provider selection / current provider for the alarm feed. Defined here in
|
||||||
|
// the worker contract because the worker SubscribeAlarmsCommand references it;
|
||||||
|
// mxaccess_gateway.proto imports this file and reuses the same enum.
|
||||||
|
enum AlarmProviderMode {
|
||||||
|
ALARM_PROVIDER_MODE_UNSPECIFIED = 0; // auto: alarmmgr primary, subtag fallback
|
||||||
|
ALARM_PROVIDER_MODE_ALARMMGR = 1;
|
||||||
|
ALARM_PROVIDER_MODE_SUBTAG = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeAlarmsCommand {
|
||||||
|
string subscription_expression = 1; // existing field — keep
|
||||||
|
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
||||||
|
AlarmProviderMode forced_mode = 2;
|
||||||
|
// Subtag watch-list resolved by the gateway (GR SQL + config). Empty in pure
|
||||||
|
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
|
||||||
|
repeated AlarmSubtagTarget watch_list = 3;
|
||||||
|
AlarmFailoverConfig failover = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One alarm attribute the subtag consumer advises. Addresses are full MXAccess
|
||||||
|
// item references the worker passes straight to AddItem.
|
||||||
|
message AlarmSubtagTarget {
|
||||||
|
string alarm_full_reference = 1; // e.g. "Galaxy!Area.Tank01.Level.HiHi"
|
||||||
|
string source_object_reference = 2; // e.g. "Tank01"
|
||||||
|
string active_subtag = 3; // item address of the in-alarm boolean
|
||||||
|
string acked_subtag = 4; // item address of the acknowledged boolean
|
||||||
|
string ack_comment_subtag = 5; // writable ack-comment attribute (ack write target)
|
||||||
|
string priority_subtag = 6; // optional severity source; empty if absent
|
||||||
|
}
|
||||||
|
|
||||||
|
message AlarmFailoverConfig {
|
||||||
|
int32 consecutive_failure_threshold = 1; // wnwrap COM failures before switching (>=1)
|
||||||
|
int32 failback_probe_interval_seconds = 2; // probe cadence while degraded (>=1)
|
||||||
|
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`UnsubscribeAlarmsCommand` and `AcknowledgeAlarmCommand` are unchanged.
|
||||||
|
|
||||||
|
**Step 2: Regenerate & verify it compiles.**
|
||||||
|
Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
||||||
|
Expected: build succeeds; generated `AlarmProviderMode`, `AlarmSubtagTarget`, `AlarmFailoverConfig` types appear.
|
||||||
|
|
||||||
|
**Step 3: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto
|
||||||
|
git commit -m "contracts(worker): subtag watch-list + failover config + AlarmProviderMode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Gateway proto — provider status on the feed, degraded provenance, mode-changed event
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Task 1; Task 3 tests both)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (`OnAlarmTransitionEvent` ~719-771, `ActiveAlarmSnapshot` ~783-803, `AlarmFeedMessage` ~860-870, `MxEvent` family enum + body oneof, `MxEventFamily` enum)
|
||||||
|
|
||||||
|
**Step 1: Add degraded provenance to the two alarm payloads.** Append to `OnAlarmTransitionEvent` (next free field 14):
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// True when this transition came from the subtag-monitoring fallback rather
|
||||||
|
// than the native alarmmgr provider — i.e. it was synthesized from data
|
||||||
|
// changes and carries reduced fidelity (synthetic GUID, no native raise time).
|
||||||
|
bool degraded = 14;
|
||||||
|
// Which provider produced this transition.
|
||||||
|
AlarmProviderMode source_provider = 15;
|
||||||
|
```
|
||||||
|
|
||||||
|
Append the identical two fields to `ActiveAlarmSnapshot` (next free field 14):
|
||||||
|
```protobuf
|
||||||
|
bool degraded = 14;
|
||||||
|
AlarmProviderMode source_provider = 15;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add provider status to the feed oneof.** Add a new oneof case to `AlarmFeedMessage` (next free field 4) and a new message:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message AlarmFeedMessage {
|
||||||
|
oneof payload {
|
||||||
|
ActiveAlarmSnapshot active_alarm = 1;
|
||||||
|
bool snapshot_complete = 2;
|
||||||
|
OnAlarmTransitionEvent transition = 3;
|
||||||
|
// Provider-mode status. Emitted once on stream open and again on every
|
||||||
|
// failover/failback so late joiners learn the current mode immediately.
|
||||||
|
AlarmProviderStatus provider_status = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message AlarmProviderStatus {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
bool degraded = 2; // true whenever mode == SUBTAG
|
||||||
|
string reason = 3; // human-readable switch reason
|
||||||
|
google.protobuf.Timestamp since = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add the worker→gateway mode-changed event to `MxEvent`.** Find the `MxEventFamily` enum and the `MxEvent` body oneof. Add a family member and a body message + oneof case (use the next free family value and the next free `MxEvent` body field number — check the file):
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// in MxEventFamily enum:
|
||||||
|
MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED = <next>;
|
||||||
|
|
||||||
|
// new message near OnAlarmTransitionEvent:
|
||||||
|
message OnAlarmProviderModeChangedEvent {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
string reason = 2;
|
||||||
|
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
|
||||||
|
google.protobuf.Timestamp at = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// in MxEvent body oneof:
|
||||||
|
OnAlarmProviderModeChangedEvent on_alarm_provider_mode_changed = <next>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`AlarmProviderMode` is defined in `mxaccess_worker.proto`; confirm `mxaccess_gateway.proto` already has `import "mxaccess_worker.proto";` (it references `SubscribeAlarmsCommand`, so it does) and reference the enum unqualified or via its package as the existing references do.
|
||||||
|
|
||||||
|
**Step 4: Regenerate & verify.**
|
||||||
|
Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
**Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto
|
||||||
|
git commit -m "contracts(gateway): AlarmProviderStatus feed case, degraded provenance, mode-changed event"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Proto round-trip tests for the new alarm fields
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none (depends on Tasks 1-2)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Add tests** mirroring the existing `Event_RoundTripsOnAlarmTransitionWithFullPayload` style:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Feed_RoundTripsProviderStatus()
|
||||||
|
{
|
||||||
|
var since = Timestamp.FromDateTime(new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
|
||||||
|
var original = new AlarmFeedMessage
|
||||||
|
{
|
||||||
|
ProviderStatus = new AlarmProviderStatus
|
||||||
|
{
|
||||||
|
Mode = AlarmProviderMode.Subtag,
|
||||||
|
Degraded = true,
|
||||||
|
Reason = "wnwrap poll failed 3x (HRESULT 0x80004005)",
|
||||||
|
Since = since,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var parsed = AlarmFeedMessage.Parser.ParseFrom(original.ToByteArray());
|
||||||
|
|
||||||
|
Assert.Equal(original, parsed);
|
||||||
|
Assert.Equal(AlarmFeedMessage.PayloadOneofCase.ProviderStatus, parsed.PayloadCase);
|
||||||
|
Assert.True(parsed.ProviderStatus.Degraded);
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.ProviderStatus.Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transition_RoundTripsDegradedProvenance()
|
||||||
|
{
|
||||||
|
var t = new OnAlarmTransitionEvent
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
|
TransitionKind = AlarmTransitionKind.Raise,
|
||||||
|
Degraded = true,
|
||||||
|
SourceProvider = AlarmProviderMode.Subtag,
|
||||||
|
};
|
||||||
|
|
||||||
|
var parsed = OnAlarmTransitionEvent.Parser.ParseFrom(t.ToByteArray());
|
||||||
|
|
||||||
|
Assert.True(parsed.Degraded);
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.SourceProvider);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run red→green.**
|
||||||
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 3: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
|
||||||
|
git commit -m "test(contracts): round-trip provider status + degraded provenance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Worker: subtag consumer + failover
|
||||||
|
|
||||||
|
### Task 4: Subtag value-source abstraction + synthesis state holder
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (Task 5 builds on it)
|
||||||
|
|
||||||
|
A testable seam so synthesis logic is unit-tested without COM. The COM wiring lands in Task 6.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Define the source abstraction.** `ISubtagAlarmSource` advises subtag addresses and raises a normalized value-change callback on the STA:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>A change in one advised subtag value, normalized off the COM boundary.</summary>
|
||||||
|
public sealed class SubtagValueChange
|
||||||
|
{
|
||||||
|
/// <summary>The full item address that changed (matches an AlarmSubtagTarget subtag).</summary>
|
||||||
|
public string ItemAddress { get; init; } = string.Empty;
|
||||||
|
/// <summary>The new value (boolean for .active/.acked, numeric for priority).</summary>
|
||||||
|
public object? Value { get; init; }
|
||||||
|
/// <summary>The change timestamp in UTC.</summary>
|
||||||
|
public DateTime TimestampUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advises a set of MXAccess subtag addresses and surfaces value changes.
|
||||||
|
/// The production implementation (Task 6) owns its own LMXProxyServerClass;
|
||||||
|
/// tests substitute a fake that pushes <see cref="SubtagValueChange"/>s.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISubtagAlarmSource : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Raised on the STA when an advised subtag's value changes.</summary>
|
||||||
|
event EventHandler<SubtagValueChange>? ValueChanged;
|
||||||
|
|
||||||
|
/// <summary>Advises every subtag in the supplied addresses; idempotent per address.</summary>
|
||||||
|
void Advise(IReadOnlyCollection<string> itemAddresses);
|
||||||
|
|
||||||
|
/// <summary>Writes a value to an item address (used for the ack-comment write).</summary>
|
||||||
|
void Write(string itemAddress, object? value);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Write the state-machine tests first.** `SubtagAlarmStateMachine` maps `(active, acked)` changes per target to `MxAlarmTransitionEvent`s. Test the four core transitions:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
public sealed class SubtagAlarmStateMachineTests
|
||||||
|
{
|
||||||
|
private static AlarmSubtagTarget Target() => new()
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
|
SourceObjectReference = "Tank01",
|
||||||
|
ActiveSubtag = "Tank01.Level.HiHi.active",
|
||||||
|
AckedSubtag = "Tank01.Level.HiHi.acked",
|
||||||
|
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveFalseToTrue_EmitsRaise_FlaggedDegraded()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State);
|
||||||
|
Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState);
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", e.Record.TagName); // reference minus provider/area
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AckedTrueWhileActive_EmitsAckTransition()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(5));
|
||||||
|
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckAlm, e.Record.State);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, e.PreviousState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveTrueToFalse_WhileUnacked_EmitsUnackRtn()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
|
||||||
|
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackRtn, e.Record.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_ReflectsActiveAndAckedState()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.acked", true, ts);
|
||||||
|
|
||||||
|
var snap = Assert.Single(sm.SnapshotActive());
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckAlm, snap.State);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~SubtagAlarmStateMachineTests` → FAIL (type missing).
|
||||||
|
|
||||||
|
**Step 3: Implement `SubtagAlarmStateMachine`.** Build an address→target index (active/acked/priority/comment addresses), hold per-reference `(bool active, bool acked, DateTime firstRaiseUtc, int priority)`, and emit on change:
|
||||||
|
- active `false→true` ⇒ `UnackAlm`, set `firstRaiseUtc`, `PreviousState` from prior state.
|
||||||
|
- acked `false→true` while active ⇒ `AckAlm`.
|
||||||
|
- active `true→false` ⇒ `AckRtn` if currently acked else `UnackRtn`; then reset acked.
|
||||||
|
- priority change ⇒ update stored priority, no transition.
|
||||||
|
- `TagName` = `alarm_full_reference` with any `Provider!Area.` prefix stripped (match `WnWrapAlarmConsumer`'s reference shape so `GatewayAlarmMonitor` keys align). Set `ProviderName`, `Group`, `Priority`, `AlarmComment` from the target/last values. Mark a `Degraded`/source flag (carried via a new field — see Task 5 wiring).
|
||||||
|
- `SnapshotActive()` returns `MxAlarmSnapshotRecord` for references whose active is true.
|
||||||
|
|
||||||
|
**Step 4: Run green.** Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs
|
||||||
|
git commit -m "worker(alarms): subtag value-source seam + synthesis state machine"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `SubtagAlarmConsumer` over the source seam (no COM yet)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Task 4)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Test with a fake `ISubtagAlarmSource`.** Drive value changes through the source, assert `AlarmTransitionEmitted` fires with synthesized records and that ack writes the comment to the ack-comment subtag:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class SubtagAlarmConsumerTests
|
||||||
|
{
|
||||||
|
private sealed class FakeSource : ISubtagAlarmSource
|
||||||
|
{
|
||||||
|
public event EventHandler<SubtagValueChange>? ValueChanged;
|
||||||
|
public List<string> Advised { get; } = new();
|
||||||
|
public (string Address, object? Value)? LastWrite { get; private set; }
|
||||||
|
public void Advise(IReadOnlyCollection<string> a) => Advised.AddRange(a);
|
||||||
|
public void Write(string a, object? v) => LastWrite = (a, v);
|
||||||
|
public void Raise(string addr, object? val, DateTime ts) =>
|
||||||
|
ValueChanged?.Invoke(this, new SubtagValueChange { ItemAddress = addr, Value = val, TimestampUtc = ts });
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AlarmSubtagTarget Target() => new()
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
|
ActiveSubtag = "Tank01.Level.HiHi.active",
|
||||||
|
AckedSubtag = "Tank01.Level.HiHi.acked",
|
||||||
|
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Subscribe_AdvisesAllSubtags()
|
||||||
|
{
|
||||||
|
var src = new FakeSource();
|
||||||
|
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
|
||||||
|
c.Subscribe("ignored-in-subtag-mode");
|
||||||
|
Assert.Contains("Tank01.Level.HiHi.active", src.Advised);
|
||||||
|
Assert.Contains("Tank01.Level.HiHi.acked", src.Advised);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValueChange_RaisesSynthesizedTransition()
|
||||||
|
{
|
||||||
|
var src = new FakeSource();
|
||||||
|
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
|
||||||
|
c.Subscribe("x");
|
||||||
|
MxAlarmTransitionEvent? seen = null;
|
||||||
|
c.AlarmTransitionEmitted += (_, e) => seen = e;
|
||||||
|
|
||||||
|
src.Raise("Tank01.Level.HiHi.active", true, new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
Assert.NotNull(seen);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, seen!.Record.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcknowledgeByName_WritesCommentToAckCommentSubtag()
|
||||||
|
{
|
||||||
|
var src = new FakeSource();
|
||||||
|
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
|
||||||
|
c.Subscribe("x");
|
||||||
|
|
||||||
|
int rc = c.AcknowledgeByName("Tank01.Level.HiHi", "Galaxy", "Area",
|
||||||
|
"ack from HMI", "op1", "node", "dom", "Op One");
|
||||||
|
|
||||||
|
Assert.Equal(0, rc);
|
||||||
|
Assert.Equal(("Tank01.Level.HiHi.ackmsg", (object?)"ack from HMI"), src.LastWrite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement `SubtagAlarmConsumer : IMxAccessAlarmConsumer`.**
|
||||||
|
- Constructor `(ISubtagAlarmSource source, IReadOnlyList<AlarmSubtagTarget> watchList)`; build a `SubtagAlarmStateMachine`; index `alarm_full_reference`→target for ack routing.
|
||||||
|
- `Subscribe(_)`: call `source.Advise(<all subtag addresses>)`; subscribe to `source.ValueChanged`, feed each into the state machine, and re-raise each produced `MxAlarmTransitionEvent` via `AlarmTransitionEmitted` (mark degraded).
|
||||||
|
- `AcknowledgeByName(alarmName, …, comment, …)`: resolve the target by reference; if no `AckCommentSubtag`, return a non-zero failure code; else `source.Write(target.AckCommentSubtag, comment)` and return 0.
|
||||||
|
- `AcknowledgeByGuid(guid, …)`: map the synthetic GUID (deterministic hash of reference — see Task 8 helper, or a local copy) back to a reference, then delegate to the name path; unknown GUID ⇒ non-zero.
|
||||||
|
- `SnapshotActiveAlarms()`: from the state machine.
|
||||||
|
- `PollOnce()`: no-op.
|
||||||
|
- `Dispose()`: unsubscribe + dispose source.
|
||||||
|
|
||||||
|
**Step 3: Run green.** `dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~SubtagAlarmConsumerTests`.
|
||||||
|
|
||||||
|
**Step 4: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
|
||||||
|
git commit -m "worker(alarms): SubtagAlarmConsumer synthesizing transitions over the source seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: COM-backed `LmxSubtagAlarmSource` (own LMXProxyServerClass)
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
The only piece that touches live COM. Like `WnWrapAlarmConsumer`, it owns its own MXAccess server object so the subtag source is self-contained and isolated from the session's item pipeline. Logic stays thin (advise/write/marshal); real verification is the live smoke test in Task 17.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs` (constructor/guard tests only; COM path is live-gated)
|
||||||
|
|
||||||
|
**Step 1: Implement `LmxSubtagAlarmSource : ISubtagAlarmSource`.**
|
||||||
|
- Own an `LMXProxyServerClass` (reuse the worker's `IMxAccessServer`/`MxAccessComServer` wrapper + `IMxAccessComObjectFactory` so it is fakeable; constructor takes the factory).
|
||||||
|
- `Advise(addresses)`: `RegisterServer` (topic) once; per address `AddItem`→`itemHandle`, `Advise`, and record `itemHandle→address`. Subscribe to the proxy's `OnDataChange`; in the handler, look up the address by `phItemHandle`, normalize `pvItemValue` (VARIANT→bool/double) and `pftItemTimeStamp`→UTC, and raise `ValueChanged`. All calls run on the STA (the worker STA pumps messages, so `OnDataChange` delivers).
|
||||||
|
- `Write(address, value)`: resolve/create the item handle, `server.Write(serverHandle, itemHandle, value, userId: 0)`.
|
||||||
|
- `Dispose()`: `UnAdvise`/`RemoveItem`/`Unregister`/release COM.
|
||||||
|
|
||||||
|
**Step 2: Tests** — only the non-COM guards (null factory throws; `Write` before `Advise` resolves a handle or throws a clear error). Mark the COM round-trip `[LiveMxAccessFact]` and `Skip` per the `AlarmsLiveSmokeTests` precedent.
|
||||||
|
|
||||||
|
**Step 3: Build x86 + run unit tests.**
|
||||||
|
`dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86`
|
||||||
|
`dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~LmxSubtagAlarmSourceTests`
|
||||||
|
|
||||||
|
**Step 4: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs
|
||||||
|
git commit -m "worker(alarms): COM-backed LmxSubtagAlarmSource advising alarm subtags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: `FailoverAlarmConsumer` state machine
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Task 5)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmProviderModeChange.cs` (small EventArgs)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Test the switch/failback with a fake primary that throws.**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class FailoverAlarmConsumerTests
|
||||||
|
{
|
||||||
|
private sealed class FlakyPrimary : IMxAccessAlarmConsumer
|
||||||
|
{
|
||||||
|
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
||||||
|
public int PollsUntilHeal = int.MaxValue; // becomes healthy after N polls while degraded
|
||||||
|
public bool ThrowOnPoll = true;
|
||||||
|
private int _polls;
|
||||||
|
public void Subscribe(string s) { if (ThrowOnPoll) throw new COMException("boom", unchecked((int)0x80004005)); }
|
||||||
|
public void PollOnce()
|
||||||
|
{
|
||||||
|
_polls++;
|
||||||
|
if (ThrowOnPoll && _polls < PollsUntilHeal) throw new COMException("boom", unchecked((int)0x80004005));
|
||||||
|
}
|
||||||
|
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 0;
|
||||||
|
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 0;
|
||||||
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => Array.Empty<MxAlarmSnapshotRecord>();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubStandby : IMxAccessAlarmConsumer { /* records Subscribe, no-op rest */ }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Primary_FailsThresholdTimes_SwitchesToSubtagAndEmitsModeChange()
|
||||||
|
{
|
||||||
|
var primary = new FlakyPrimary();
|
||||||
|
var standby = new StubStandby();
|
||||||
|
using var c = new FailoverAlarmConsumer(primary, standby,
|
||||||
|
new FailoverSettings(threshold: 3, probeIntervalSeconds: 30, stableProbes: 3));
|
||||||
|
AlarmProviderModeChange? change = null;
|
||||||
|
c.ProviderModeChanged += (_, e) => change = e;
|
||||||
|
|
||||||
|
c.Subscribe("\\\\host\\Galaxy!Area"); // primary.Subscribe throws -> counts as failure 1
|
||||||
|
c.PollOnce(); // failure 2
|
||||||
|
c.PollOnce(); // failure 3 -> switch
|
||||||
|
|
||||||
|
Assert.NotNull(change);
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, change!.Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WhileDegraded_PrimaryHeals_FailsBackAfterStableProbes()
|
||||||
|
{
|
||||||
|
var primary = new FlakyPrimary { PollsUntilHeal = 0 }; // will heal once we stop throwing
|
||||||
|
var standby = new StubStandby();
|
||||||
|
using var c = new FailoverAlarmConsumer(primary, standby,
|
||||||
|
new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 2));
|
||||||
|
var modes = new List<AlarmProviderMode>();
|
||||||
|
c.ProviderModeChanged += (_, e) => modes.Add(e.Mode);
|
||||||
|
|
||||||
|
c.Subscribe("x"); // failure -> switch to subtag
|
||||||
|
primary.ThrowOnPoll = false;
|
||||||
|
c.ProbeOnce(); // clean probe 1
|
||||||
|
c.ProbeOnce(); // clean probe 2 -> failback
|
||||||
|
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, modes[0]);
|
||||||
|
Assert.Equal(AlarmProviderMode.Alarmmgr, modes[^1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement.**
|
||||||
|
- `record FailoverSettings(int threshold, int probeIntervalSeconds, int stableProbes)`; `AlarmProviderModeChange : EventArgs { AlarmProviderMode Mode; string Reason; int HResult; DateTime AtUtc; }`.
|
||||||
|
- Constructor `(IMxAccessAlarmConsumer primary, IMxAccessAlarmConsumer standby, FailoverSettings settings)`; forced-mode variants handled in Task 9 wiring (forced ⇒ skip the other consumer).
|
||||||
|
- Forward `AlarmTransitionEmitted` from the **active** child only (swap the subscription on switch).
|
||||||
|
- Wrap `Subscribe`/`PollOnce` on the primary: on `COMException` (or a failure HRESULT) while `PrimaryActive`, increment a counter; at `threshold`, ensure standby `Subscribe`d, set active=standby, snapshot standby for hand-off, raise `ProviderModeChanged(Subtag, reason, hresult, now)`. Reset counter on any clean primary poll.
|
||||||
|
- `ProbeOnce()` (driven by the poll loop while degraded, gated by `probeIntervalSeconds`): try primary `Subscribe`+`PollOnce`; count consecutive clean probes; at `stableProbes`, set active=primary, return standby to standby, raise `ProviderModeChanged(Alarmmgr, "recovered", 0, now)`.
|
||||||
|
- `Acknowledge*` / `SnapshotActiveAlarms` delegate to the **active** child.
|
||||||
|
- `PollOnce()` drives the active child's poll, and—while degraded—also drives the failback probe cadence.
|
||||||
|
|
||||||
|
**Step 3: Run green** (x86 filter `FailoverAlarmConsumerTests`).
|
||||||
|
|
||||||
|
**Step 4: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmProviderModeChange.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs
|
||||||
|
git commit -m "worker(alarms): FailoverAlarmConsumer auto-failover/failback state machine"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Synthetic-GUID helper + degraded flag on the event sink path
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 9
|
||||||
|
|
||||||
|
Carry `degraded` + `source_provider` from the worker synthesis into the emitted `OnAlarmTransitionEvent`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs` (add `bool Degraded`)
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` (`EnqueueTransition` carries degraded)
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs` (`CreateOnAlarmTransition` sets `Degraded`/`SourceProvider`)
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs`
|
||||||
|
- Test: add cases to `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs` and a new `SyntheticAlarmGuidTests.cs`
|
||||||
|
|
||||||
|
**Step 1: `SyntheticAlarmGuid.ForReference(string reference)`** — deterministic GUID from a stable hash (e.g. MD5 of the UTF-8 reference → `new Guid(bytes)`), so subtag-mode acks resolve by GUID. Test determinism + difference:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact] public void SameReference_SameGuid() =>
|
||||||
|
Assert.Equal(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.C"));
|
||||||
|
[Fact] public void DifferentReference_DifferentGuid() =>
|
||||||
|
Assert.NotEqual(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.D"));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Thread `degraded`** through `MxAlarmSnapshotRecord.Degraded`, `EnqueueTransition(... bool degraded)`, and `CreateOnAlarmTransition(... bool degraded, AlarmProviderMode sourceProvider)`. Default `degraded=false`, `sourceProvider=Alarmmgr` so the wnwrap path is unchanged (regression: existing `AlarmDispatcherTests` still pass with `Degraded=false`).
|
||||||
|
|
||||||
|
**Step 3: Tests** — extend `AlarmDispatcherTests` with a subtag-style transition asserting `body.Degraded == true` and `SourceProvider == Subtag`.
|
||||||
|
|
||||||
|
**Step 4: Build x86 + run** worker tests for `AlarmDispatcherTests`, `SyntheticAlarmGuidTests`.
|
||||||
|
|
||||||
|
**Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/
|
||||||
|
git commit -m "worker(alarms): synthetic GUID + degraded provenance on emitted transitions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Wire watch-list + failover config through `AlarmCommandHandler`; emit mode-changed event
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Tasks 5, 7, 8)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs`
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs`
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs` (`ExecuteSubscribeAlarms`, ~lines 588-616)
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (consumer factory wiring; mode-change → event queue)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs` (extend or create)
|
||||||
|
|
||||||
|
**Step 1: Carry the subscribe payload.** Change the alarm subscribe entry point from `Subscribe(string subscription)` to `Subscribe(SubscribeAlarmsCommand command)` (the command now has `ForcedMode`, `WatchList`, `Failover`). In `AlarmCommandHandler.Subscribe`:
|
||||||
|
- Build the active provider per `ForcedMode`:
|
||||||
|
- `ALARMMGR` ⇒ `WnWrapAlarmConsumer` only.
|
||||||
|
- `SUBTAG` ⇒ `SubtagAlarmConsumer(new LmxSubtagAlarmSource(factory), watchList)` only.
|
||||||
|
- `UNSPECIFIED` ⇒ `FailoverAlarmConsumer(primary: wnwrap, standby: subtag, settings-from-Failover)`.
|
||||||
|
- Use the existing `consumerFactory` seam but widen it to `Func<SubscribeAlarmsCommand, IMxAccessAlarmConsumer>` so tests inject fakes and production builds the failover composite. Subscribe to `FailoverAlarmConsumer.ProviderModeChanged` and enqueue an `OnAlarmProviderModeChangedEvent` MxEvent via the event queue (new mapper method `CreateOnAlarmProviderModeChanged`).
|
||||||
|
|
||||||
|
**Step 2: Executor + STA wiring.** `ExecuteSubscribeAlarms` passes the full `SubscribeAlarmsCommand` (not just the expression). In `MxAccessStaSession`, the `alarmCommandHandlerFactory` must give the handler access to the `IMxAccessComObjectFactory` so the subtag source can create its own proxy server on the STA; keep the `EnsureOnAlarmConsumerThread` affinity guard on every path.
|
||||||
|
|
||||||
|
**Step 3: Test** — fake consumer factory; assert that a `SUBTAG` forced command builds the subtag consumer and advises; that an auto command building a fake failover composite, when it raises `ProviderModeChanged`, enqueues an `OnAlarmProviderModeChangedEvent` on the queue.
|
||||||
|
|
||||||
|
**Step 4: Build x86 + worker tests.**
|
||||||
|
|
||||||
|
**Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/
|
||||||
|
git commit -m "worker(alarms): route watch-list/failover config; emit provider-mode-changed event"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Gateway: discovery, options, monitor, metrics, dashboard
|
||||||
|
|
||||||
|
### Task 10: `AlarmsOptions.Fallback` + validation
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 11, Task 13
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs`
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs` (`ValidateAlarms`, ~lines 234-258)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs` (extend)
|
||||||
|
|
||||||
|
**Step 1:** Add `AlarmFallbackOptions Fallback { get; init; } = new();` to `AlarmsOptions`. `AlarmFallbackOptions`: `string Mode = "Auto"` (`Auto|ForceAlarmManager|ForceSubtag`), `int ConsecutiveFailureThreshold = 3`, `int FailbackProbeIntervalSeconds = 30`, `int FailbackStableProbes = 3`, a `Discovery` sub-object (`bool UseGalaxyRepository = true`, `string Area = ""`, `string[] IncludeAttributes = []`, `string[] ExcludeAttributes = []`), and a `Subtags` sub-object (`Active="active"`, `Acked="acked"`, `AckComment=""`, `Priority="priority"`).
|
||||||
|
|
||||||
|
**Step 2:** In `ValidateAlarms`, when `Enabled` and `Mode == "ForceSubtag"` and `Discovery.UseGalaxyRepository == false` and `IncludeAttributes` empty ⇒ add a validation error ("ForceSubtag requires Galaxy Repository discovery or an explicit IncludeAttributes list"). Floor the three numeric values at 1. Validate `Mode` is one of the three literals.
|
||||||
|
|
||||||
|
**Step 3-5:** Test the new validation cases (red→green), build the server, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Galaxy Repository "alarm attributes" discovery query
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 10, Task 13
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs` (add `GetAlarmAttributesAsync` + SQL constant, following `GetAttributesAsync` ~lines 86-115 and `AttributesSql` ~line 176)
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/` (projection unit test; live SQL gated)
|
||||||
|
|
||||||
|
**Step 1:** `GalaxyAlarmAttributeRow { string FullTagReference; string SourceObjectReference; string AckCommentSubtag; }` (and any priority subtag). `GetAlarmAttributesAsync` reuses the existing `is_alarm` detection (the `AlarmExtension` primitive join already in `AttributesSql`) filtered to `is_alarm = 1`, projecting the alarm reference + its ack-comment attribute. Follow the exact `SqlConnection`/`SqlCommand`/`SqlDataReader` pattern from `GetAttributesAsync`.
|
||||||
|
|
||||||
|
**Step 2:** Unit-test the row→`AlarmSubtagTarget` mapping (a pure mapper function); gate any live-DB test like the existing Galaxy live tests (or `Skip` with a note, matching `AlarmsLiveSmokeTests`).
|
||||||
|
|
||||||
|
**Step 3-5:** red→green, build server, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Watch-list resolver (GR SQL + config override → `AlarmSubtagTarget[]`)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none (depends on Tasks 10, 11)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Test the merge** with a fake `IGalaxyRepository`:
|
||||||
|
- discovery rows + `IncludeAttributes` are unioned; `ExcludeAttributes` removed; each becomes an `AlarmSubtagTarget` with `.active`/`.acked`/`.ackmsg` addresses composed from the configured `Subtags` names (`<reference>.<Active>`, etc.); empty config subtag names fall back to defaults; GR unavailable + no includes ⇒ empty list + a logged warning flag.
|
||||||
|
|
||||||
|
**Step 2: Implement** `ResolveAsync(AlarmsOptions, CancellationToken) → IReadOnlyList<AlarmSubtagTarget>`.
|
||||||
|
|
||||||
|
**Step 3-5:** red→green, build, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 13: Gateway metrics — provider-mode gauge + switch counter
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 10, Task 11
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs` (ctor ~lines 55-79; add counter + observable gauge following the existing pattern)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Metrics/GatewayMetricsTests.cs` (if present; else assert via a `MeterListener`)
|
||||||
|
|
||||||
|
**Step 1:** Add `mxgateway.alarms.provider_switches` counter (tagged `from`,`to`,`reason`) and `mxgateway.alarms.provider_mode` observable gauge (1=alarmmgr, 2=subtag), plus `AlarmProviderSwitched(int from, int to, string reason)` and a private `GetAlarmProviderMode()` (lock on `_syncRoot` like the others).
|
||||||
|
|
||||||
|
**Step 2-4:** test, build, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 14: `GatewayAlarmMonitor` — arm watch-list, reflect provider mode, reconcile on switch
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Tasks 9, 12, 13)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (ctor ~41-49; `SubscribeAlarmsAsync` ~210-233; event-drain loop; `StreamAsync` ~386-434)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs` (new, using `FakeWorkerHarness`)
|
||||||
|
|
||||||
|
**Step 1:** Inject `IAlarmWatchListResolver` and `GatewayMetrics`. In `SubscribeAlarmsAsync`, resolve the watch-list and build the `SubscribeAlarmsCommand` with `ForcedMode` (from `Fallback.Mode`), `WatchList`, and `Failover` populated from options — instead of the bare `{ SubscriptionExpression }`.
|
||||||
|
|
||||||
|
**Step 2:** In the worker-event drain path, handle `OnAlarmProviderModeChangedEvent`: update a `_providerStatus` field (mode/degraded/reason/since), `Broadcast(new AlarmFeedMessage { ProviderStatus = … })` to every subscriber, call `metrics.AlarmProviderSwitched(...)`, and force a `ReconcileAsync` so the cache re-seeds from the now-active provider (avoids raise/clear storms).
|
||||||
|
|
||||||
|
**Step 3:** In `StreamAsync`, emit the current `provider_status` as the **first** message (before the snapshot) so a late joiner immediately knows the mode.
|
||||||
|
|
||||||
|
**Step 4: Test** — stand up the monitor with `FakeWorkerHarness`; emit an `OnAlarmProviderModeChangedEvent(Subtag)`; assert a `StreamAsync` subscriber receives a `ProviderStatus{ Mode=Subtag, Degraded=true }` and that the switch counter incremented. Also assert a transition emitted in subtag mode flows through with `Degraded=true`.
|
||||||
|
|
||||||
|
**Step 5:** build server, run the new test, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 15: Dashboard — push provider status to `/hubs/alarms` + UI indicator
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Task 14)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs` (forward `ProviderStatus` messages — they already flow through `StreamAsync`, so confirm the existing `SendAsync(AlarmMessage, message)` carries them; add a dedicated `"ProviderModeChanged"` client method if the dashboard needs a distinct channel)
|
||||||
|
- Modify: the alarms dashboard page/component (Bootstrap-only badge: green "alarmmgr" / amber "degraded — subtag") — find under `src/ZB.MOM.WW.MxGateway.Server/Dashboard/`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/` dashboard model test (e.g. a `DashboardAlarmProviderStatus.FromFeed` mapper, mirroring `DashboardActiveAlarm.FromSnapshot`)
|
||||||
|
|
||||||
|
**Constraint:** Bootstrap CSS/JS only — no MudBlazor/Radzen/FluentUI.
|
||||||
|
|
||||||
|
**Steps:** TDD the model mapper, wire the publisher + badge, build, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Integration, docs, live smoke
|
||||||
|
|
||||||
|
### Task 16: End-to-end fake-worker failover test
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 18
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs`
|
||||||
|
|
||||||
|
Drive the full gateway path with `FakeWorkerHarness`: subscribe (assert the `SubscribeAlarmsCommand` carries a watch-list), emit a wnwrap-style transition (assert `Degraded=false`), emit `OnAlarmProviderModeChangedEvent(Subtag)`, emit a synthesized transition (assert `Degraded=true`, `SourceProvider=Subtag`), then `OnAlarmProviderModeChangedEvent(Alarmmgr)` and assert the feed reports recovery. Build, run, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 17: Live subtag smoke test (opt-in)
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 18
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.IntegrationTests/...AlarmSubtagLiveSmokeTests.cs` (or the worker live suite)
|
||||||
|
|
||||||
|
A `[LiveMxAccessFact]`, `Skip`-by-default test (per `AlarmsLiveSmokeTests` precedent) that, against a live Galaxy + alarm flip script: advises the real `.active`/`.acked` subtags via `LmxSubtagAlarmSource`, asserts a synthesized raise/clear, and performs an ack via the ack-comment write. Document the exact subtag names discovered (resolves the design's open item). Commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18: Documentation
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 16, Task 17
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `gateway.md` (alarm provider section: dual provider + auto-failover/failback)
|
||||||
|
- Modify: `docs/DesignDecisions.md` (record the fallback decision + parity rationale)
|
||||||
|
- Modify: `docs/GatewayConfiguration.md` (the `MxGateway:Alarms:Fallback` block)
|
||||||
|
- Modify: `docs/AlarmClientDiscovery.md` (subtag provider, synthesis rules, ack-comment write)
|
||||||
|
- Modify: `docs/Grpc.md` (new `provider_status` feed case + `degraded`/`source_provider` fields)
|
||||||
|
|
||||||
|
Follow `StyleGuide.md` (PascalCase filenames, present tense, explain *why*). No code; commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution order & parallelism summary
|
||||||
|
|
||||||
|
- **Serial spine:** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8/9 → 10/11 → 12 → 13 → 14 → 15 → 16 → 17/18.
|
||||||
|
- **Parallelizable clusters:** {8, 9 partially}, {10, 11, 13}, {16, 17, 18}.
|
||||||
|
- **High-risk tasks** (full review chain): 1, 2, 6, 7, 9, 14. **Standard:** 4, 5, 8, 10, 11, 12, 15, 16. **Small/trivial:** 3, 13, 17, 18.
|
||||||
|
|
||||||
|
## Risk notes for the executor
|
||||||
|
|
||||||
|
- **Field-number collisions:** Task 2 must read the live `MxEvent`/`MxEventFamily` numbers before adding — the agent map gave alarm-payload maxima but not `MxEvent`'s. Verify before editing.
|
||||||
|
- **STA discipline:** every COM call in `LmxSubtagAlarmSource` and every consumer swap runs on the worker STA; keep the `EnsureOnAlarmConsumerThread` guard. The worker STA already pumps Windows messages, which is required for the subtag `OnDataChange` to deliver.
|
||||||
|
- **Parity regression:** alarmmgr-mode output must be byte-for-byte unchanged. Existing `AlarmDispatcherTests` and `ProtobufContractRoundTripTests` are the guardrail — they must stay green with `Degraded=false` defaults.
|
||||||
|
- **Subtag names unverified:** the design leaves exact AVEVA subtag names (`.active`, `.acked`, ack-comment) to confirm against `C:\Users\dohertj2\Desktop\mxaccess` + a live Galaxy (Task 17). The config `Subtags` block exists so names are not hard-coded.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-13-alarm-subtag-fallback.md",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": 54,
|
||||||
|
"subject": "Task 1: Worker proto \u2014 watch-list, failover config, AlarmProviderMode",
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 55,
|
||||||
|
"subject": "Task 2: Gateway proto \u2014 provider status, degraded provenance, mode-changed event",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
54
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 56,
|
||||||
|
"subject": "Task 3: Proto round-trip tests for new alarm fields",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
54,
|
||||||
|
55
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 57,
|
||||||
|
"subject": "Task 4: Subtag value-source abstraction + synthesis state machine",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
54
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 58,
|
||||||
|
"subject": "Task 5: SubtagAlarmConsumer over the source seam",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
57
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 59,
|
||||||
|
"subject": "Task 6: COM-backed LmxSubtagAlarmSource",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
57
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 60,
|
||||||
|
"subject": "Task 7: FailoverAlarmConsumer state machine",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
58
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 61,
|
||||||
|
"subject": "Task 8: Synthetic GUID + degraded flag on event sink path",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
55
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 62,
|
||||||
|
"subject": "Task 9: Wire watch-list/failover through AlarmCommandHandler; emit mode-changed",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
58,
|
||||||
|
60,
|
||||||
|
61
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 63,
|
||||||
|
"subject": "Task 10: AlarmsOptions.Fallback + validation",
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 64,
|
||||||
|
"subject": "Task 11: Galaxy Repository alarm-attributes discovery query",
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 65,
|
||||||
|
"subject": "Task 12: Watch-list resolver (GR SQL + config override)",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
54,
|
||||||
|
63,
|
||||||
|
64
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 66,
|
||||||
|
"subject": "Task 13: Metrics \u2014 provider-mode gauge + switch counter",
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 67,
|
||||||
|
"subject": "Task 14: GatewayAlarmMonitor \u2014 arm watch-list, reflect mode, reconcile on switch",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
55,
|
||||||
|
62,
|
||||||
|
65,
|
||||||
|
66
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 68,
|
||||||
|
"subject": "Task 15: Dashboard \u2014 push provider status + UI badge",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
67
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 69,
|
||||||
|
"subject": "Task 16: End-to-end fake-worker failover test",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
67
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 70,
|
||||||
|
"subject": "Task 17: Live subtag smoke test (opt-in)",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
59,
|
||||||
|
62
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 71,
|
||||||
|
"subject": "Task 18: Documentation",
|
||||||
|
"status": "completed",
|
||||||
|
"blockedBy": [
|
||||||
|
67
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-13T13:30:00Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
# Deferred Follow-ups Implementation Plan
|
||||||
|
|
||||||
|
**Date:** 2026-06-14
|
||||||
|
**Status:** Plan only — NOT yet executed. Saved for review.
|
||||||
|
**Context:** After the alarm-subtag-fallback cleanup (merged `5976770`) and its redeploy to
|
||||||
|
windev (10.100.0.48), five items remain deferred. This plan handles all five. They are
|
||||||
|
independent — execute in any order, or cherry-pick. Items D1–D2 are code (branch off `main`);
|
||||||
|
D3 is a dev-rig validation; D4–D5 are ops on the deployed hosts (no code).
|
||||||
|
|
||||||
|
Source of the deferred list: the post-deploy review on 2026-06-14. See also memory
|
||||||
|
`project_deploy_mechanics`, `project_wonder_deployment`, `project_alarm_subtag_fallback`,
|
||||||
|
`project_rig_alarms_object_driven`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D1 — Surface `AlarmProviderSwitchCount` on the dashboard metric list
|
||||||
|
|
||||||
|
**Classification:** small · **Est:** ~10 min · **Where:** Server (net10), build+test on macOS
|
||||||
|
**Why deferred:** the dashboard reads provider *state* from the feed's `ProviderStatus` badge,
|
||||||
|
not the metrics snapshot, so the snapshot field (added in B1) and the OTEL counter were enough.
|
||||||
|
This makes the cumulative switch count visible in the dashboard's metric table too.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs` (`CreateMetricSummaries`, ~line 178–198)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardSnapshotServiceTests.cs` (or the existing snapshot test file)
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. In `CreateMetricSummaries`, add to the `metrics` list (near the other counters):
|
||||||
|
`new("mxgateway.alarms.provider_switches", snapshot.AlarmProviderSwitchCount),`
|
||||||
|
Also consider adding the current mode as a gauge row if useful:
|
||||||
|
the snapshot does not carry the mode int (only the feed does), so DO NOT invent one —
|
||||||
|
only add the switch count, which the snapshot already exposes.
|
||||||
|
2. Add/extend a unit test asserting the summary list contains a
|
||||||
|
`mxgateway.alarms.provider_switches` row equal to the snapshot's `AlarmProviderSwitchCount`
|
||||||
|
after calling `metrics.AlarmProviderSwitched(...)`.
|
||||||
|
3. Verify: `dotnet build src/ZB.MOM.WW.MxGateway.Server` (macOS) and
|
||||||
|
`dotnet test src/ZB.MOM.WW.MxGateway.Tests --filter FullyQualifiedName~DashboardSnapshot`.
|
||||||
|
4. Commit.
|
||||||
|
|
||||||
|
**Rollback:** revert the one-line list addition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D2 — Reproduce and fix the `AmbiguousMatchException` on `GET /`
|
||||||
|
|
||||||
|
**Classification:** standard · **Est:** ~30–45 min (repro + fix) · **Where:** Server (net10)
|
||||||
|
**Why deferred:** observed only in *old* (06/05, Development) logs:
|
||||||
|
`AmbiguousMatchException: The request matched multiple endpoints. Matches: / (/) / (/)`.
|
||||||
|
Unauthenticated `GET /` safely 302s to `/login`; the ambiguity would only throw for an
|
||||||
|
*authenticated* request that reaches routing. Pre-existing and unrelated to the alarm work,
|
||||||
|
but it would surface as a dashboard-home 500. Confirm it still reproduces before fixing.
|
||||||
|
|
||||||
|
**Root-cause candidates (the two endpoints both matching `/`):**
|
||||||
|
- `DashboardHome.razor` (`@page "/"`) mapped by `MapRazorComponents<App>()`
|
||||||
|
(`Dashboard/DashboardEndpointRouteBuilderExtensions.cs:92`).
|
||||||
|
- `MapStaticAssets(...)` (`GatewayApplication.cs:190`) — in .NET 8–10 the static-assets
|
||||||
|
endpoint can register a fingerprinted root that collides with the home page.
|
||||||
|
|
||||||
|
**Step 0 — Reproduce (do this first; if it does NOT repro, downgrade to a documentation note):**
|
||||||
|
1. Log into the dashboard as `multi-role`/`password` (Administrator) at
|
||||||
|
`http://10.100.0.48:5130/login`.
|
||||||
|
2. Navigate to `http://10.100.0.48:5130/` (the "Dashboard" home).
|
||||||
|
3. If it renders the Overview page → not a live bug on this build; record that and stop
|
||||||
|
(the dev error-page throw was a Development-only artifact). If it 500s with
|
||||||
|
`AmbiguousMatchException` → proceed to fix.
|
||||||
|
|
||||||
|
**Fix (only if it reproduces):**
|
||||||
|
- Files: `src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs` (endpoint mapping order/region),
|
||||||
|
`src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs`,
|
||||||
|
`src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor`.
|
||||||
|
- Candidate fixes, in order of preference:
|
||||||
|
1. Inspect the actual two matched endpoint display names at runtime (the exception lists
|
||||||
|
them) — temporarily enable detailed errors or read the stderr `Matches:` block — to learn
|
||||||
|
exactly which two collide.
|
||||||
|
2. If `MapStaticAssets` owns the second `/`: it must run, but confirm it isn't double-mapping
|
||||||
|
the home route; if it is, move `MapStaticAssets` ordering or scope it so it doesn't claim
|
||||||
|
the literal `/` content path. (Do NOT remove `MapStaticAssets` — Blazor needs it.)
|
||||||
|
3. If two component pages both declare `@page "/"`: give the non-home one a distinct route.
|
||||||
|
4. Last resort: give `DashboardHome` an explicit route (e.g. `@page "/overview"`) and add a
|
||||||
|
trivial `MapGet("/", () => Results.Redirect("/overview"))` with `.RequireAuthorization(...)`.
|
||||||
|
- Add a test that exercises the root route once resolved (a WebApplicationFactory-based
|
||||||
|
integration test asserting authenticated `GET /` returns 200, if the dashboard test harness
|
||||||
|
supports auth; otherwise a routing-uniqueness assertion).
|
||||||
|
- Verify: `dotnet build src/ZB.MOM.WW.MxGateway.Server`; re-run the authenticated `GET /` repro.
|
||||||
|
|
||||||
|
**Rollback:** revert the routing change; the home page returns to its prior (ambiguous) state.
|
||||||
|
|
||||||
|
**Note:** this fix ships only via a Server redeploy (D-deploy applies, see D5/windev redeploy
|
||||||
|
procedure in `project_deploy_mechanics`). Decide whether to bundle it with the next redeploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D3 — Validate the failover path end-to-end on the dev rig (ForceSubtag)
|
||||||
|
|
||||||
|
**Classification:** standard (validation, no production code) · **Est:** ~30–45 min · **Where:** dev rig / windev
|
||||||
|
**Why deferred:** `provider_mode 1` (healthy alarmmgr) is verified in production, but the actual
|
||||||
|
alarmmgr→subtag **failover**, the **degraded badge**, the synthesized subtag alarms, and the
|
||||||
|
`provider_switches{from,to,reason}` counter (with the new bounded `failover`/`failback`/`unknown`
|
||||||
|
tag) have never fired live — a healthy system never switches, and the rig's alarms can't be made
|
||||||
|
to fail COM on demand. Use the explicit `ForceSubtag` config mode to exercise the degraded path
|
||||||
|
deterministically without an alarmmgr fault.
|
||||||
|
|
||||||
|
**Approach:** run a *temporary* gateway instance (NOT the production service) in `ForceSubtag`
|
||||||
|
mode against the dev Galaxy, drive it with the operator/IDE alarm toggle, and confirm the
|
||||||
|
degraded surface. Do this on windev in a throwaway run so production stays on `Auto`.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. On windev, from a build worktree at `main`, run the Server locally (or a second instance on
|
||||||
|
alt ports) with `MxGateway:Alarms:Fallback:Mode=ForceSubtag` (env
|
||||||
|
`MxGateway__Alarms__Fallback__Mode=ForceSubtag`), pointed at the dev Galaxy
|
||||||
|
(`\\DESKTOP-6JL3KKO\Galaxy!DEV`). Use distinct Kestrel ports to avoid clashing with the
|
||||||
|
production service on 5120/5130.
|
||||||
|
2. Subscribe an alarm client (or open the dashboard alarms page for that instance) and confirm:
|
||||||
|
- The provider badge shows **"Subtag monitoring (degraded)"** (amber `bg-warning`).
|
||||||
|
- `curl .../metrics` shows `mxgateway_alarms_provider_mode 2`.
|
||||||
|
- Active alarms appear with `degraded=true` and a synthetic (MD5-derived) GUID, with the
|
||||||
|
reference shape `Galaxy!<realArea>.<object>.<attr>` (e.g. `Galaxy!TestArea.TestMachine_001.TestAlarm001`).
|
||||||
|
3. Drive a transition: have the operator/IDE toggle a `TestMachine_NNN.TestAlarmNNN` true→false
|
||||||
|
(external MXAccess writes are ignored — see `project_rig_alarms_object_driven`); confirm a
|
||||||
|
synthesized Raise then Clear, and an `AckMsg` write via AcknowledgeByName returns 0.
|
||||||
|
4. (Optional, to exercise the switch counter) run in `Auto` and induce a primary fault if a safe
|
||||||
|
way exists; otherwise document that the counter is unit-tested only and `ForceSubtag` covers
|
||||||
|
the degraded surface. Note: `ForceSubtag` may not increment `provider_switches` (no runtime
|
||||||
|
switch) — that counter's live exercise remains the one gap; record it explicitly rather than
|
||||||
|
claiming coverage.
|
||||||
|
5. Tear down the temporary instance. Production service is untouched (stays `Auto`).
|
||||||
|
6. Record results in `project_alarm_subtag_fallback` memory (degraded badge + synthesized
|
||||||
|
subtag alarms now live-validated; switch-counter still unit-test-only if not exercised).
|
||||||
|
|
||||||
|
**Rollback:** none — temporary instance only; nothing in production changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D4 — Prune stale deploy backups on windev
|
||||||
|
|
||||||
|
**Classification:** trivial (ops) · **Est:** ~10 min · **Where:** windev (10.100.0.48), no code
|
||||||
|
**Why deferred:** backups accumulate on every deploy; harmless but cluttering
|
||||||
|
`C:\publish\mxaccessgw\`.
|
||||||
|
|
||||||
|
**Current backups observed (2026-06-14):**
|
||||||
|
- `Server.bak-20260526T143341`, `Server.bak-20260529T090320`, `Server.bak529.20260604T122616`,
|
||||||
|
`Server.bak-theme030-20260605-053056`, `Server.bak-theme031-20260605-083410`,
|
||||||
|
`Server.bak-20260614-prefallback` (today's), `Worker.bak-20260614-prefallback` (today's).
|
||||||
|
- Inside `Server\`: `ZB.MOM.WW.MxGateway.Server.dll.bak-20260604-loginfix`,
|
||||||
|
`appsettings.json.bak-20260604-{glauth35,ldapkeys}`.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **Keep** `Server.bak-20260614-prefallback` and `Worker.bak-20260614-prefallback` — the
|
||||||
|
immediate rollback for the current deploy. Keep until the new build has soaked (e.g. a few
|
||||||
|
days / one business cycle).
|
||||||
|
2. Delete the clearly-superseded older backups: the `Server.bak-2026052*`, `Server.bak529.*`,
|
||||||
|
and `Server.bak-theme03*` dirs. Confirm each is a Server dir (not something live) before
|
||||||
|
`rmdir /s /q`.
|
||||||
|
3. Optionally remove the in-`Server` `.bak-*` sidecar files (`*.dll.bak-*`,
|
||||||
|
`appsettings.json.bak-*`) — but FIRST confirm the live `appsettings.json` is correct (it is,
|
||||||
|
post-deploy) so the `.bak` ldap/glauth copies aren't the only record of a needed value.
|
||||||
|
4. Verify the service is unaffected: `Get-Service MxAccessGw` Running, `/health/ready` 200.
|
||||||
|
|
||||||
|
**Rollback:** none needed (deletions only; the current-deploy backup is retained). If unsure
|
||||||
|
about any single dir, skip it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D5 — Redeploy the wonder host to bring it to parity
|
||||||
|
|
||||||
|
**Classification:** high-risk (separate production-ish box, fiddly access, divergent build) · **Est:** ~1–2 h · **Where:** wonder-app-vd03.zmr.zimmer.com (10.220.157.247)
|
||||||
|
**Why deferred:** wonder still runs the pre-feature build — it lacks the entire alarm-fallback
|
||||||
|
feature and the cleanup. Bring it to `5976770` if parity is wanted. This box is materially
|
||||||
|
different from windev (read `project_wonder_deployment` fully before starting).
|
||||||
|
|
||||||
|
**Key differences from windev (do NOT reuse the windev recipe blindly):**
|
||||||
|
- Access: no usable `ssh host "cmd"` exec. Port 22 OpenSSH (PowerShell) was observed CLOSED
|
||||||
|
2026-05-26; the reliable path is **servecli on port 2222** (cmd.exe PTY + SFTP only) using the
|
||||||
|
base64-`-EncodedCommand` pattern in `project_wonder_deployment` / `~/Desktop/servecli/instructions.md`.
|
||||||
|
Re-test port 22 first; if up, prefer it.
|
||||||
|
- Build shape: wonder runs a **self-contained single-file win-x64** Server (~118 MB), renamed to
|
||||||
|
`MxGateway.Server.exe` for the NSSM path `E:\ApiInstall\MxGateway\Server\MxGateway.Server.exe`.
|
||||||
|
So publish must be `-r win-x64 --self-contained -p:PublishSingleFile=true` (NOT the
|
||||||
|
framework-dependent windev recipe), then rename the exe to `MxGateway.Server.exe`.
|
||||||
|
- Static assets quirk: ship BOTH `ZB.MOM.WW.MxGateway.Server.staticwebassets.endpoints.json` and
|
||||||
|
`MxGateway.Server.staticwebassets.endpoints.json` next to the exe (the ZB-named one is used).
|
||||||
|
- Config: wonder's `appsettings.json` carries HTTP-switched Kestrel (`http://0.0.0.0:5130`),
|
||||||
|
`Dashboard.GroupToRole` = `{ SCADA-Admins/Designers/Deploy-All → Admin }`,
|
||||||
|
`Dashboard.RequireHttpsCookie:false`, LDAP base `dc=scadalink,dc=local`. **Preserve wonder's
|
||||||
|
appsettings.json** exactly like windev (do not ship the repo default). Note: wonder's
|
||||||
|
GroupToRole values are `"Admin"` — VERIFY the current build's validator accepts `"Admin"` (the
|
||||||
|
windev crash showed it requires `'Administrator'`/`'Viewer'`). **If the validator rejects
|
||||||
|
`"Admin"`, wonder's appsettings GroupToRole values must be updated to `Administrator` BEFORE/with
|
||||||
|
the deploy or the new build will crash-loop.** This is the single biggest risk — resolve it first.
|
||||||
|
- Worker: wonder's x86 worker is the original; the alarm-fallback adds new subscribe fields. To
|
||||||
|
use subtag fallback on wonder, the **x86 worker must also be redeployed** (publish x86, rename
|
||||||
|
if needed). If only the Server is updated, confirm worker IPC protobuf compat (contracts changed
|
||||||
|
for alarms — a stale worker may not understand the extended SubscribeAlarms). Safer to deploy
|
||||||
|
both Server and Worker together.
|
||||||
|
|
||||||
|
**Steps (high level — expand against the runbook before executing):**
|
||||||
|
1. Confirm parity is actually wanted on wonder, and whether the dashboard (disabled there) and
|
||||||
|
the alarm monitor are even in scope for that box. If alarms/dashboard are off on wonder, this
|
||||||
|
may reduce to "Server binary parity only."
|
||||||
|
2. Resolve the **GroupToRole value compatibility** question (Admin vs Administrator) — inspect
|
||||||
|
the current build's `GatewayOptionsValidator` and decide whether to patch wonder's appsettings.
|
||||||
|
3. Build self-contained Server (`-r win-x64 --self-contained -p:PublishSingleFile=true`) and x86
|
||||||
|
Worker from `main` (`5976770`).
|
||||||
|
4. Transfer via SFTP (servecli) to a staging dir on `E:\ApiInstall\MxGateway\`.
|
||||||
|
5. Stop `MxAccessGw`; back up `Server` → `Server.bak.<ts>` and `Worker` → `Worker.bak.<ts>`;
|
||||||
|
swap in the new build; **restore wonder's `appsettings.json`** (+ patched GroupToRole if
|
||||||
|
needed); ensure both staticwebassets manifests are present; rename Server exe to
|
||||||
|
`MxGateway.Server.exe`.
|
||||||
|
6. Start `MxAccessGw` (no dependents). Verify stderr line count does not grow (no crash loop),
|
||||||
|
`curl -k https://...:5130/health/live` (or HTTP per current config) Healthy, gRPC 5120 up.
|
||||||
|
7. Verify the new metric is present if the metrics endpoint is exposed there.
|
||||||
|
|
||||||
|
**Rollback (documented in memory):** stop service,
|
||||||
|
`Move-Item Server Server.failed; Move-Item Server.bak.<ts> Server`, restore appsettings from the
|
||||||
|
backup, start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. **D2 repro** (5 min — just log in and hit `/`): decides whether D2 is a real fix or a no-op.
|
||||||
|
2. **D1** (small code) + **D2 fix if needed** — bundle into one Server branch; they ship together
|
||||||
|
on the next Server redeploy.
|
||||||
|
3. **D4** (prune windev backups) — quick ops, independent.
|
||||||
|
4. **D3** (ForceSubtag validation) — exercises the degraded surface; do before/after the D1+D2
|
||||||
|
redeploy so you also confirm the new Server build is healthy.
|
||||||
|
5. **D5** (wonder) — largest and riskiest; do last, only if parity is wanted, and resolve the
|
||||||
|
GroupToRole-value compatibility question first.
|
||||||
|
|
||||||
|
## Execution note
|
||||||
|
|
||||||
|
This plan is intentionally NOT executed. When ready, execute on a branch off `main`
|
||||||
|
(`feat/deferred-followups` or per-item branches) — do not commit to `main` directly. D1/D2 need a
|
||||||
|
Server redeploy to take effect; D4/D5 are host operations; D3 touches nothing permanent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D2 — Resolution (2026-06-14)
|
||||||
|
|
||||||
|
Static source determination on build `5976770` (runtime repro out of scope). What was checked:
|
||||||
|
|
||||||
|
- **Razor `@page "/"` count:** exactly ONE — `Dashboard/Components/Pages/DashboardHome.razor`.
|
||||||
|
All other pages declare distinct routes (`/login`, `/sessions`, `/galaxy`, `/browse`,
|
||||||
|
`/apikeys`, `/workers`, `/events`, `/alarms`, `/settings`, `/sessions/{SessionId}`).
|
||||||
|
- **No root index.html:** `src/ZB.MOM.WW.MxGateway.Server/wwwroot/` contains only `css/` and
|
||||||
|
`lib/` subdirectories; no `index.html` anywhere under `wwwroot/`.
|
||||||
|
- **No `UseDefaultFiles` / no `MapFallback`:** neither appears anywhere in the Server project.
|
||||||
|
- **`MapStaticAssets` mapped once** (`GatewayApplication.cs:190`) and `UseStaticFiles()` once
|
||||||
|
(`:41`); the static-assets manifest serves fingerprinted CSS/JS assets, not a literal `/`.
|
||||||
|
- **No `MapGet("/")`:** the dashboard endpoint builder
|
||||||
|
(`Dashboard/DashboardEndpointRouteBuilderExtensions.cs`) maps only `/auth/login`, `/logout`,
|
||||||
|
`/denied`, `/hubs/{snapshot,alarms,events}`, `/hubs/token`, then `MapRazorComponents<App>()`.
|
||||||
|
None use pattern `/`.
|
||||||
|
- **`MapZbHealth` / `MapZbMetrics`** come from the external `ZB.MOM.WW.Health` shared library
|
||||||
|
(not in this repo) and map health/metrics paths, not `/`.
|
||||||
|
|
||||||
|
**Root cause of the 2026-06-05 log:** `code-reviews/Server/findings.md` (re-review at `42b0037`,
|
||||||
|
2026-05-24) records that commit `de7639a` **removed the legacy `MapGet("/", ...)` redirect that
|
||||||
|
was colliding with the Blazor `@page "/"` (a real 500)**. That legacy registration was the source
|
||||||
|
of the `AmbiguousMatchException`. It is gone on the current build, so the second `/` endpoint no
|
||||||
|
longer exists.
|
||||||
|
|
||||||
|
**Conclusion:** No duplicate `/` endpoint on build `5976770`. The AmbiguousMatchException is not
|
||||||
|
reproducible from source — it was a stale Development-only artifact from before `de7639a` reached
|
||||||
|
the deployed instance. **No source change made** (no-op).
|
||||||
|
|
||||||
|
**Residual:** A 100% confirmation still requires an authenticated runtime `GET /` against a
|
||||||
|
deployed instance (the only path that exercises routing past the unauthenticated 302-to-`/login`).
|
||||||
|
Recommend a spot-check of authenticated `GET /` after the next Server redeploy; if it returns 200
|
||||||
|
(not 500), this item can be fully closed.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# ForceSubtag Mode Fix Implementation Plan
|
||||||
|
|
||||||
|
> Fixes the two defects surfaced by the D3 live validation (2026-06-15): forced-subtag
|
||||||
|
> doesn't actually run subtag (#1), and the gateway never reflects a forced provider mode
|
||||||
|
> into the gauge/feed (#2).
|
||||||
|
|
||||||
|
**Goal:** Make `MxGateway:Alarms:Fallback:Mode=ForceSubtag` actually serve degraded subtag
|
||||||
|
alarms AND have the gateway advertise `provider_mode=2` / degraded badge.
|
||||||
|
|
||||||
|
**Evidence:** Live ForceSubtag run returned alarmmgr-sourced active alarms (May raise
|
||||||
|
timestamps, `degraded=false`) and `provider_mode` stuck at 1, despite ForceSubtag binding
|
||||||
|
(proven by invalid-value crash) and the deployed worker containing the ForcedMode routing
|
||||||
|
(`3f5e5fc` ∈ `5976770`, worker dated 2026-06-14).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defect #2 (CONFIRMED code defect) — gateway never reflects forced mode
|
||||||
|
|
||||||
|
**Root cause:** `GatewayAlarmMonitor.RunMonitorAsync` hard-baselines `_providerMode=Alarmmgr`
|
||||||
|
and sets the gauge to 1, ignoring `_options.Fallback.Mode`. `_providerMode` only advances on a
|
||||||
|
worker `OnAlarmProviderModeChanged` event, which is raised ONLY by `FailoverAlarmConsumer`
|
||||||
|
(Auto mode). Forced-subtag builds `SubtagAlarmConsumer` directly → no event → gauge/feed stay
|
||||||
|
Alarmmgr forever.
|
||||||
|
|
||||||
|
### Task 2: Seed provider mode from configured forced mode (gateway, net10)
|
||||||
|
**Classification:** small · **Parallelizable with:** none (precedes the diagnostic build)
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (`RunMonitorAsync`, ~lines 160-172; add a permanent observability log in `SubscribeAlarmsAsync` ~line 257)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs`
|
||||||
|
|
||||||
|
**Change:** in `RunMonitorAsync`, compute `initialMode = MapForcedMode(_options.Fallback.Mode)`
|
||||||
|
mapped as `Subtag→Subtag`, `Alarmmgr→Alarmmgr`, `Unspecified→Alarmmgr` (Auto starts on the
|
||||||
|
alarmmgr primary). Set `_providerMode/_providerDegraded(=Subtag)/_providerReason/_providerSince`
|
||||||
|
and `_metrics.SetAlarmProviderMode(ModeToInt(initialMode))` — using the existing no-switch gauge
|
||||||
|
seam so `provider_switches` does NOT increment. Add a log in `SubscribeAlarmsAsync`:
|
||||||
|
`"Alarm subscribe forcedMode={ForcedMode} (configMode={ConfigMode}) watchList={Count}"`.
|
||||||
|
|
||||||
|
**Tests (fake-worker, no MXAccess):** with `Fallback:Mode=ForceSubtag` assert (a) first
|
||||||
|
`StreamAlarms` message is `ProviderStatus{Mode=Subtag,Degraded=true}`; (b) gauge==2; (c)
|
||||||
|
`provider_switches`==0. Add `ForceAlarmManager`→gauge 1 and `Auto`→gauge 1 baseline cases.
|
||||||
|
|
||||||
|
**Verify:** `dotnet build src/ZB.MOM.WW.MxGateway.Server`; `dotnet test src/ZB.MOM.WW.MxGateway.Tests --filter FullyQualifiedName~GatewayAlarmMonitorProviderMode`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defect #1 (runtime bug — needs diagnosis) — ForceSubtag runs alarmmgr
|
||||||
|
|
||||||
|
The HEAD source path (gateway `MapForcedMode` → `SubscribeAlarmsCommand.ForcedMode=Subtag` →
|
||||||
|
IPC → worker `AlarmCommandHandler.BuildConsumer` → `SubtagAlarmConsumer`) is statically correct,
|
||||||
|
yet the runtime ran alarmmgr. A runtime diagnostic must locate where `forcedMode` becomes
|
||||||
|
`Unspecified`.
|
||||||
|
|
||||||
|
### Task 1: Add worker BuildConsumer observability log (worker, net48 x86)
|
||||||
|
**Classification:** small (net48 — no init-only props) · **Parallelizable with:** Task 2
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs` (`BuildConsumer`, ~line 206)
|
||||||
|
|
||||||
|
**Change:** log at entry of `BuildConsumer`:
|
||||||
|
`"BuildConsumer forcedMode={ForcedMode} watchList={Count}"`. This is permanent observability.
|
||||||
|
|
||||||
|
**Verify (windev only):** `dotnet build src/ZB.MOM.WW.MxGateway.Worker -p:Platform=x86`.
|
||||||
|
|
||||||
|
### Task 3: Diagnostic run — capture the real forcedMode
|
||||||
|
**Classification:** standard (ops/diagnostic on windev) · **Parallelizable with:** none
|
||||||
|
**Steps:** rebuild worker (x86) + server from the branch, stand up the D3-style temp ForceSubtag
|
||||||
|
instance (alt ports 5122/5132, isolated `.D3` worker name, WMI-detached, Http2, Development env
|
||||||
|
for file logs), trigger the always-on monitor, and read the two new log lines:
|
||||||
|
- Gateway `Alarm subscribe forcedMode=...` — what the gateway SENDS.
|
||||||
|
- Worker `BuildConsumer forcedMode=...` — what the worker RECEIVES.
|
||||||
|
|
||||||
|
Decision matrix:
|
||||||
|
- Gateway logs `Subtag`, worker logs `Unspecified` → IPC/serialization drops the enum → fix the
|
||||||
|
send/translation path (likely a worker-proto vs gateway-proto `SubscribeAlarmsCommand` mismatch
|
||||||
|
in the named-pipe envelope).
|
||||||
|
- Worker logs `Subtag` but alarmmgr data appears → bug in `BuildStandby`/`SubtagAlarmConsumer`/
|
||||||
|
`AlarmDispatcher` snapshot path.
|
||||||
|
- Gateway logs `Unspecified` despite config ForceSubtag → gateway config/options read.
|
||||||
|
|
||||||
|
### Task 4: Fix #1 per Task 3 diagnosis
|
||||||
|
**Classification:** standard/high-risk (depends on where the defect is) · the exact change is
|
||||||
|
determined by Task 3. Add a regression test at the identified layer (worker unit test for
|
||||||
|
BuildConsumer→SubtagAlarmConsumer, or a contract/IPC round-trip test if the enum is dropped).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final: build, test, redeploy, re-validate
|
||||||
|
- Build gateway (macOS) + worker (windev x86); run gateway + worker test suites.
|
||||||
|
- Redeploy windev Server (and Worker if Task 4 changed it) per `project_deploy_mechanics`,
|
||||||
|
preserving appsettings.
|
||||||
|
- Re-validate live with the temp ForceSubtag instance: active alarms `degraded=true` /
|
||||||
|
`source_provider=SUBTAG` with recent timestamps, `provider_mode 2`. Tear down temp instance;
|
||||||
|
production untouched.
|
||||||
|
|
||||||
|
## Execution note
|
||||||
|
Branch off `main`. #2 is the clean confirmed fix; #1 is diagnose-then-fix. Net48 worker
|
||||||
|
constraints apply (no init-only props/positional records). Do NOT increment `provider_switches`
|
||||||
|
on an initial forced-mode set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolution (2026-06-15)
|
||||||
|
|
||||||
|
**#1 was NOT a bug — it was a grpcurl proto-mismatch artifact.** End-to-end instrumentation
|
||||||
|
proved: the gateway sends `forcedMode=Subtag`, the worker's `BuildConsumer` builds the
|
||||||
|
`SubtagAlarmConsumer`, and the worker `QueryActive` + gateway `ApplyReconcile`/`StreamAlarms`
|
||||||
|
all carry `degraded=true` / `source_provider=SUBTAG`. The original "degraded=false" observation
|
||||||
|
came from running grpcurl against the host checkout's proto (branch `feat/lazy-browse-children`),
|
||||||
|
which predates the feature and lacks the `degraded`/`source_provider` fields — grpcurl silently
|
||||||
|
dropped them. With the matching proto, every active alarm shows `degraded=true`. No code change.
|
||||||
|
Tasks 1/3/4 (worker diagnostics + #1 fix) were dropped; the temporary diagnostics were reverted.
|
||||||
|
|
||||||
|
**#2 was a real defect — fixed.** Gateway now seeds `_providerMode`/gauge/feed from the configured
|
||||||
|
forced mode (`fix: gateway reflects configured forced provider mode`). Verified live:
|
||||||
|
`provider_mode=2`, first `ProviderStatus` = `Mode=Subtag, degraded=true,
|
||||||
|
reason="Forced subtag mode (configuration)"`. Auto mode unchanged → windev production (Auto)
|
||||||
|
unaffected; no redeploy required. Gateway tests: 163 passed.
|
||||||
+61
@@ -143,6 +143,67 @@ session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
|
|||||||
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
|
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
|
||||||
for the alarm subsystem.
|
for the alarm subsystem.
|
||||||
|
|
||||||
|
### Alarm providers and failover
|
||||||
|
|
||||||
|
The alarm feed has two providers, both implemented worker-side:
|
||||||
|
|
||||||
|
- **Alarm manager (primary):** `WnWrapAlarmConsumer` polls
|
||||||
|
`wwAlarmConsumerClass.GetXmlCurrentAlarms2` on the worker STA. This is the
|
||||||
|
authoritative native source.
|
||||||
|
- **Subtag monitoring (standby):** `SubtagAlarmConsumer` advises each alarm
|
||||||
|
attribute's subtags (`.active`, `.acked`, optionally `.priority`) via the
|
||||||
|
existing `AddItem`/`Advise` pipeline through `LmxSubtagAlarmSource` and
|
||||||
|
synthesizes alarm transitions with `SubtagAlarmStateMachine`. This is a
|
||||||
|
non-parity, lower-fidelity source — synthetic GUIDs, no native raise
|
||||||
|
timestamps, narrower fields.
|
||||||
|
|
||||||
|
`FailoverAlarmConsumer` wraps both and owns the state machine:
|
||||||
|
|
||||||
|
- **Auto-failover:** after `ConsecutiveFailureThreshold` (default 3)
|
||||||
|
consecutive wnwrap COM failures — `Subscribe` or `PollOnce` throws or
|
||||||
|
returns a failure HRESULT — it activates the standby. The standby is armed
|
||||||
|
(subscribed and adviseing) from the start so its state is warm at the moment
|
||||||
|
of switch.
|
||||||
|
- **Auto-failback:** while degraded, every `FailbackProbeIntervalSeconds`
|
||||||
|
(default 30) it re-probes the still-subscribed primary. After
|
||||||
|
`FailbackStableProbes` (default 3) consecutive clean polls it switches back
|
||||||
|
to the alarm manager.
|
||||||
|
- **On every switch:** the consumer snapshots the now-active provider and
|
||||||
|
emits `OnAlarmProviderModeChangedEvent` so the gateway can reconcile its
|
||||||
|
cache without a raise/clear storm.
|
||||||
|
|
||||||
|
Synthesis is worker-side. This preserves the parity rule — the gateway
|
||||||
|
forwards only events the worker emits and never synthesizes transitions
|
||||||
|
itself. The synthesis rules are documented in
|
||||||
|
`docs/AlarmClientDiscovery.md`.
|
||||||
|
|
||||||
|
**Acknowledge in subtag mode:** the ack-by-name path writes the operator
|
||||||
|
comment to the alarm attribute's ack-comment subtag. The write performs the
|
||||||
|
ack. If the attribute has no writable ack-comment subtag configured, the RPC
|
||||||
|
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is
|
||||||
|
used as before.
|
||||||
|
|
||||||
|
**Degraded state visibility:** every subtag-mode transition carries
|
||||||
|
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the
|
||||||
|
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` proto fields. The
|
||||||
|
`AlarmFeedMessage` feed emits an `AlarmProviderStatus` message (the
|
||||||
|
`provider_status` oneof case) on stream open and on every switch. The
|
||||||
|
dashboard shows a Bootstrap badge: green ("Alarm Manager") when healthy, amber
|
||||||
|
("Subtag monitoring (degraded)") on an unexpected failover, and cyan ("Subtag
|
||||||
|
monitoring (forced)") when subtag mode is the configured `Fallback:Mode=ForceSubtag`
|
||||||
|
— the latter distinguished by the well-known `AlarmProviderStatus.reason`
|
||||||
|
(`AlarmProviderReasons.ForcedSubtag`) so an intentional configuration is not shown
|
||||||
|
as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr,
|
||||||
|
2 = subtag) and `mxgateway.alarms.provider_switches` counter.
|
||||||
|
|
||||||
|
Forced modes are available via `MxGateway:Alarms:Fallback:Mode`:
|
||||||
|
`ForceAlarmManager` disables failover; `ForceSubtag` forces the standby
|
||||||
|
on from startup; `Auto` (default) enables failover and failback. Watch-list
|
||||||
|
discovery for the subtag provider uses Galaxy Repository SQL with config
|
||||||
|
overrides. See `docs/GatewayConfiguration.md` for the full `Fallback` option
|
||||||
|
block and `docs/AlarmClientDiscovery.md` for synthesis rules and fidelity
|
||||||
|
limitations.
|
||||||
|
|
||||||
Dashboard authentication is LDAP-backed (distinct from the API-key model on
|
Dashboard authentication is LDAP-backed (distinct from the API-key model on
|
||||||
the gRPC API). `/login` accepts username and password in a form body, binds
|
the gRPC API). `/login` accepts username and password in a form body, binds
|
||||||
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
|
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
# GLAuth — LDAP authn reference for mxaccessgw
|
# GLAuth — LDAP authn reference for mxaccessgw
|
||||||
|
|
||||||
GLAuth is a lightweight LDAP server installed on this dev box at
|
> **UPDATED 2026-06-04 — mxaccessgw no longer uses a per-box GLAuth at `C:\publish\glauth`.
|
||||||
`C:\publish\glauth\` and run as a Windows service via NSSM. It already
|
> Dev/test LDAP is now the SHARED GLAuth on `10.100.0.35:3893` (`dc=zb,dc=local`);
|
||||||
backs the LmxOpcUa OPC UA server's UserName-token authn and the LmxOpcUa
|
> the single source of truth is `scadaproj/infra/glauth/` (`config.toml` + `README`).
|
||||||
Admin UI's cookie login; this doc captures everything mxaccessgw needs
|
> The localhost/NSSM/`glauth.cfg` procedures below are RETIRED, kept for reference/rollback.**
|
||||||
to consume the same directory so a single set of dev credentials covers
|
|
||||||
both stacks.
|
|
||||||
|
|
||||||
The authoritative copy of LmxOpcUa's reference lives at
|
GLAuth is a lightweight LDAP server. It already backs all three sister apps (MxAccessGateway,
|
||||||
`C:\publish\glauth\auth.md`. This doc is a redistilled view tailored to
|
OtOpcUa, ScadaBridge) through a **shared container** (`zb-shared-glauth`) running on the Linux
|
||||||
mxaccessgw — what users + groups are already provisioned, how to bind
|
docker host at **`10.100.0.35:3893`**. This doc captures everything mxaccessgw needs to consume
|
||||||
against them, and what's needed to add a gw-specific role.
|
that directory so a single set of dev credentials covers all stacks.
|
||||||
|
|
||||||
|
~~GLAuth is installed on this dev box at `C:\publish\glauth\` and run as a Windows service via
|
||||||
|
NSSM.~~ *(RETIRED — the per-box Windows service has been stopped and set to Manual startup;
|
||||||
|
kept only as a rollback option. Do not edit or restart it for new work.)*
|
||||||
|
|
||||||
|
The single source of truth for the shared GLAuth is
|
||||||
|
**`~/Desktop/scadaproj/infra/glauth/config.toml`** (deploy/verify runbook:
|
||||||
|
`scadaproj/infra/glauth/README.md`). This doc is a redistilled view tailored to mxaccessgw —
|
||||||
|
what users + groups are provisioned, how to bind against them, and what's needed to add a
|
||||||
|
gw-specific role.
|
||||||
|
|
||||||
## Connection details
|
## Connection details
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Protocol | LDAP (unencrypted) |
|
| Protocol | LDAP (unencrypted) |
|
||||||
| Host | `localhost` |
|
| Host | **`10.100.0.35`** (shared docker host — ~~`localhost`~~ retired) |
|
||||||
| Port | `3893` |
|
| Port | `3893` |
|
||||||
| LDAPS | disabled in dev (set `[ldaps]` block to enable) |
|
| LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
|
||||||
| Base DN | `dc=lmxopcua,dc=local` |
|
| Base DN | `dc=zb,dc=local` |
|
||||||
| Bind DN format | `cn={username},dc=lmxopcua,dc=local` |
|
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
||||||
| Group OU | `ou=<groupname>,ou=groups,dc=lmxopcua,dc=local` |
|
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
|
||||||
|
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
||||||
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
||||||
|
|
||||||
## Pre-existing groups (LmxOpcUa role taxonomy)
|
## Pre-existing groups (LmxOpcUa role taxonomy)
|
||||||
@@ -33,11 +42,11 @@ LmxOpcUa write rights doesn't need a second account for the gw.
|
|||||||
|
|
||||||
| Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping |
|
| Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) |
|
| ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=zb,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) |
|
||||||
| WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) |
|
| WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=zb,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) |
|
||||||
| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=lmxopcua,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
|
| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=zb,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
|
||||||
| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=lmxopcua,dc=local` | Write Configure attrs | `WriteSecured` (Configure) |
|
| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=zb,dc=local` | Write Configure attrs | `WriteSecured` (Configure) |
|
||||||
| AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=lmxopcua,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added |
|
| AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=zb,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added |
|
||||||
|
|
||||||
**A user can be in multiple groups** — `othergroups = [...]` in the
|
**A user can be in multiple groups** — `othergroups = [...]` in the
|
||||||
config is a list. `admin` is the canonical example (in every role
|
config is a list. `admin` is the canonical example (in every role
|
||||||
@@ -59,20 +68,26 @@ For mxaccessgw dev, `admin` covers every gw-side capability test;
|
|||||||
`readonly` is the right "negative" case for proving Browse-OK /
|
`readonly` is the right "negative" case for proving Browse-OK /
|
||||||
Write-denied.
|
Write-denied.
|
||||||
|
|
||||||
The gateway dashboard adds one role beyond this LmxOpcUa taxonomy:
|
The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
|
||||||
`GwAdmin`. `LdapOptions.RequiredGroup` defaults to `GwAdmin`, so the
|
`GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
|
||||||
dashboard login and `DashboardLdapLiveTests` require `admin` to be a
|
These are already provisioned in the shared `scadaproj/infra/glauth/config.toml`.
|
||||||
member of a `GwAdmin` group. `GwAdmin` is **not** in the baseline
|
The dashboard test users are **`multi-role`/`password`** (Administrator) and
|
||||||
GLAuth config — it must be provisioned before dashboard authn or the
|
**`gw-viewer`/`password`** (Viewer). `LdapOptions.RequiredGroup` defaults to `GwAdmin`.
|
||||||
LDAP live tests work. See [Provisioning the GwAdmin
|
See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for the
|
||||||
group](#provisioning-the-gwadmin-group) below.
|
(now-retired) per-box procedure and for the shared-config equivalent.
|
||||||
|
|
||||||
|
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
|
||||||
|
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
|
||||||
|
> maps to `Viewer`. This is a pure value rename via
|
||||||
|
> `MxGateway:Dashboard:GroupToRole` — same operations are authorized. (This
|
||||||
|
> dashboard role is distinct from the lowercase gRPC `admin` *API-key scope*.)
|
||||||
|
|
||||||
## Two bind patterns
|
## Two bind patterns
|
||||||
|
|
||||||
### 1. Direct bind (simplest)
|
### 1. Direct bind (simplest)
|
||||||
|
|
||||||
```
|
```
|
||||||
DN: cn=admin,dc=lmxopcua,dc=local
|
DN: cn=admin,dc=zb,dc=local
|
||||||
Password: admin123
|
Password: admin123
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,9 +99,9 @@ by `sAMAccountName`, not `cn`. Use this only for dev convenience.
|
|||||||
### 2. Bind-then-search (production-grade)
|
### 2. Bind-then-search (production-grade)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Bind as the service account (cn=serviceaccount,dc=lmxopcua,dc=local
|
1. Bind as the service account (cn=serviceaccount,dc=zb,dc=local
|
||||||
/ serviceaccount123).
|
/ serviceaccount123).
|
||||||
2. Search under dc=lmxopcua,dc=local with filter
|
2. Search under dc=zb,dc=local with filter
|
||||||
(uid=<entered-username>) — or any attribute the deployment
|
(uid=<entered-username>) — or any attribute the deployment
|
||||||
identifies users by. GLAuth populates uid + cn.
|
identifies users by. GLAuth populates uid + cn.
|
||||||
3. Read the returned entry's DN + memberOf list (groups).
|
3. Read the returned entry's DN + memberOf list (groups).
|
||||||
@@ -112,12 +127,12 @@ record:
|
|||||||
```yaml
|
```yaml
|
||||||
ldap:
|
ldap:
|
||||||
enabled: true
|
enabled: true
|
||||||
server: localhost
|
server: 10.100.0.35 # shared GLAuth on docker host (was localhost)
|
||||||
port: 3893
|
port: 3893
|
||||||
useTls: false
|
useTls: false
|
||||||
allowInsecureLdap: true # dev only
|
allowInsecureLdap: true # dev only
|
||||||
searchBase: "dc=lmxopcua,dc=local"
|
searchBase: "dc=zb,dc=local"
|
||||||
serviceAccountDn: "cn=serviceaccount,dc=lmxopcua,dc=local"
|
serviceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||||
serviceAccountPassword: "serviceaccount123"
|
serviceAccountPassword: "serviceaccount123"
|
||||||
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
|
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
|
||||||
displayNameAttribute: "cn"
|
displayNameAttribute: "cn"
|
||||||
@@ -131,19 +146,35 @@ ldap:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`groupAttribute` returns full DNs like
|
`groupAttribute` returns full DNs like
|
||||||
`ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local` — the authenticator
|
`ou=ReadOnly,ou=groups,dc=zb,dc=local` — the authenticator
|
||||||
should strip the leading `ou=` (or `cn=` against AD) RDN value and
|
should strip the leading `ou=` (or `cn=` against AD) RDN value and
|
||||||
look that up in `groupToRole`.
|
look that up in `groupToRole`.
|
||||||
|
|
||||||
## Provisioning the GwAdmin group
|
## Provisioning the GwAdmin group
|
||||||
|
|
||||||
|
> **UPDATED 2026-06-04 — RETIRED per-box procedure.** `GwAdmin` (gid 5610) and `GwReader`
|
||||||
|
> (gid 5611) are already present in the shared GLAuth. To add or modify users/groups,
|
||||||
|
> edit **`~/Desktop/scadaproj/infra/glauth/config.toml`** on host `10.100.0.35` and run:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> cd ~/Desktop/scadaproj/infra/glauth
|
||||||
|
> docker compose up -d --force-recreate
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> The per-box `C:\publish\glauth\glauth.cfg` + NSSM procedure below is kept for
|
||||||
|
> rollback reference only — do not use it for new provisioning.
|
||||||
|
|
||||||
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
||||||
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
||||||
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
||||||
`admin` until a `GwAdmin` group exists and `admin` is a member.
|
logins unless the user is a member of `GwAdmin`.
|
||||||
GLAuth's baseline config ships only the five LmxOpcUa role groups, so
|
The `GwAdmin` (gid 5610) and `GwReader` (gid 5611) groups already exist in the shared
|
||||||
`GwAdmin` must be added to GLAuth rather than run from a separate LDAP
|
config at `scadaproj/infra/glauth/config.toml`. Dashboard test users are
|
||||||
server:
|
`multi-role`/`password` (Administrator) and `gw-viewer`/`password` (Viewer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**RETIRED — per-box provisioning (reference/rollback only):**
|
||||||
|
|
||||||
1. Edit `C:\publish\glauth\glauth.cfg`
|
1. Edit `C:\publish\glauth\glauth.cfg`
|
||||||
2. Append the group:
|
2. Append the group:
|
||||||
@@ -172,7 +203,7 @@ server:
|
|||||||
4. `nssm restart GLAuth`
|
4. `nssm restart GLAuth`
|
||||||
|
|
||||||
After the restart, `admin`'s `memberOf` includes
|
After the restart, `admin`'s `memberOf` includes
|
||||||
`ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local`, which the authenticator
|
`ou=GwAdmin,ou=groups,dc=zb,dc=local`, which the authenticator
|
||||||
strips to `GwAdmin` and matches against `RequiredGroup`. The same
|
strips to `GwAdmin` and matches against `RequiredGroup`. The same
|
||||||
pattern applies to any future permission that doesn't fit the existing
|
pattern applies to any future permission that doesn't fit the existing
|
||||||
five roles.
|
five roles.
|
||||||
@@ -193,15 +224,16 @@ echo -n "yourpassword" | openssl dgst -sha256
|
|||||||
|
|
||||||
## Quick verification
|
## Quick verification
|
||||||
|
|
||||||
From mxaccessgw's dev box, prove the directory is reachable:
|
From mxaccessgw's dev box, prove the shared directory is reachable:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
||||||
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:3893")
|
# (shared GLAuth on 10.100.0.35 — was localhost, now the docker host)
|
||||||
|
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("10.100.0.35:3893")
|
||||||
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
||||||
$ldap.SessionOptions.ProtocolVersion = 3
|
$ldap.SessionOptions.ProtocolVersion = 3
|
||||||
$ldap.SessionOptions.SecureSocketLayer = $false
|
$ldap.SessionOptions.SecureSocketLayer = $false
|
||||||
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=lmxopcua,dc=local","admin123")
|
$cred = New-Object System.Net.NetworkCredential("cn=multi-role,dc=zb,dc=local","password")
|
||||||
$ldap.Bind($cred)
|
$ldap.Bind($cred)
|
||||||
"Bind OK"
|
"Bind OK"
|
||||||
```
|
```
|
||||||
@@ -209,17 +241,32 @@ $ldap.Bind($cred)
|
|||||||
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ldapsearch -x -H ldap://localhost:3893 \
|
ldapsearch -x -H ldap://10.100.0.35:3893 \
|
||||||
-D "cn=admin,dc=lmxopcua,dc=local" -w admin123 \
|
-D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
|
||||||
-b "dc=lmxopcua,dc=local" "(uid=admin)"
|
-b "dc=zb,dc=local" "(uid=multi-role)"
|
||||||
```
|
```
|
||||||
|
|
||||||
The response should list `admin`'s entry with `memberOf` populated for
|
The response should list `multi-role`'s entry with `memberOf` including
|
||||||
all five role groups — plus `GwAdmin` once the gateway-specific group
|
`ou=GwAdmin,ou=groups,dc=zb,dc=local`.
|
||||||
is provisioned.
|
|
||||||
|
|
||||||
## Service management
|
## Service management
|
||||||
|
|
||||||
|
> **RETIRED — per-box NSSM service (reference/rollback only).** The shared GLAuth is
|
||||||
|
> managed via `docker compose` on `10.100.0.35` (`scadaproj/infra/glauth/`). The
|
||||||
|
> Windows NSSM `GLAuth` service on the dev box has been stopped and set to
|
||||||
|
> `StartupType=Manual`; only restart it if you need to roll back to a local directory.
|
||||||
|
>
|
||||||
|
> **Active (shared) management:**
|
||||||
|
> ```bash
|
||||||
|
> ssh 10.100.0.35
|
||||||
|
> cd ~/Desktop/scadaproj/infra/glauth
|
||||||
|
> docker compose ps # check container status
|
||||||
|
> docker compose up -d --force-recreate # apply config.toml changes
|
||||||
|
> docker compose logs -f # tail logs
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**RETIRED — per-box NSSM commands (rollback reference):**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Status / start / stop / restart
|
# Status / start / stop / restart
|
||||||
nssm status GLAuth
|
nssm status GLAuth
|
||||||
@@ -253,12 +300,12 @@ applies to mxaccessgw verbatim. Keys that change:
|
|||||||
|
|
||||||
| Field | GLAuth dev value | AD production value |
|
| Field | GLAuth dev value | AD production value |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Server` | `localhost` | a domain controller FQDN, or the domain itself |
|
| `Server` | `10.100.0.35` (shared docker host) | a domain controller FQDN, or the domain itself |
|
||||||
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
||||||
| `UseTls` | `false` | `true` |
|
| `UseTls` | `false` | `true` |
|
||||||
| `AllowInsecureLdap` | `true` | `false` |
|
| `AllowInsecureLdap` | `true` | `false` |
|
||||||
| `SearchBase` | `dc=lmxopcua,dc=local` | `DC=corp,DC=example,DC=com` |
|
| `SearchBase` | `dc=zb,dc=local` | `DC=corp,DC=example,DC=com` |
|
||||||
| `ServiceAccountDn` | `cn=serviceaccount,dc=lmxopcua,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` |
|
| `ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` |
|
||||||
| `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) |
|
| `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) |
|
||||||
| `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) |
|
| `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) |
|
||||||
|
|
||||||
@@ -269,12 +316,12 @@ add a `tokenGroups` query as an enhancement.
|
|||||||
|
|
||||||
## Security notes for production
|
## Security notes for production
|
||||||
|
|
||||||
- **Plaintext passwords in `glauth.cfg` are dev-only.** The config is
|
- **Plaintext passwords in `config.toml` are dev-only.** The shared config is in
|
||||||
unencrypted on disk; anyone with read access to `C:\publish\glauth\`
|
`scadaproj/infra/glauth/config.toml` (unencrypted); restrict filesystem access on
|
||||||
can SHA256-rainbow-table the entries. Treat the dev creds as
|
`10.100.0.35` accordingly. Treat the dev creds as throwaway. Production LDAP is Active
|
||||||
throwaway. Production LDAP is Active Directory.
|
Directory. *(The retired per-box `C:\publish\glauth\glauth.cfg` has the same caveat.)*
|
||||||
- The 3-fail / 10-minute lockout is per source IP, not per user — a
|
- The 3-fail / 10-minute lockout is per source IP, not per user — a
|
||||||
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
|
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
|
||||||
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
||||||
wire. Fine for `localhost`, never expose port 3893 off-box without
|
wire. The shared GLAuth listens only on the LAN (`10.100.0.35`); never
|
||||||
enabling TLS first.
|
expose port 3893 externally without enabling TLS first.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
<!-- nuget.org serves everything; the Gitea feed serves only the ZB.MOM.WW.* shared libs.
|
||||||
|
Credentials are NOT committed: they are provided per-developer at the user level. -->
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
<packageSource key="dohertj2-gitea">
|
||||||
|
<package pattern="ZB.MOM.WW.Health" />
|
||||||
|
<package pattern="ZB.MOM.WW.Health.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Telemetry" />
|
||||||
|
<package pattern="ZB.MOM.WW.Telemetry.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Configuration" />
|
||||||
|
<package pattern="ZB.MOM.WW.Auth" />
|
||||||
|
<package pattern="ZB.MOM.WW.Auth.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Audit" />
|
||||||
|
<package pattern="ZB.MOM.WW.Theme" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
#Requires -Version 7
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Packs all MxAccessGateway clients into a single dist/ directory.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Runs each language client's native packaging command:
|
||||||
|
.NET -> dotnet pack (NuGet)
|
||||||
|
Python -> python -m build (sdist + wheel)
|
||||||
|
Rust -> cargo package (.crate)
|
||||||
|
Java -> gradle assemble + jars (jar + sources + javadoc + pom)
|
||||||
|
Go -> skipped; use scripts/tag-go-module.ps1
|
||||||
|
|
||||||
|
All artifacts land in -OutputDir (default: dist/).
|
||||||
|
|
||||||
|
With -Publish, each language pushes its package to the internal Gitea
|
||||||
|
feed. Requires GITEA_USERNAME and GITEA_TOKEN env vars.
|
||||||
|
|
||||||
|
.PARAMETER OutputDir
|
||||||
|
Where to drop the packed artifacts. Default: ./dist
|
||||||
|
|
||||||
|
.PARAMETER Languages
|
||||||
|
Subset of languages to pack. Default: all five.
|
||||||
|
Values: dotnet, python, rust, java, go
|
||||||
|
|
||||||
|
.PARAMETER Publish
|
||||||
|
After packing, upload to Gitea feeds. Requires:
|
||||||
|
GITEA_USERNAME
|
||||||
|
GITEA_TOKEN
|
||||||
|
Will refuse to publish if either is missing.
|
||||||
|
|
||||||
|
.PARAMETER SkipTests
|
||||||
|
Skip per-language regression tests before packing. Default: false.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh scripts/pack-clients.ps1
|
||||||
|
pwsh scripts/pack-clients.ps1 -Languages dotnet,python
|
||||||
|
pwsh scripts/pack-clients.ps1 -Publish
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$OutputDir = (Join-Path $PSScriptRoot '..' 'dist'),
|
||||||
|
[string[]]$Languages = @('dotnet', 'python', 'rust', 'java', 'go'),
|
||||||
|
[switch]$Publish,
|
||||||
|
[switch]$SkipTests
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# Normalize comma-separated strings that shells may pass as a single element.
|
||||||
|
$validLanguages = @('dotnet', 'python', 'rust', 'java', 'go')
|
||||||
|
$Languages = @($Languages | ForEach-Object { $_ -split ',' } | ForEach-Object {
|
||||||
|
$_.Trim().ToLowerInvariant()
|
||||||
|
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
|
|
||||||
|
foreach ($lang in $Languages) {
|
||||||
|
if ($validLanguages -notcontains $lang) {
|
||||||
|
throw "Unsupported language '$lang'. Supported values: $($validLanguages -join ', ')."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Languages.Count -eq 0) {
|
||||||
|
throw "At least one language is required. Supported values: $($validLanguages -join ', ')."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve absolute output dir
|
||||||
|
$OutputDir = [System.IO.Path]::GetFullPath($OutputDir)
|
||||||
|
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||||
|
|
||||||
|
if (-not (Test-Path $OutputDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $OutputDir | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Publish) {
|
||||||
|
if ([string]::IsNullOrEmpty($env:GITEA_USERNAME)) {
|
||||||
|
throw 'Publish requires GITEA_USERNAME env var.'
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrEmpty($env:GITEA_TOKEN)) {
|
||||||
|
throw 'Publish requires GITEA_TOKEN env var.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$GiteaNugetFeed = 'https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json'
|
||||||
|
$GiteaPypiFeed = 'https://gitea.dohertylan.com/api/packages/dohertj2/pypi'
|
||||||
|
$JavaHome = '/Users/dohertj2/.local/jdks/jdk-21.0.11+10/Contents/Home'
|
||||||
|
|
||||||
|
function Write-Header {
|
||||||
|
param([string]$Text)
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host '=== ' -NoNewline -ForegroundColor Cyan
|
||||||
|
Write-Host $Text -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------- .NET --------
|
||||||
|
|
||||||
|
function Invoke-PackDotnet {
|
||||||
|
Write-Header '.NET'
|
||||||
|
|
||||||
|
if (-not $SkipTests) {
|
||||||
|
Write-Host 'Running .NET client tests...'
|
||||||
|
$testProject = Join-Path $RepoRoot 'clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj'
|
||||||
|
& dotnet test $testProject --no-restore
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw '.NET tests failed.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'Packing ZB.MOM.WW.MxGateway.Contracts...'
|
||||||
|
& dotnet pack (Join-Path $RepoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj') `
|
||||||
|
-c Release -o $OutputDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw '.NET Contracts pack failed.' }
|
||||||
|
|
||||||
|
Write-Host 'Packing ZB.MOM.WW.MxGateway.Client...'
|
||||||
|
& dotnet pack (Join-Path $RepoRoot 'clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj') `
|
||||||
|
-c Release -o $OutputDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw '.NET Client pack failed.' }
|
||||||
|
|
||||||
|
Write-Host "Packed .NET artifacts -> $OutputDir" -ForegroundColor Green
|
||||||
|
|
||||||
|
if ($Publish) {
|
||||||
|
Write-Host 'Publishing .NET packages to Gitea...' -ForegroundColor Yellow
|
||||||
|
Get-ChildItem $OutputDir -Filter 'ZB.MOM.WW.MxGateway.*.nupkg' | ForEach-Object {
|
||||||
|
& dotnet nuget push $_.FullName --source $GiteaNugetFeed --api-key $env:GITEA_TOKEN
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for '$($_.Name)'." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------- Python --------
|
||||||
|
|
||||||
|
function Invoke-PackPython {
|
||||||
|
Write-Header 'Python'
|
||||||
|
|
||||||
|
# Use a persistent venv in /tmp so repeated runs skip reinstall.
|
||||||
|
$Venv = '/tmp/mxgw-py'
|
||||||
|
if (-not (Test-Path "$Venv/bin/python")) {
|
||||||
|
Write-Host "Creating Python venv at $Venv..."
|
||||||
|
& python3 -m venv $Venv
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'python3 -m venv failed.' }
|
||||||
|
& "$Venv/bin/pip" install --quiet --upgrade pip
|
||||||
|
& "$Venv/bin/pip" install --quiet build twine
|
||||||
|
& "$Venv/bin/pip" install --quiet -e (Join-Path $RepoRoot 'clients/python[dev]')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipTests) {
|
||||||
|
Write-Host 'Running Python tests...'
|
||||||
|
Push-Location (Join-Path $RepoRoot 'clients/python')
|
||||||
|
try {
|
||||||
|
& "$Venv/bin/python" -m pytest -q
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Python tests failed.' }
|
||||||
|
} finally { Pop-Location }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'Building Python sdist + wheel...'
|
||||||
|
& "$Venv/bin/python" -m build (Join-Path $RepoRoot 'clients/python') --outdir $OutputDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Python build failed.' }
|
||||||
|
|
||||||
|
Write-Host "Packed Python artifacts -> $OutputDir" -ForegroundColor Green
|
||||||
|
|
||||||
|
if ($Publish) {
|
||||||
|
Write-Host 'Publishing Python distribution to Gitea...' -ForegroundColor Yellow
|
||||||
|
$wheels = @(Get-ChildItem $OutputDir -Filter 'zb_mom_ww_mxaccess_gateway_client-*.whl')
|
||||||
|
$sdists = @(Get-ChildItem $OutputDir -Filter 'zb_mom_ww_mxaccess_gateway_client-*.tar.gz')
|
||||||
|
$files = ($wheels + $sdists) | ForEach-Object { $_.FullName }
|
||||||
|
& "$Venv/bin/python" -m twine upload `
|
||||||
|
--repository-url $GiteaPypiFeed `
|
||||||
|
-u $env:GITEA_USERNAME `
|
||||||
|
-p $env:GITEA_TOKEN `
|
||||||
|
@files
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'twine upload failed.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------- Rust --------
|
||||||
|
|
||||||
|
function Invoke-PackRust {
|
||||||
|
Write-Header 'Rust'
|
||||||
|
|
||||||
|
$rustDir = Join-Path $RepoRoot 'clients/rust'
|
||||||
|
Push-Location $rustDir
|
||||||
|
try {
|
||||||
|
if (-not $SkipTests) {
|
||||||
|
Write-Host 'Running Rust tests...'
|
||||||
|
& cargo test --workspace
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Rust tests failed.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'Running cargo package...'
|
||||||
|
& cargo package --no-verify
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'cargo package failed.' }
|
||||||
|
|
||||||
|
$packageDir = Join-Path $rustDir 'target/package'
|
||||||
|
$crates = @(Get-ChildItem $packageDir -Filter '*.crate')
|
||||||
|
if ($crates.Count -eq 0) {
|
||||||
|
throw 'cargo package produced no .crate files.'
|
||||||
|
}
|
||||||
|
foreach ($crate in $crates) {
|
||||||
|
Copy-Item $crate.FullName -Destination $OutputDir -Force
|
||||||
|
Write-Host " Copied $($crate.Name)"
|
||||||
|
}
|
||||||
|
} finally { Pop-Location }
|
||||||
|
|
||||||
|
Write-Host "Packed Rust artifacts -> $OutputDir" -ForegroundColor Green
|
||||||
|
|
||||||
|
if ($Publish) {
|
||||||
|
Write-Host 'Publishing Rust crate to Gitea...' -ForegroundColor Yellow
|
||||||
|
Push-Location (Join-Path $RepoRoot 'clients/rust')
|
||||||
|
try {
|
||||||
|
& cargo publish --no-verify --registry dohertj2-gitea
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'cargo publish failed.' }
|
||||||
|
} finally { Pop-Location }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------- Java --------
|
||||||
|
|
||||||
|
function Invoke-PackJava {
|
||||||
|
Write-Header 'Java'
|
||||||
|
|
||||||
|
$env:JAVA_HOME = $JavaHome
|
||||||
|
$javaDir = Join-Path $RepoRoot 'clients/java'
|
||||||
|
Push-Location $javaDir
|
||||||
|
try {
|
||||||
|
if (-not $SkipTests) {
|
||||||
|
Write-Host 'Running Java tests...'
|
||||||
|
& gradle ':zb-mom-ww-mxgateway-client:test' --no-daemon
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Java tests failed.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'Assembling Java jars + pom...'
|
||||||
|
& gradle `
|
||||||
|
':zb-mom-ww-mxgateway-client:assemble' `
|
||||||
|
':zb-mom-ww-mxgateway-client:sourcesJar' `
|
||||||
|
':zb-mom-ww-mxgateway-client:javadocJar' `
|
||||||
|
':zb-mom-ww-mxgateway-client:generatePomFileForMavenPublication' `
|
||||||
|
--no-daemon
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Java assemble failed.' }
|
||||||
|
|
||||||
|
$libsDir = Join-Path $javaDir 'zb-mom-ww-mxgateway-client/build/libs'
|
||||||
|
$jars = @(Get-ChildItem $libsDir -Filter 'zb-mom-ww-mxgateway-client-*.jar')
|
||||||
|
if ($jars.Count -eq 0) {
|
||||||
|
throw "No jars found under '$libsDir'."
|
||||||
|
}
|
||||||
|
foreach ($jar in $jars) {
|
||||||
|
Copy-Item $jar.FullName -Destination $OutputDir -Force
|
||||||
|
Write-Host " Copied $($jar.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$pomSrc = Join-Path $javaDir 'zb-mom-ww-mxgateway-client/build/publications/maven/pom-default.xml'
|
||||||
|
if (Test-Path $pomSrc) {
|
||||||
|
# Derive the version from the jar filename (e.g. zb-mom-ww-mxgateway-client-0.1.0.jar).
|
||||||
|
$versionJar = $jars | Where-Object { $_.Name -notmatch '-(sources|javadoc)\.jar$' } | Select-Object -First 1
|
||||||
|
$version = if ($versionJar) {
|
||||||
|
[System.IO.Path]::GetFileNameWithoutExtension($versionJar.Name) -replace '^zb-mom-ww-mxgateway-client-', ''
|
||||||
|
} else {
|
||||||
|
'0.1.0'
|
||||||
|
}
|
||||||
|
$pomDest = Join-Path $OutputDir "zb-mom-ww-mxgateway-client-$version.pom"
|
||||||
|
Copy-Item $pomSrc -Destination $pomDest -Force
|
||||||
|
Write-Host " Copied pom -> $([System.IO.Path]::GetFileName($pomDest))"
|
||||||
|
} else {
|
||||||
|
Write-Warning "POM not found at '$pomSrc'; skipping."
|
||||||
|
}
|
||||||
|
} finally { Pop-Location }
|
||||||
|
|
||||||
|
Write-Host "Packed Java artifacts -> $OutputDir" -ForegroundColor Green
|
||||||
|
|
||||||
|
if ($Publish) {
|
||||||
|
Write-Host 'Publishing Java artifacts to Gitea Maven feed...' -ForegroundColor Yellow
|
||||||
|
Push-Location $javaDir
|
||||||
|
try {
|
||||||
|
& gradle ':zb-mom-ww-mxgateway-client:publish' --no-daemon
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'gradle publish failed.' }
|
||||||
|
} finally { Pop-Location }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------- Go --------
|
||||||
|
|
||||||
|
function Invoke-PackGo {
|
||||||
|
Write-Header 'Go'
|
||||||
|
Write-Host 'Go modules are released by git-tagging — no artifact to pack.' -ForegroundColor Yellow
|
||||||
|
Write-Host 'To publish a Go release, run:' -ForegroundColor Yellow
|
||||||
|
Write-Host ' pwsh scripts/tag-go-module.ps1 -Version v0.1.0 -Push' -ForegroundColor Yellow
|
||||||
|
Write-Host '(skipping)' -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------- Dispatch --------
|
||||||
|
|
||||||
|
$wanted = @{}
|
||||||
|
foreach ($lang in $Languages) { $wanted[$lang.ToLower()] = $true }
|
||||||
|
|
||||||
|
if ($wanted.ContainsKey('dotnet')) { Invoke-PackDotnet }
|
||||||
|
if ($wanted.ContainsKey('python')) { Invoke-PackPython }
|
||||||
|
if ($wanted.ContainsKey('rust')) { Invoke-PackRust }
|
||||||
|
if ($wanted.ContainsKey('java')) { Invoke-PackJava }
|
||||||
|
if ($wanted.ContainsKey('go')) { Invoke-PackGo }
|
||||||
|
|
||||||
|
# -------- Summary --------
|
||||||
|
|
||||||
|
Write-Header 'Summary'
|
||||||
|
$artifacts = @(Get-ChildItem $OutputDir)
|
||||||
|
if ($artifacts.Count -eq 0) {
|
||||||
|
Write-Host ' (no artifacts)' -ForegroundColor DarkGray
|
||||||
|
} else {
|
||||||
|
foreach ($a in $artifacts) {
|
||||||
|
Write-Host (' {0,10} {1}' -f $a.Length, $a.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "All artifacts in: $OutputDir" -ForegroundColor Green
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#Requires -Version 7
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Tags a release of the Go MxAccessGateway client module.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Go modules in monorepo subdirectories use prefixed tags
|
||||||
|
("clients/go/v0.1.0") so `go get <module>@v0.1.0` resolves correctly.
|
||||||
|
This script validates the version, creates the prefixed tag at HEAD,
|
||||||
|
and (optionally) pushes it.
|
||||||
|
|
||||||
|
.PARAMETER Version
|
||||||
|
Semver tag without the prefix, e.g. "v0.1.0".
|
||||||
|
|
||||||
|
.PARAMETER Push
|
||||||
|
When set, pushes the tag to origin after creation.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh scripts/tag-go-module.ps1 -Version v0.1.0
|
||||||
|
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Version,
|
||||||
|
|
||||||
|
[switch]$Push
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if ($Version -notmatch '^v\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$') {
|
||||||
|
throw "Version '$Version' must match semver vX.Y.Z (optionally with -prerelease suffix)."
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = "clients/go/$Version"
|
||||||
|
Write-Host "Creating Go-module tag: $tag" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Verify we're on a clean checkout — refuse to tag with uncommitted changes.
|
||||||
|
$status = (git status --porcelain) -join "`n"
|
||||||
|
if ($status -and -not ($status -match '^\?\?')) {
|
||||||
|
throw "Working tree has tracked changes. Commit or stash before tagging."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify the tag doesn't already exist.
|
||||||
|
$existing = git tag --list $tag
|
||||||
|
if ($existing) {
|
||||||
|
throw "Tag '$tag' already exists. Use a new version."
|
||||||
|
}
|
||||||
|
|
||||||
|
git tag -a $tag -m "Go client release $Version"
|
||||||
|
Write-Host "Created tag: $tag" -ForegroundColor Green
|
||||||
|
|
||||||
|
if ($Push) {
|
||||||
|
git push origin $tag
|
||||||
|
Write-Host "Pushed tag to origin." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "Tag not pushed. To publish, run: git push origin $tag" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
@@ -8,10 +8,13 @@ namespace ZB.MOM.WW.MxGateway.Contracts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class GatewayContractInfo
|
public static class GatewayContractInfo
|
||||||
{
|
{
|
||||||
|
/// <summary>Protocol version advertised to clients in <c>OpenSessionReply</c>.</summary>
|
||||||
public const uint GatewayProtocolVersion = 3;
|
public const uint GatewayProtocolVersion = 3;
|
||||||
|
|
||||||
|
/// <summary>Protocol version used to validate <c>WorkerEnvelope</c> framing on the gateway-worker pipe.</summary>
|
||||||
public const uint WorkerProtocolVersion = 1;
|
public const uint WorkerProtocolVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>Default backend name identifying the MXAccess worker process type.</summary>
|
||||||
public const string DefaultBackendName = "mxaccess-worker";
|
public const string DefaultBackendName = "mxaccess-worker";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -315,6 +315,14 @@ message SubscribeBulkCommand {
|
|||||||
repeated string tag_addresses = 2;
|
repeated string tag_addresses = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider selection / current provider for the alarm feed. UNSPECIFIED on a
|
||||||
|
// SubscribeAlarmsCommand means auto: alarmmgr primary with subtag fallback.
|
||||||
|
enum AlarmProviderMode {
|
||||||
|
ALARM_PROVIDER_MODE_UNSPECIFIED = 0;
|
||||||
|
ALARM_PROVIDER_MODE_ALARMMGR = 1;
|
||||||
|
ALARM_PROVIDER_MODE_SUBTAG = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe the worker's alarm consumer to an AVEVA alarm provider.
|
// Subscribe the worker's alarm consumer to an AVEVA alarm provider.
|
||||||
// Subscription expression follows the canonical
|
// Subscription expression follows the canonical
|
||||||
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
|
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
|
||||||
@@ -323,6 +331,12 @@ message SubscribeBulkCommand {
|
|||||||
// SubscribeAlarms to reconfigure).
|
// SubscribeAlarms to reconfigure).
|
||||||
message SubscribeAlarmsCommand {
|
message SubscribeAlarmsCommand {
|
||||||
string subscription_expression = 1;
|
string subscription_expression = 1;
|
||||||
|
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
||||||
|
AlarmProviderMode forced_mode = 2;
|
||||||
|
// Subtag watch-list resolved by the gateway (GR SQL + config). Empty in pure
|
||||||
|
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
|
||||||
|
repeated AlarmSubtagTarget watch_list = 3;
|
||||||
|
AlarmFailoverConfig failover = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tear down the worker's alarm consumer. No-op if no subscription is
|
// Tear down the worker's alarm consumer. No-op if no subscription is
|
||||||
@@ -330,6 +344,23 @@ message SubscribeAlarmsCommand {
|
|||||||
message UnsubscribeAlarmsCommand {
|
message UnsubscribeAlarmsCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One alarm attribute the subtag fallback consumer advises. Addresses are full
|
||||||
|
// MXAccess item references the worker passes straight to AddItem.
|
||||||
|
message AlarmSubtagTarget {
|
||||||
|
string alarm_full_reference = 1; // e.g. "Galaxy!Area.Tank01.Level.HiHi"
|
||||||
|
string source_object_reference = 2; // e.g. "Tank01"
|
||||||
|
string active_subtag = 3; // item address of the in-alarm boolean
|
||||||
|
string acked_subtag = 4; // item address of the acknowledged boolean
|
||||||
|
string ack_comment_subtag = 5; // writable ack-comment attribute (ack write target)
|
||||||
|
string priority_subtag = 6; // optional severity source; empty if absent
|
||||||
|
}
|
||||||
|
|
||||||
|
message AlarmFailoverConfig {
|
||||||
|
int32 consecutive_failure_threshold = 1; // wnwrap COM failures before switching (>=1)
|
||||||
|
int32 failback_probe_interval_seconds = 2; // probe cadence while degraded (>=1)
|
||||||
|
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
||||||
|
}
|
||||||
|
|
||||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||||
// recorded atomically with the ack transition in the alarm-history log.
|
// recorded atomically with the ack transition in the alarm-history log.
|
||||||
// The reply's hresult / native_status surfaces AVEVA's
|
// The reply's hresult / native_status surfaces AVEVA's
|
||||||
@@ -684,6 +715,7 @@ message MxEvent {
|
|||||||
OperationCompleteEvent operation_complete = 22;
|
OperationCompleteEvent operation_complete = 22;
|
||||||
OnBufferedDataChangeEvent on_buffered_data_change = 23;
|
OnBufferedDataChangeEvent on_buffered_data_change = 23;
|
||||||
OnAlarmTransitionEvent on_alarm_transition = 24;
|
OnAlarmTransitionEvent on_alarm_transition = 24;
|
||||||
|
OnAlarmProviderModeChangedEvent on_alarm_provider_mode_changed = 25;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,6 +726,7 @@ enum MxEventFamily {
|
|||||||
MX_EVENT_FAMILY_OPERATION_COMPLETE = 3;
|
MX_EVENT_FAMILY_OPERATION_COMPLETE = 3;
|
||||||
MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4;
|
MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4;
|
||||||
MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5;
|
MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5;
|
||||||
|
MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message OnDataChangeEvent {
|
message OnDataChangeEvent {
|
||||||
@@ -768,6 +801,20 @@ message OnAlarmTransitionEvent {
|
|||||||
// Limit/threshold value that triggered the transition for limit alarms.
|
// Limit/threshold value that triggered the transition for limit alarms.
|
||||||
// Optional; populated for AnalogLimitAlarm-family transitions.
|
// Optional; populated for AnalogLimitAlarm-family transitions.
|
||||||
MxValue limit_value = 13;
|
MxValue limit_value = 13;
|
||||||
|
|
||||||
|
// True when this transition came from the subtag-monitoring fallback rather
|
||||||
|
// than the native alarmmgr provider — synthesized from data changes, reduced
|
||||||
|
// fidelity (synthetic GUID, no native raise time).
|
||||||
|
bool degraded = 14;
|
||||||
|
// Which provider produced this transition.
|
||||||
|
AlarmProviderMode source_provider = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OnAlarmProviderModeChangedEvent {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
string reason = 2;
|
||||||
|
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
|
||||||
|
google.protobuf.Timestamp at = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AlarmTransitionKind {
|
enum AlarmTransitionKind {
|
||||||
@@ -800,6 +847,8 @@ message ActiveAlarmSnapshot {
|
|||||||
string operator_comment = 11;
|
string operator_comment = 11;
|
||||||
MxValue current_value = 12;
|
MxValue current_value = 12;
|
||||||
MxValue limit_value = 13;
|
MxValue limit_value = 13;
|
||||||
|
bool degraded = 14;
|
||||||
|
AlarmProviderMode source_provider = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AlarmConditionState {
|
enum AlarmConditionState {
|
||||||
@@ -866,9 +915,19 @@ message AlarmFeedMessage {
|
|||||||
bool snapshot_complete = 2;
|
bool snapshot_complete = 2;
|
||||||
// A live alarm state change (raise / acknowledge / clear).
|
// A live alarm state change (raise / acknowledge / clear).
|
||||||
OnAlarmTransitionEvent transition = 3;
|
OnAlarmTransitionEvent transition = 3;
|
||||||
|
// Provider-mode status. Emitted once on stream open and again on every
|
||||||
|
// failover/failback so late joiners learn the current mode immediately.
|
||||||
|
AlarmProviderStatus provider_status = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message AlarmProviderStatus {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
bool degraded = 2; // true whenever mode == SUBTAG
|
||||||
|
string reason = 3; // human-readable switch reason
|
||||||
|
google.protobuf.Timestamp since = 4;
|
||||||
|
}
|
||||||
|
|
||||||
message MxStatusProxy {
|
message MxStatusProxy {
|
||||||
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
||||||
// (a 16-bit signed value in the COM struct, widened to int32 on the
|
// (a 16-bit signed value in the COM struct, widened to int32 on the
|
||||||
|
|||||||
@@ -4,6 +4,24 @@
|
|||||||
<TargetFrameworks>net10.0;net48</TargetFrameworks>
|
<TargetFrameworks>net10.0;net48</TargetFrameworks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<PackageId>ZB.MOM.WW.MxGateway.Contracts</PackageId>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<Authors>Joseph Doherty</Authors>
|
||||||
|
<Company>ZB MOM WW</Company>
|
||||||
|
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
||||||
|
<Description>Protobuf contracts and gRPC stubs for the MxAccessGateway service. Multi-targets net10.0 and net48.</Description>
|
||||||
|
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
|
||||||
|
<PackageTags>mxaccess;mxgateway;grpc;contracts;protobuf</PackageTags>
|
||||||
|
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
|
||||||
|
<IncludeSymbols>true</IncludeSymbols>
|
||||||
|
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Remove="Generated\**\*.cs" />
|
<Compile Remove="Generated\**\*.cs" />
|
||||||
<Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
|
<Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using ZB.MOM.WW.Auth.Ldap;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
||||||
|
|
||||||
@@ -28,12 +31,11 @@ public sealed class DashboardLdapLiveTests
|
|||||||
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
||||||
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
// IntegrationTests-023: DashboardAuthenticator.CreatePrincipal emits a
|
// IntegrationTests-023: DashboardAuthenticator builds the principal with a
|
||||||
// ClaimTypes.Role claim derived from MapGroupsToRoles. The seeded
|
// ClaimTypes.Role claim resolved from the LDAP groups via the
|
||||||
// GroupToRole map (GwAdmin -> Admin) means the admin principal must
|
// DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin)
|
||||||
// carry Role=Admin alongside the raw LDAP-group claim. A regression in
|
// means the admin principal must carry Role=Admin alongside the raw LDAP-group
|
||||||
// MapGroupsToRoles (returning an empty list, missing the RDN fallback)
|
// claim. A regression in the group→role mapping would fail this assertion.
|
||||||
// would silently pass without this assertion.
|
|
||||||
Assert.Contains(result.Principal.Claims, claim =>
|
Assert.Contains(result.Principal.Claims, claim =>
|
||||||
claim.Type == ClaimTypes.Role
|
claim.Type == ClaimTypes.Role
|
||||||
&& claim.Value == DashboardRoles.Admin);
|
&& claim.Value == DashboardRoles.Admin);
|
||||||
@@ -59,7 +61,7 @@ public sealed class DashboardLdapLiveTests
|
|||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
||||||
{
|
{
|
||||||
// Exercises the LdapException branch: the user exists and the service
|
// Exercises the user-bind-failure branch: the user exists and the service
|
||||||
// account search succeeds, but the candidate bind is rejected.
|
// account search succeeds, but the candidate bind is rejected.
|
||||||
const string wrongPassword = "definitely-not-the-admin-password";
|
const string wrongPassword = "definitely-not-the-admin-password";
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||||
@@ -78,8 +80,8 @@ public sealed class DashboardLdapLiveTests
|
|||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
||||||
{
|
{
|
||||||
// Exercises the `candidate is null` branch: the service-account search
|
// Exercises the user-not-found branch: the service-account search returns no
|
||||||
// returns no entry, so no candidate bind is attempted.
|
// entry, so no candidate bind is attempted.
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
@@ -96,18 +98,13 @@ public sealed class DashboardLdapLiveTests
|
|||||||
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
||||||
{
|
{
|
||||||
// Exercises the connect-failure path: a closed loopback port produces a
|
// Exercises the connect-failure path: a closed loopback port produces a
|
||||||
// connection error that DashboardAuthenticator must absorb into a Fail
|
// connection error that the shared LdapAuthService must absorb into a Fail
|
||||||
// result rather than propagating an exception to the dashboard.
|
// result rather than propagating an exception to the dashboard.
|
||||||
DashboardAuthenticator authenticator = new(
|
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
||||||
Options.Create(new GatewayOptions
|
{
|
||||||
{
|
// 1 is a reserved port number that no LDAP server listens on.
|
||||||
Ldap = new LdapOptions
|
Port = 1,
|
||||||
{
|
});
|
||||||
// 1 is a reserved port number that no LDAP server listens on.
|
|
||||||
Port = 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
"admin",
|
"admin",
|
||||||
@@ -118,19 +115,48 @@ public sealed class DashboardLdapLiveTests
|
|||||||
Assert.Null(result.Principal);
|
Assert.Null(result.Principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardAuthenticator CreateAuthenticator()
|
private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
|
||||||
|
|
||||||
|
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
|
||||||
{
|
{
|
||||||
return new DashboardAuthenticator(
|
GatewayOptions gatewayOptions = new()
|
||||||
Options.Create(new GatewayOptions
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
{
|
{
|
||||||
Dashboard = new DashboardOptions
|
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
["GwAdmin"] = DashboardRoles.Admin,
|
||||||
{
|
|
||||||
["GwAdmin"] = DashboardRoles.Admin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new DashboardAuthenticator(
|
||||||
|
new LdapAuthService(ldapOptions),
|
||||||
|
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
NullLogger<DashboardAuthenticator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the shared library <see cref="LibraryLdapOptions"/> from the gateway's
|
||||||
|
/// default LDAP settings so the live tests exercise the same seeded directory the
|
||||||
|
/// gateway connects to (localhost:3893, plaintext, with AllowInsecure for dev).
|
||||||
|
/// </summary>
|
||||||
|
private static LibraryLdapOptions LibraryOptions()
|
||||||
|
{
|
||||||
|
ZB.MOM.WW.MxGateway.Server.Configuration.LdapOptions gateway = new();
|
||||||
|
return new LibraryLdapOptions
|
||||||
|
{
|
||||||
|
Enabled = gateway.Enabled,
|
||||||
|
Server = gateway.Server,
|
||||||
|
Port = gateway.Port,
|
||||||
|
Transport = gateway.Transport,
|
||||||
|
AllowInsecure = gateway.AllowInsecure,
|
||||||
|
SearchBase = gateway.SearchBase,
|
||||||
|
ServiceAccountDn = gateway.ServiceAccountDn,
|
||||||
|
ServiceAccountPassword = gateway.ServiceAccountPassword,
|
||||||
|
UserNameAttribute = gateway.UserNameAttribute,
|
||||||
|
DisplayNameAttribute = gateway.DisplayNameAttribute,
|
||||||
|
GroupAttribute = gateway.GroupAttribute,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Well-known <c>reason</c> strings carried on the alarm feed's
|
||||||
|
/// <c>AlarmProviderStatus</c> message. Shared between the producer
|
||||||
|
/// (<see cref="GatewayAlarmMonitor" />) and consumers (e.g. the dashboard
|
||||||
|
/// provider badge) so the two cannot drift on a magic string.
|
||||||
|
/// </summary>
|
||||||
|
public static class AlarmProviderReasons
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reason set when the monitor starts in subtag mode because
|
||||||
|
/// <c>MxGateway:Alarms:Fallback:Mode</c> is <c>ForceSubtag</c> — a
|
||||||
|
/// deliberate configuration, not a runtime failover. Lets the dashboard
|
||||||
|
/// distinguish a forced subtag mode from an unexpected degraded failover.
|
||||||
|
/// </summary>
|
||||||
|
public const string ForcedSubtag = "Forced subtag mode (configuration)";
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IAlarmWatchListResolver"/>. Merges Galaxy Repository
|
||||||
|
/// alarm-attribute discovery with the configured include/exclude overrides
|
||||||
|
/// and composes the per-attribute subtag item addresses from the configured
|
||||||
|
/// subtag names.
|
||||||
|
/// </summary>
|
||||||
|
// NOTE: The exact subtag names and the canonical AlarmFullReference shape
|
||||||
|
// ("Galaxy!{area}.{reference}") are validated against a live Galaxy in the
|
||||||
|
// Task 17 live smoke test. The config Subtags block exists precisely so these
|
||||||
|
// names are not hard-coded here. The {area} is the alarm object's REAL Galaxy
|
||||||
|
// area discovered via gobject.area_gobject_id (the alarm group the native
|
||||||
|
// alarmmgr emits), giving exact reference parity with wnwrap. The configured
|
||||||
|
// Discovery.Area/DefaultArea is only the fallback for explicit IncludeAttributes
|
||||||
|
// entries, which carry no discovered area.
|
||||||
|
public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
|
||||||
|
{
|
||||||
|
private const string ProviderLiteral = "Galaxy";
|
||||||
|
private const string DefaultActiveSubtag = "InAlarm";
|
||||||
|
private const string DefaultAckedSubtag = "Acked";
|
||||||
|
|
||||||
|
private readonly IGalaxyRepository _repository;
|
||||||
|
private readonly ILogger<AlarmWatchListResolver> _logger;
|
||||||
|
|
||||||
|
/// <summary>Initializes the watch-list resolver.</summary>
|
||||||
|
/// <param name="repository">Galaxy Repository used for alarm-attribute discovery.</param>
|
||||||
|
/// <param name="logger">Diagnostic logger.</param>
|
||||||
|
public AlarmWatchListResolver(
|
||||||
|
IGalaxyRepository repository,
|
||||||
|
ILogger<AlarmWatchListResolver> logger)
|
||||||
|
{
|
||||||
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
|
||||||
|
AlarmsOptions options,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
AlarmDiscoveryOptions discovery = options.Fallback.Discovery;
|
||||||
|
|
||||||
|
// Config fallback area used only for explicit IncludeAttributes entries (which
|
||||||
|
// carry no discovered area): discovery area, else the default area (may be empty).
|
||||||
|
string configFallbackArea = string.IsNullOrEmpty(discovery.Area) ? options.DefaultArea : discovery.Area;
|
||||||
|
|
||||||
|
// 1. Build the ordered, de-duplicated attribute reference set.
|
||||||
|
// Each entry carries the reference, the source-object reference, and the
|
||||||
|
// per-entry area used to compose the canonical reference. GR rows contribute
|
||||||
|
// the object's real Galaxy area; config includes contribute the config
|
||||||
|
// fallback area (Discovery.Area else DefaultArea).
|
||||||
|
List<(string Reference, string SourceObject, string Area)> ordered = [];
|
||||||
|
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (discovery.UseGalaxyRepository)
|
||||||
|
{
|
||||||
|
List<GalaxyAlarmAttributeRow> rows;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rows = await _repository.GetAlarmAttributesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Discovery being unavailable must not crash the resolver: log and
|
||||||
|
// continue with an empty discovery set. The caller decides what to
|
||||||
|
// do with the (possibly config-only) result.
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Galaxy Repository alarm-attribute discovery failed; continuing with configuration-only watch-list.");
|
||||||
|
rows = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (GalaxyAlarmAttributeRow row in rows)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(row.FullTagReference) || !seen.Add(row.FullTagReference))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered.Add((row.FullTagReference, row.SourceObjectReference, row.Area));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string include in discovery.IncludeAttributes)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(include) || !seen.Add(include))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered.Add((include, DeriveSourceObject(include), configFallbackArea));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove excluded references (case-insensitive), but only when GR discovery
|
||||||
|
// is active. ExcludeAttributes is documented as "Ignored when
|
||||||
|
// UseGalaxyRepository is false" (AlarmDiscoveryOptions.ExcludeAttributes).
|
||||||
|
// Whitespace-only entries are skipped, consistent with the include guard above.
|
||||||
|
if (discovery.UseGalaxyRepository)
|
||||||
|
{
|
||||||
|
HashSet<string> excluded = new(
|
||||||
|
discovery.ExcludeAttributes.Where(e => !string.IsNullOrWhiteSpace(e)),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (excluded.Count > 0)
|
||||||
|
{
|
||||||
|
ordered.RemoveAll(e => excluded.Contains(e.Reference));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resolve subtag names with safe fallbacks.
|
||||||
|
string active = string.IsNullOrEmpty(options.Fallback.Subtags.Active)
|
||||||
|
? DefaultActiveSubtag
|
||||||
|
: options.Fallback.Subtags.Active;
|
||||||
|
string acked = string.IsNullOrEmpty(options.Fallback.Subtags.Acked)
|
||||||
|
? DefaultAckedSubtag
|
||||||
|
: options.Fallback.Subtags.Acked;
|
||||||
|
string priority = options.Fallback.Subtags.Priority;
|
||||||
|
string ackComment = options.Fallback.Subtags.AckComment;
|
||||||
|
|
||||||
|
// 3. Compose one target per reference, using the PER-ENTRY area: the GR row's
|
||||||
|
// real Galaxy area (matching the alarmmgr group), or the config fallback for
|
||||||
|
// explicit includes.
|
||||||
|
List<AlarmSubtagTarget> targets = new(ordered.Count);
|
||||||
|
foreach ((string reference, string sourceObject, string area) in ordered)
|
||||||
|
{
|
||||||
|
targets.Add(new AlarmSubtagTarget
|
||||||
|
{
|
||||||
|
AlarmFullReference = ComposeFullReference(area, reference),
|
||||||
|
SourceObjectReference = sourceObject,
|
||||||
|
ActiveSubtag = $"{reference}.{active}",
|
||||||
|
AckedSubtag = $"{reference}.{acked}",
|
||||||
|
PrioritySubtag = string.IsNullOrEmpty(priority) ? string.Empty : $"{reference}.{priority}",
|
||||||
|
AckCommentSubtag = string.IsNullOrEmpty(ackComment) ? string.Empty : $"{reference}.{ackComment}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Report the resolved count; warn when subtag mode was expected to cover
|
||||||
|
// something (GR enabled, or explicit includes were configured) but resolved
|
||||||
|
// to nothing. Only emit the Debug line when there is at least one target,
|
||||||
|
// to avoid a confusing "0 target(s)" noise line.
|
||||||
|
if (targets.Count == 0 && (discovery.UseGalaxyRepository || discovery.IncludeAttributes.Length > 0))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Alarm subtag watch-list resolved to zero targets; subtag-polling fallback will cover no alarms.");
|
||||||
|
}
|
||||||
|
else if (targets.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Resolved alarm subtag watch-list with {TargetCount} target(s).", targets.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the source-object reference for a configuration entry: the
|
||||||
|
/// substring before the first '.', or the whole string when there is no dot.
|
||||||
|
/// </summary>
|
||||||
|
private static string DeriveSourceObject(string reference)
|
||||||
|
{
|
||||||
|
int dot = reference.IndexOf('.', StringComparison.Ordinal);
|
||||||
|
return dot < 0 ? reference : reference[..dot];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the canonical alarm full reference: <c>Galaxy!{area}.{reference}</c>
|
||||||
|
/// when an area is set, otherwise <c>Galaxy!{reference}</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string ComposeFullReference(string area, string reference) =>
|
||||||
|
string.IsNullOrEmpty(area)
|
||||||
|
? $"{ProviderLiteral}!{reference}"
|
||||||
|
: $"{ProviderLiteral}!{area}.{reference}";
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ public static class AlarmsServiceCollectionExtensions
|
|||||||
/// <returns>The service collection for chaining.</returns>
|
/// <returns>The service collection for chaining.</returns>
|
||||||
public static IServiceCollection AddGatewayAlarms(this IServiceCollection services)
|
public static IServiceCollection AddGatewayAlarms(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.AddSingleton<IAlarmWatchListResolver, AlarmWatchListResolver>();
|
||||||
services.AddSingleton<GatewayAlarmMonitor>();
|
services.AddSingleton<GatewayAlarmMonitor>();
|
||||||
services.AddSingleton<IGatewayAlarmService>(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
services.AddSingleton<IGatewayAlarmService>(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
||||||
services.AddHostedService(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
services.AddHostedService(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
@@ -23,6 +25,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IAlarmWatchListResolver _watchListResolver;
|
||||||
|
private readonly GatewayMetrics _metrics;
|
||||||
private readonly AlarmsOptions _options;
|
private readonly AlarmsOptions _options;
|
||||||
private readonly ILogger<GatewayAlarmMonitor> _logger;
|
private readonly ILogger<GatewayAlarmMonitor> _logger;
|
||||||
|
|
||||||
@@ -30,20 +34,34 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
private readonly Dictionary<string, ActiveAlarmSnapshot> _alarms = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, ActiveAlarmSnapshot> _alarms = new(StringComparer.Ordinal);
|
||||||
private readonly List<Subscriber> _subscribers = [];
|
private readonly List<Subscriber> _subscribers = [];
|
||||||
|
|
||||||
|
// Current provider status (mode + degraded + reason + since), guarded by _sync.
|
||||||
|
// Initialized to the alarm-manager, not-degraded baseline so a late joiner sees
|
||||||
|
// a sensible status even before any OnAlarmProviderModeChanged event arrives.
|
||||||
|
private AlarmProviderMode _providerMode = AlarmProviderMode.Alarmmgr;
|
||||||
|
private bool _providerDegraded;
|
||||||
|
private string _providerReason = string.Empty;
|
||||||
|
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
|
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
|
||||||
private volatile string? _lastError;
|
private volatile string? _lastError;
|
||||||
private GatewaySession? _session;
|
private GatewaySession? _session;
|
||||||
|
|
||||||
/// <summary>Initializes the gateway alarm monitor.</summary>
|
/// <summary>Initializes the gateway alarm monitor.</summary>
|
||||||
/// <param name="sessionManager">Gateway session manager.</param>
|
/// <param name="sessionManager">Gateway session manager.</param>
|
||||||
|
/// <param name="watchListResolver">Resolver for the subtag-fallback watch-list.</param>
|
||||||
|
/// <param name="metrics">Gateway metrics sink.</param>
|
||||||
/// <param name="options">Gateway options carrying the alarm configuration.</param>
|
/// <param name="options">Gateway options carrying the alarm configuration.</param>
|
||||||
/// <param name="logger">Diagnostic logger.</param>
|
/// <param name="logger">Diagnostic logger.</param>
|
||||||
public GatewayAlarmMonitor(
|
public GatewayAlarmMonitor(
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
|
IAlarmWatchListResolver watchListResolver,
|
||||||
|
GatewayMetrics metrics,
|
||||||
IOptions<GatewayOptions> options,
|
IOptions<GatewayOptions> options,
|
||||||
ILogger<GatewayAlarmMonitor> logger)
|
ILogger<GatewayAlarmMonitor> logger)
|
||||||
{
|
{
|
||||||
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
||||||
|
_watchListResolver = watchListResolver ?? throw new ArgumentNullException(nameof(watchListResolver));
|
||||||
|
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms;
|
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms;
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
@@ -139,6 +157,49 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken)
|
private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_state = GatewayAlarmMonitorState.Starting;
|
_state = GatewayAlarmMonitorState.Starting;
|
||||||
|
|
||||||
|
// Derive the lifecycle baseline from the configured forced mode so a
|
||||||
|
// ForceSubtag / ForceAlarmManager start advertises the correct mode even
|
||||||
|
// though no OnAlarmProviderModeChanged event is raised in those modes
|
||||||
|
// (only Auto/failover produces that event). ForceSubtag starts degraded.
|
||||||
|
AlarmProviderMode initialMode;
|
||||||
|
bool initialDegraded;
|
||||||
|
string initialReason;
|
||||||
|
switch (MapForcedMode(_options.Fallback.Mode))
|
||||||
|
{
|
||||||
|
case AlarmProviderMode.Subtag:
|
||||||
|
initialMode = AlarmProviderMode.Subtag;
|
||||||
|
initialDegraded = true;
|
||||||
|
initialReason = AlarmProviderReasons.ForcedSubtag;
|
||||||
|
break;
|
||||||
|
case AlarmProviderMode.Alarmmgr:
|
||||||
|
initialMode = AlarmProviderMode.Alarmmgr;
|
||||||
|
initialDegraded = false;
|
||||||
|
initialReason = string.Empty;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Unspecified (Auto): the failover consumer starts on the
|
||||||
|
// alarm-manager primary and only degrades to subtag on failure.
|
||||||
|
initialMode = AlarmProviderMode.Alarmmgr;
|
||||||
|
initialDegraded = false;
|
||||||
|
initialReason = string.Empty;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
// Re-baseline the provider status for this lifecycle so a restarted
|
||||||
|
// monitor advertises the configured mode until told otherwise.
|
||||||
|
_providerMode = initialMode;
|
||||||
|
_providerDegraded = initialDegraded;
|
||||||
|
_providerReason = initialReason;
|
||||||
|
_providerSince = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Align the observable gauge with the lifecycle baseline without recording
|
||||||
|
// a switch — the gauge was 0 (unknown) from construction until now.
|
||||||
|
_metrics.SetAlarmProviderMode(ModeToInt(initialMode));
|
||||||
|
|
||||||
GatewaySession session = await _sessionManager.OpenSessionAsync(
|
GatewaySession session = await _sessionManager.OpenSessionAsync(
|
||||||
new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
|
new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
|
||||||
MonitorClientName,
|
MonitorClientName,
|
||||||
@@ -173,6 +234,15 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
{
|
{
|
||||||
ApplyTransition(mxEvent.OnAlarmTransition);
|
ApplyTransition(mxEvent.OnAlarmTransition);
|
||||||
}
|
}
|
||||||
|
else if (mxEvent is { BodyCase: MxEvent.BodyOneofCase.OnAlarmProviderModeChanged }
|
||||||
|
&& mxEvent.OnAlarmProviderModeChanged is not null)
|
||||||
|
{
|
||||||
|
await ApplyProviderModeChangeAsync(
|
||||||
|
session.SessionId,
|
||||||
|
mxEvent.OnAlarmProviderModeChanged,
|
||||||
|
linked.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -209,6 +279,33 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken)
|
private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
IReadOnlyList<AlarmSubtagTarget> watchList = await _watchListResolver
|
||||||
|
.ResolveAsync(_options, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Alarm subscribe: forcedMode={ForcedMode} configMode={ConfigMode} watchList={WatchListCount}.",
|
||||||
|
forcedMode, _options.Fallback.Mode, watchList.Count);
|
||||||
|
|
||||||
|
// When the forced mode is Unspecified (the "Auto" case) and the resolved
|
||||||
|
// watch-list is empty — the common alarmmgr-only deployment — the command
|
||||||
|
// is identical-in-effect to the historical SubscribeAlarms (wnwrap only):
|
||||||
|
// the worker builds the wnwrap consumer and no subtag watch-list.
|
||||||
|
SubscribeAlarmsCommand command = new()
|
||||||
|
{
|
||||||
|
SubscriptionExpression = subscription,
|
||||||
|
ForcedMode = forcedMode,
|
||||||
|
Failover = new AlarmFailoverConfig
|
||||||
|
{
|
||||||
|
ConsecutiveFailureThreshold = _options.Fallback.ConsecutiveFailureThreshold,
|
||||||
|
FailbackProbeIntervalSeconds = _options.Fallback.FailbackProbeIntervalSeconds,
|
||||||
|
FailbackStableProbes = _options.Fallback.FailbackStableProbes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
command.WatchList.AddRange(watchList);
|
||||||
|
|
||||||
WorkerCommandReply reply = await _sessionManager.InvokeAsync(
|
WorkerCommandReply reply = await _sessionManager.InvokeAsync(
|
||||||
sessionId,
|
sessionId,
|
||||||
new WorkerCommand
|
new WorkerCommand
|
||||||
@@ -216,7 +313,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Command = new MxCommand
|
Command = new MxCommand
|
||||||
{
|
{
|
||||||
Kind = MxCommandKind.SubscribeAlarms,
|
Kind = MxCommandKind.SubscribeAlarms,
|
||||||
SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = subscription },
|
SubscribeAlarms = command,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
@@ -310,6 +407,104 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handles the worker's provider-mode-change event: updates the stored provider
|
||||||
|
// status, broadcasts it to every subscriber (provider status is global, not
|
||||||
|
// alarm-scoped), records the switch metric, and forces a cache reconcile so the
|
||||||
|
// active-alarm set reflects whatever the new mode reports.
|
||||||
|
private async Task ApplyProviderModeChangeAsync(
|
||||||
|
string sessionId,
|
||||||
|
OnAlarmProviderModeChangedEvent change,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
AlarmProviderMode toMode = change.Mode;
|
||||||
|
string reason = change.Reason ?? string.Empty;
|
||||||
|
|
||||||
|
AlarmProviderStatus status;
|
||||||
|
int fromModeInt;
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
fromModeInt = ModeToInt(_providerMode);
|
||||||
|
_providerMode = toMode;
|
||||||
|
_providerDegraded = toMode == AlarmProviderMode.Subtag;
|
||||||
|
_providerReason = reason;
|
||||||
|
_providerSince = DateTimeOffset.UtcNow;
|
||||||
|
status = BuildProviderStatus();
|
||||||
|
BroadcastToAll(new AlarmFeedMessage { ProviderStatus = status });
|
||||||
|
}
|
||||||
|
|
||||||
|
AlarmProviderSwitchReason switchReason = toMode switch
|
||||||
|
{
|
||||||
|
AlarmProviderMode.Subtag => AlarmProviderSwitchReason.Failover,
|
||||||
|
AlarmProviderMode.Alarmmgr => AlarmProviderSwitchReason.Failback,
|
||||||
|
_ => AlarmProviderSwitchReason.Unknown,
|
||||||
|
};
|
||||||
|
_metrics.AlarmProviderSwitched(fromModeInt, ModeToInt(toMode), switchReason);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Alarm provider mode changed to {Mode} (degraded={Degraded}): {Reason}",
|
||||||
|
toMode,
|
||||||
|
status.Degraded,
|
||||||
|
reason);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Intentionally awaited OUTSIDE _sync: ReconcileAsync acquires _sync itself,
|
||||||
|
// so holding it across the await here would deadlock. Subscribers therefore
|
||||||
|
// see the ProviderStatus push (above) slightly before the cache is re-seeded
|
||||||
|
// by the reconcile — an accepted brief inconsistency.
|
||||||
|
await ReconcileAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
exception,
|
||||||
|
"Reconcile after alarm provider mode change failed; keeping the current cache.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller holds _sync. Builds an AlarmProviderStatus snapshot of the current state.
|
||||||
|
private AlarmProviderStatus BuildProviderStatus()
|
||||||
|
{
|
||||||
|
return new AlarmProviderStatus
|
||||||
|
{
|
||||||
|
Mode = _providerMode,
|
||||||
|
Degraded = _providerDegraded,
|
||||||
|
Reason = _providerReason,
|
||||||
|
Since = Timestamp.FromDateTimeOffset(_providerSince),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps the configured fallback mode string to the forced provider mode the
|
||||||
|
// worker honours. Case-insensitive; anything other than the two force values
|
||||||
|
// (including the default "Auto") yields Unspecified ("let the worker decide").
|
||||||
|
private static AlarmProviderMode MapForcedMode(string? mode)
|
||||||
|
{
|
||||||
|
if (string.Equals(mode, "ForceAlarmManager", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return AlarmProviderMode.Alarmmgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return AlarmProviderMode.Subtag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlarmProviderMode.Unspecified;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps the provider-mode enum to the integer the metric expects
|
||||||
|
// (alarmmgr=1, subtag=2, unknown/unspecified=0).
|
||||||
|
private static int ModeToInt(AlarmProviderMode mode) => mode switch
|
||||||
|
{
|
||||||
|
AlarmProviderMode.Alarmmgr => 1,
|
||||||
|
AlarmProviderMode.Subtag => 2,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
||||||
// a synthetic transition for any alarm the live stream missed.
|
// a synthetic transition for any alarm the live stream missed.
|
||||||
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
|
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
|
||||||
@@ -374,6 +569,23 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caller holds _sync. Pushes a feed message to every subscriber regardless of
|
||||||
|
// its alarm-filter prefix. Used for provider-status messages, which are global
|
||||||
|
// rather than scoped to a single alarm reference.
|
||||||
|
private void BroadcastToAll(AlarmFeedMessage message)
|
||||||
|
{
|
||||||
|
for (int index = _subscribers.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
Subscriber subscriber = _subscribers[index];
|
||||||
|
if (!subscriber.Channel.Writer.TryWrite(message))
|
||||||
|
{
|
||||||
|
subscriber.Channel.Writer.TryComplete(new InvalidOperationException(
|
||||||
|
"Alarm feed subscriber fell behind and was dropped; reconnect to re-snapshot."));
|
||||||
|
_subscribers.RemoveAt(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ClearCache()
|
private void ClearCache()
|
||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
@@ -398,11 +610,14 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Subscriber subscriber = new(channel, prefix);
|
Subscriber subscriber = new(channel, prefix);
|
||||||
|
|
||||||
ActiveAlarmSnapshot[] snapshot;
|
ActiveAlarmSnapshot[] snapshot;
|
||||||
|
AlarmProviderStatus providerStatus;
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
// Register before snapshotting under the same lock so no transition
|
// Register before snapshotting under the same lock so neither a
|
||||||
// can slip between the snapshot and the live stream.
|
// transition nor a provider-mode change can slip between the snapshot
|
||||||
|
// and the live stream.
|
||||||
_subscribers.Add(subscriber);
|
_subscribers.Add(subscriber);
|
||||||
|
providerStatus = BuildProviderStatus();
|
||||||
snapshot = _alarms.Values
|
snapshot = _alarms.Values
|
||||||
.Where(alarm => prefix.Length == 0
|
.Where(alarm => prefix.Length == 0
|
||||||
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
|
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
|
||||||
@@ -412,6 +627,10 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Emit the current provider status first so a late joiner immediately
|
||||||
|
// learns the mode (and whether the feed is degraded) before any alarms.
|
||||||
|
yield return new AlarmFeedMessage { ProviderStatus = providerStatus };
|
||||||
|
|
||||||
foreach (ActiveAlarmSnapshot alarm in snapshot)
|
foreach (ActiveAlarmSnapshot alarm in snapshot)
|
||||||
{
|
{
|
||||||
yield return new AlarmFeedMessage { ActiveAlarm = alarm };
|
yield return new AlarmFeedMessage { ActiveAlarm = alarm };
|
||||||
@@ -624,6 +843,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Description = transition.Description,
|
Description = transition.Description,
|
||||||
OperatorUser = transition.OperatorUser,
|
OperatorUser = transition.OperatorUser,
|
||||||
OperatorComment = transition.OperatorComment,
|
OperatorComment = transition.OperatorComment,
|
||||||
|
Degraded = transition.Degraded,
|
||||||
|
SourceProvider = transition.SourceProvider,
|
||||||
};
|
};
|
||||||
if (transition.OriginalRaiseTimestamp is not null)
|
if (transition.OriginalRaiseTimestamp is not null)
|
||||||
{
|
{
|
||||||
@@ -660,6 +881,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Description = snapshot.Description,
|
Description = snapshot.Description,
|
||||||
OperatorUser = snapshot.OperatorUser,
|
OperatorUser = snapshot.OperatorUser,
|
||||||
OperatorComment = snapshot.OperatorComment,
|
OperatorComment = snapshot.OperatorComment,
|
||||||
|
Degraded = snapshot.Degraded,
|
||||||
|
SourceProvider = snapshot.SourceProvider,
|
||||||
};
|
};
|
||||||
if (snapshot.OriginalRaiseTimestamp is not null)
|
if (snapshot.OriginalRaiseTimestamp is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the subtag watch-list the gateway sends to the worker when the
|
||||||
|
/// central alarm monitor operates in subtag-polling fallback mode. Merges
|
||||||
|
/// Galaxy Repository alarm-attribute discovery with the configured
|
||||||
|
/// include/exclude overrides and composes the per-attribute subtag item
|
||||||
|
/// addresses from the configured subtag names.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAlarmWatchListResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the subtag watch-list for the supplied alarm configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Alarm configuration carrying discovery and subtag-name settings.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The resolved <see cref="AlarmSubtagTarget"/> watch-list, possibly empty.
|
||||||
|
/// Discovery being unavailable never throws — it yields an empty (or
|
||||||
|
/// config-only) list and the caller decides what to do with it. Cancellation
|
||||||
|
/// is the one exception: a triggered <paramref name="cancellationToken"/>
|
||||||
|
/// still propagates an <see cref="OperationCanceledException"/>.
|
||||||
|
/// </returns>
|
||||||
|
Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
|
||||||
|
AlarmsOptions options,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls how the central alarm monitor selects between the MXAccess
|
||||||
|
/// alarm-manager subscription and the subtag-polling fallback, and
|
||||||
|
/// governs the failure-detection thresholds used when switching.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmFallbackOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Selects the operating mode for the alarm-manager ↔ subtag fallback
|
||||||
|
/// mechanism. Accepted values (case-insensitive):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>Auto</c> — use the alarm manager; switch to subtag polling
|
||||||
|
/// automatically when <see cref="ConsecutiveFailureThreshold"/> failures
|
||||||
|
/// are detected, and probe for failback.</item>
|
||||||
|
/// <item><c>ForceAlarmManager</c> — always use the alarm manager;
|
||||||
|
/// never fall back.</item>
|
||||||
|
/// <item><c>ForceSubtag</c> — always use subtag polling;
|
||||||
|
/// never try the alarm manager.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Default is <c>Auto</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Mode { get; init; } = "Auto";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of consecutive alarm-manager failures before the monitor
|
||||||
|
/// switches to subtag-polling fallback. Must be at least 1. Default 3.
|
||||||
|
/// </summary>
|
||||||
|
public int ConsecutiveFailureThreshold { get; init; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How often (in seconds) the monitor sends a probe to the alarm manager
|
||||||
|
/// while operating in subtag-polling fallback mode, to detect recovery.
|
||||||
|
/// Must be at least 1. Default 30.
|
||||||
|
/// </summary>
|
||||||
|
public int FailbackProbeIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of consecutive successful probes required before the monitor
|
||||||
|
/// considers the alarm manager recovered and switches back. Must be at
|
||||||
|
/// least 1. Default 3.
|
||||||
|
/// </summary>
|
||||||
|
public int FailbackStableProbes { get; init; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls how the monitor discovers the set of objects to poll when
|
||||||
|
/// operating in subtag-polling fallback mode.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmDiscoveryOptions Discovery { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the subtag names the monitor reads when polling alarm state
|
||||||
|
/// in subtag-fallback mode.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmSubtagNameOptions Subtags { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Governs how the alarm monitor discovers objects to include in subtag-polling
|
||||||
|
/// fallback mode. Either the Galaxy Repository query (when
|
||||||
|
/// <see cref="UseGalaxyRepository"/> is <c>true</c>) or an explicit
|
||||||
|
/// <see cref="IncludeAttributes"/> list must be supplied when
|
||||||
|
/// <c>MxGateway:Alarms:Fallback:Mode</c> is <c>ForceSubtag</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmDiscoveryOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When <c>true</c> the monitor queries the Galaxy Repository SQL database
|
||||||
|
/// to enumerate alarm objects for the configured area. Default <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseGalaxyRepository { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Galaxy area to scope the Repository query to. When empty the monitor
|
||||||
|
/// falls back to <see cref="AlarmsOptions.DefaultArea"/>. Ignored when
|
||||||
|
/// <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Area { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Explicit list of MXAccess attribute paths to include in subtag polling,
|
||||||
|
/// supplementing (or replacing, when <see cref="UseGalaxyRepository"/> is
|
||||||
|
/// <c>false</c>) the Repository-derived list. Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string[] IncludeAttributes { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attribute paths to exclude from the Repository-derived poll list.
|
||||||
|
/// Ignored when <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
||||||
|
/// Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string[] ExcludeAttributes { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the subtag names read by the alarm monitor when it is operating
|
||||||
|
/// in subtag-polling fallback mode. Names are matched against MXAccess item
|
||||||
|
/// handles; validation against the live MXAccess attribute list occurs at
|
||||||
|
/// runtime, not at startup.
|
||||||
|
/// Defaults are the confirmed AVEVA <c>AlarmExtension</c> primitive field names,
|
||||||
|
/// verified against the live ZB Galaxy <c>attribute_definition</c> rows.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmSubtagNameOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the in-alarm boolean. Confirmed AVEVA <c>AlarmExtension</c>
|
||||||
|
/// field name. Default <c>InAlarm</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Active { get; init; } = "InAlarm";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the acknowledged boolean. Confirmed AVEVA <c>AlarmExtension</c>
|
||||||
|
/// field name. Default <c>Acked</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Acked { get; init; } = "Acked";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the acknowledgement comment write target. Writing this subtag
|
||||||
|
/// performs the acknowledge in AVEVA. Confirmed AVEVA <c>AlarmExtension</c>
|
||||||
|
/// field name. When empty the ack-comment write path is disabled.
|
||||||
|
/// Default <c>AckMsg</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string AckComment { get; init; } = "AckMsg";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the alarm priority / severity. Confirmed AVEVA
|
||||||
|
/// <c>AlarmExtension</c> field name. Default <c>Priority</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Priority { get; init; } = "Priority";
|
||||||
|
}
|
||||||
@@ -45,4 +45,12 @@ public sealed class AlarmsOptions
|
|||||||
/// the monitor floors it at 5 seconds.
|
/// the monitor floors it at 5 seconds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReconcileIntervalSeconds { get; init; } = 30;
|
public int ReconcileIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
||||||
|
/// operating mode, failure-detection thresholds, discovery, and subtag
|
||||||
|
/// names. Defaults (Mode = "Auto") preserve behaviour when the section is
|
||||||
|
/// omitted from configuration.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmFallbackOptions Fallback { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ public sealed class DashboardOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RequireHttpsCookie { get; init; } = true;
|
public bool RequireHttpsCookie { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dashboard auth cookie name. When null/blank (the default) the canonical
|
||||||
|
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardAuthenticationDefaults.CookieName"/>
|
||||||
|
/// is used. Override it (<c>MxGateway:Dashboard:CookieName</c>) to give a distinct name to a
|
||||||
|
/// gateway that shares a hostname with another gateway instance — browser cookies are scoped
|
||||||
|
/// by host+path but NOT by port, so two instances on the same host would otherwise clobber
|
||||||
|
/// each other's dashboard session under a shared cookie name. Changing this signs out
|
||||||
|
/// existing dashboard sessions on next deploy.
|
||||||
|
/// </summary>
|
||||||
|
public string? CookieName { get; init; }
|
||||||
|
|
||||||
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
|
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
|
||||||
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ public sealed record EffectiveLdapConfiguration(
|
|||||||
bool Enabled,
|
bool Enabled,
|
||||||
string Server,
|
string Server,
|
||||||
int Port,
|
int Port,
|
||||||
bool UseTls,
|
string Transport,
|
||||||
bool AllowInsecureLdap,
|
bool AllowInsecure,
|
||||||
string SearchBase,
|
string SearchBase,
|
||||||
string ServiceAccountDn,
|
string ServiceAccountDn,
|
||||||
string ServiceAccountPassword,
|
string ServiceAccountPassword,
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
|||||||
Enabled: value.Ldap.Enabled,
|
Enabled: value.Ldap.Enabled,
|
||||||
Server: value.Ldap.Server,
|
Server: value.Ldap.Server,
|
||||||
Port: value.Ldap.Port,
|
Port: value.Ldap.Port,
|
||||||
UseTls: value.Ldap.UseTls,
|
Transport: value.Ldap.Transport.ToString(),
|
||||||
AllowInsecureLdap: value.Ldap.AllowInsecureLdap,
|
AllowInsecure: value.Ldap.AllowInsecure,
|
||||||
SearchBase: value.Ldap.SearchBase,
|
SearchBase: value.Ldap.SearchBase,
|
||||||
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
||||||
ServiceAccountPassword: RedactedValue,
|
ServiceAccountPassword: RedactedValue,
|
||||||
|
|||||||
+7
-7
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
@@ -6,15 +7,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
|
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
|
||||||
/// <param name="services">The service collection.</param>
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <param name="configuration">The configuration to bind gateway options from.</param>
|
||||||
/// <returns>The service collection for chaining.</returns>
|
/// <returns>The service collection for chaining.</returns>
|
||||||
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services)
|
public static IServiceCollection AddGatewayConfiguration(
|
||||||
|
this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services
|
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
|
||||||
.AddOptions<GatewayOptions>()
|
configuration, GatewayOptions.SectionName);
|
||||||
.BindConfiguration(GatewayOptions.SectionName)
|
|
||||||
.ValidateOnStart();
|
|
||||||
|
|
||||||
services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
|
|
||||||
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -43,4 +43,7 @@ public sealed class GatewayOptions
|
|||||||
/// behaviour (alarms disabled).
|
/// behaviour (alarms disabled).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AlarmsOptions Alarms { get; init; } = new();
|
public AlarmsOptions Alarms { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
|
||||||
|
public TlsOptions Tls { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions>
|
||||||
{
|
{
|
||||||
private const int MinimumMaxMessageBytes = 1024;
|
private const int MinimumMaxMessageBytes = 1024;
|
||||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||||
@@ -11,32 +12,26 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates gateway configuration options.
|
/// Validates gateway configuration options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="name">Options name.</param>
|
/// <param name="builder">The accumulator to record failures on.</param>
|
||||||
/// <param name="options">Gateway options to validate.</param>
|
/// <param name="options">Gateway options to validate.</param>
|
||||||
/// <returns>Validation result.</returns>
|
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
|
||||||
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
|
|
||||||
{
|
{
|
||||||
List<string> failures = [];
|
ValidateAuthentication(options.Authentication, builder);
|
||||||
|
ValidateLdap(options.Ldap, builder);
|
||||||
ValidateAuthentication(options.Authentication, failures);
|
ValidateWorker(options.Worker, builder);
|
||||||
ValidateLdap(options.Ldap, failures);
|
ValidateSessions(options.Sessions, builder);
|
||||||
ValidateWorker(options.Worker, failures);
|
ValidateEvents(options.Events, builder);
|
||||||
ValidateSessions(options.Sessions, failures);
|
ValidateDashboard(options.Dashboard, builder);
|
||||||
ValidateEvents(options.Events, failures);
|
ValidateProtocol(options.Protocol, builder);
|
||||||
ValidateDashboard(options.Dashboard, failures);
|
ValidateAlarms(options.Alarms, builder);
|
||||||
ValidateProtocol(options.Protocol, failures);
|
ValidateTls(options.Tls, builder);
|
||||||
ValidateAlarms(options.Alarms, failures);
|
|
||||||
|
|
||||||
return failures.Count == 0
|
|
||||||
? ValidateOptionsResult.Success
|
|
||||||
: ValidateOptionsResult.Fail(failures);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures)
|
private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!Enum.IsDefined(options.Mode))
|
if (!Enum.IsDefined(options.Mode))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
builder.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,67 +40,67 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.SqlitePath,
|
options.SqlitePath,
|
||||||
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.SqlitePath,
|
options.SqlitePath,
|
||||||
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.PepperSecretName,
|
options.PepperSecretName,
|
||||||
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateLdap(LdapOptions options, List<string> failures)
|
private static void ValidateLdap(LdapOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures);
|
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", builder);
|
||||||
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures);
|
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.ServiceAccountDn,
|
options.ServiceAccountDn,
|
||||||
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.ServiceAccountPassword,
|
options.ServiceAccountPassword,
|
||||||
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.UserNameAttribute,
|
options.UserNameAttribute,
|
||||||
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.DisplayNameAttribute,
|
options.DisplayNameAttribute,
|
||||||
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.GroupAttribute,
|
options.GroupAttribute,
|
||||||
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
|
builder.Port(options.Port, "MxGateway:Ldap:Port");
|
||||||
|
|
||||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.ExecutablePath,
|
options.ExecutablePath,
|
||||||
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
||||||
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
builder.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
||||||
@@ -113,94 +108,94 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.WorkingDirectory,
|
options.WorkingDirectory,
|
||||||
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Enum.IsDefined(options.RequiredArchitecture))
|
if (!Enum.IsDefined(options.RequiredArchitecture))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
builder.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
||||||
}
|
}
|
||||||
|
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupTimeoutSeconds,
|
options.StartupTimeoutSeconds,
|
||||||
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupProbeRetryAttempts,
|
options.StartupProbeRetryAttempts,
|
||||||
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupProbeRetryDelayMilliseconds,
|
options.StartupProbeRetryDelayMilliseconds,
|
||||||
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.PipeConnectAttemptTimeoutMilliseconds,
|
options.PipeConnectAttemptTimeoutMilliseconds,
|
||||||
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.ShutdownTimeoutSeconds,
|
options.ShutdownTimeoutSeconds,
|
||||||
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.HeartbeatIntervalSeconds,
|
options.HeartbeatIntervalSeconds,
|
||||||
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.HeartbeatGraceSeconds,
|
options.HeartbeatGraceSeconds,
|
||||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateSessions(SessionOptions options, List<string> failures)
|
private static void ValidateSessions(SessionOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.DefaultCommandTimeoutSeconds,
|
options.DefaultCommandTimeoutSeconds,
|
||||||
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures);
|
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.MaxPendingCommandsPerSession,
|
options.MaxPendingCommandsPerSession,
|
||||||
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.DefaultLeaseSeconds,
|
options.DefaultLeaseSeconds,
|
||||||
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.LeaseSweepIntervalSeconds,
|
options.LeaseSweepIntervalSeconds,
|
||||||
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (options.AllowMultipleEventSubscribers)
|
if (options.AllowMultipleEventSubscribers)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateEvents(EventOptions options, List<string> failures)
|
private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures);
|
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);
|
||||||
|
|
||||||
if (!Enum.IsDefined(options.BackpressurePolicy))
|
if (!Enum.IsDefined(options.BackpressurePolicy))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
|
private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
// GroupToRole shape is validated even when the dashboard is disabled so
|
// GroupToRole shape is validated even when the dashboard is disabled so
|
||||||
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
||||||
@@ -211,13 +206,13 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(entry.Key))
|
if (string.IsNullOrWhiteSpace(entry.Key))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
|
builder.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
|
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
|
||||||
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
|
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
|
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,18 +220,20 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.SnapshotIntervalMilliseconds,
|
options.SnapshotIntervalMilliseconds,
|
||||||
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNegative(
|
AddIfNegative(
|
||||||
options.RecentFaultLimit,
|
options.RecentFaultLimit,
|
||||||
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNegative(
|
AddIfNegative(
|
||||||
options.RecentSessionLimit,
|
options.RecentSessionLimit,
|
||||||
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateAlarms(AlarmsOptions options, List<string> failures)
|
private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
|
||||||
|
|
||||||
|
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
@@ -250,58 +247,119 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||||
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||||
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ValidateAlarmFallback(options.Fallback, builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateAlarmFallback(AlarmFallbackOptions fallback, ValidationBuilder builder)
|
||||||
|
{
|
||||||
|
// Validate Mode is one of the recognised values (case-insensitive).
|
||||||
|
bool modeValid = Array.Exists(
|
||||||
|
ValidAlarmFallbackModes,
|
||||||
|
m => string.Equals(m, fallback.Mode, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (!modeValid)
|
||||||
|
{
|
||||||
|
builder.Add(
|
||||||
|
$"MxGateway:Alarms:Fallback:Mode must be one of: {string.Join(", ", ValidAlarmFallbackModes)} (was '{fallback.Mode}').");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceSubtag requires either Galaxy Repository discovery or an explicit IncludeAttributes list.
|
||||||
|
if (modeValid
|
||||||
|
&& string.Equals(fallback.Mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !fallback.Discovery.UseGalaxyRepository
|
||||||
|
&& fallback.Discovery.IncludeAttributes.Length == 0)
|
||||||
|
{
|
||||||
|
builder.Add(
|
||||||
|
"MxGateway:Alarms:Fallback ForceSubtag requires Galaxy Repository discovery or a non-empty Discovery:IncludeAttributes list.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor validation: numeric thresholds must be at least 1.
|
||||||
|
AddIfNotPositive(
|
||||||
|
fallback.ConsecutiveFailureThreshold,
|
||||||
|
"MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold must be greater than zero.",
|
||||||
|
builder);
|
||||||
|
AddIfNotPositive(
|
||||||
|
fallback.FailbackProbeIntervalSeconds,
|
||||||
|
"MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds must be greater than zero.",
|
||||||
|
builder);
|
||||||
|
AddIfNotPositive(
|
||||||
|
fallback.FailbackStableProbes,
|
||||||
|
"MxGateway:Alarms:Fallback:FailbackStableProbes must be greater than zero.",
|
||||||
|
builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int MinimumCertValidityYears = 1;
|
||||||
|
private const int MaximumCertValidityYears = 100;
|
||||||
|
|
||||||
|
private static void ValidateTls(TlsOptions options, ValidationBuilder builder)
|
||||||
|
{
|
||||||
|
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
|
||||||
|
{
|
||||||
|
builder.Add(
|
||||||
|
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default is non-blank, so this only catches an explicitly-blanked path.
|
||||||
|
AddIfBlank(
|
||||||
|
options.SelfSignedCertPath,
|
||||||
|
"MxGateway:Tls:SelfSignedCertPath must not be blank.",
|
||||||
|
builder);
|
||||||
|
AddIfInvalidPath(
|
||||||
|
options.SelfSignedCertPath,
|
||||||
|
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
||||||
|
builder);
|
||||||
|
|
||||||
|
foreach (string dns in options.AdditionalDnsNames)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dns))
|
||||||
|
{
|
||||||
|
builder.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateProtocol(ProtocolOptions options, List<string> failures)
|
private static void ValidateProtocol(ProtocolOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfBlank(string? value, string message, List<string> failures)
|
private static void AddIfBlank(string? value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfNotPositive(int value, string message, List<string> failures)
|
private static void AddIfNotPositive(int value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (value <= 0)
|
builder.RequireThat(value > 0, message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfNegative(int value, string message, List<string> failures)
|
private static void AddIfNegative(int value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (value < 0)
|
builder.RequireThat(value >= 0, message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfInvalidPath(string? value, string message, List<string> failures)
|
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
{
|
{
|
||||||
@@ -314,15 +372,19 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
catch (NotSupportedException)
|
catch (NotSupportedException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
catch (PathTooLongException)
|
catch (PathTooLongException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gateway-side view of the <c>MxGateway:Ldap</c> section. This is a SHADOW of the
|
||||||
|
/// shared <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> type and is NOT
|
||||||
|
/// used to perform LDAP authentication at runtime — runtime bind/search is done by the
|
||||||
|
/// shared <c>ZB.MOM.WW.Auth.Ldap</c> provider, whose options are bound directly from the
|
||||||
|
/// same <c>MxGateway:Ldap</c> section by <c>AddZbLdapAuth</c> (see
|
||||||
|
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardServiceCollectionExtensions"/>).
|
||||||
|
/// <para>
|
||||||
|
/// This shadow exists for three things only: (1) startup validation via
|
||||||
|
/// <see cref="GatewayOptionsValidator"/>; (2) the redacted effective-config display
|
||||||
|
/// (<see cref="EffectiveLdapConfiguration"/> / <see cref="GatewayConfigurationProvider"/>);
|
||||||
|
/// and (3) it is the single home of the gateway's dev/default LDAP values, which the
|
||||||
|
/// integration live-test helper copies onto the shared options.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Review C2 — DRIFT WARNING: this class MUST stay field-compatible with the shared
|
||||||
|
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> so the one config section
|
||||||
|
/// binds cleanly onto both. The two are intentionally NOT merged because their defaults
|
||||||
|
/// differ on purpose: this shadow ships dev-friendly defaults (plaintext localhost,
|
||||||
|
/// <c>AllowInsecure=true</c>, populated <c>SearchBase</c>/<c>ServiceAccount*</c>), whereas
|
||||||
|
/// the shared type is secure-by-default (<c>Transport=Ldaps</c>, <c>AllowInsecure=false</c>,
|
||||||
|
/// empty DN fields). If you add/rename/remove a field on the shared type, mirror it here
|
||||||
|
/// (and in the validator + effective-config) so the section keeps binding to both.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
public sealed class LdapOptions
|
public sealed class LdapOptions
|
||||||
{
|
{
|
||||||
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
|
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
|
||||||
@@ -11,17 +38,24 @@ public sealed class LdapOptions
|
|||||||
/// <summary>Gets the LDAP server port.</summary>
|
/// <summary>Gets the LDAP server port.</summary>
|
||||||
public int Port { get; init; } = 3893;
|
public int Port { get; init; } = 3893;
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary>
|
/// <summary>
|
||||||
public bool UseTls { get; init; }
|
/// Gets the transport/TLS mode for the LDAP connection. Replaces the former
|
||||||
|
/// boolean <c>UseTls</c> (true ≈ <see cref="LdapTransport.Ldaps"/>, false =
|
||||||
|
/// <see cref="LdapTransport.None"/>). <see cref="LdapTransport.StartTls"/> upgrades
|
||||||
|
/// a plaintext connection to TLS. Matches the shared
|
||||||
|
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions.Transport"/> field so the
|
||||||
|
/// <c>MxGateway:Ldap</c> section binds straight onto the shared options.
|
||||||
|
/// </summary>
|
||||||
|
public LdapTransport Transport { get; init; } = LdapTransport.None;
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary>
|
/// <summary>Gets a value indicating whether insecure (plaintext) LDAP connections are allowed.</summary>
|
||||||
public bool AllowInsecureLdap { get; init; } = true;
|
public bool AllowInsecure { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>Gets the LDAP search base distinguished name.</summary>
|
/// <summary>Gets the LDAP search base distinguished name.</summary>
|
||||||
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
|
public string SearchBase { get; init; } = "dc=zb,dc=local";
|
||||||
|
|
||||||
/// <summary>Gets the service account distinguished name.</summary>
|
/// <summary>Gets the service account distinguished name.</summary>
|
||||||
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local";
|
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=zb,dc=local";
|
||||||
|
|
||||||
/// <summary>Gets the service account password.</summary>
|
/// <summary>Gets the service account password.</summary>
|
||||||
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
|
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Options controlling the gateway's self-signed certificate auto-generation.
|
||||||
|
/// Only consulted when a Kestrel HTTPS endpoint is configured without its own
|
||||||
|
/// certificate; plaintext deployments never trigger generation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TlsOptions
|
||||||
|
{
|
||||||
|
/// <summary>Path to the persisted self-signed PFX. Reused across restarts.</summary>
|
||||||
|
public string SelfSignedCertPath { get; init; } =
|
||||||
|
@"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx";
|
||||||
|
|
||||||
|
/// <summary>Lifetime in years of a freshly generated certificate.</summary>
|
||||||
|
public int ValidityYears { get; init; } = 10;
|
||||||
|
|
||||||
|
/// <summary>Extra DNS SANs to embed (e.g. a load-balancer name).</summary>
|
||||||
|
public IReadOnlyList<string> AdditionalDnsNames { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Regenerate the persisted certificate when it has expired.</summary>
|
||||||
|
public bool RegenerateIfExpired { get; init; } = true;
|
||||||
|
}
|
||||||
@@ -5,14 +5,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="/css/theme.css" />
|
<ThemeHead />
|
||||||
<link rel="stylesheet" href="/css/site.css" />
|
<link rel="stylesheet" href="/css/site.css" />
|
||||||
<HeadOutlet @rendermode="InteractiveServer" />
|
<HeadOutlet @rendermode="InteractiveServer" />
|
||||||
</head>
|
</head>
|
||||||
<body class="dashboard-body">
|
<body class="dashboard-body">
|
||||||
<Routes @rendermode="InteractiveServer" />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/js/nav-state.js"></script>
|
<ThemeScripts />
|
||||||
<script src="/_framework/blazor.web.js"></script>
|
<script src="/_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@* Minimal layout for the login page: no side rail, no brand block. The page
|
||||||
|
renders its own centred card via the shared kit's <LoginCard>. Mirrors
|
||||||
|
OtOpcUa AdminUI's LoginLayout. *@
|
||||||
|
@Body
|
||||||
@@ -1,210 +1,40 @@
|
|||||||
@using System.Linq
|
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
|
||||||
@using Microsoft.JSInterop
|
|
||||||
@implements IDisposable
|
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@inject NavigationManager Navigation
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
|
|
||||||
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
|
@* Thin layout: delegates the side-rail chassis (hamburger, brand, responsive
|
||||||
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS
|
collapse) to the shared ZB.MOM.WW.Theme <ThemeShell>. The nav is reproduced
|
||||||
lives in bootstrap.bundle.min.js (loaded in App.razor). *@
|
with the kit's NavRailSection / NavRailItem; section expand-state persistence
|
||||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
is owned by the kit's <details> + ThemeScripts (no JS interop here). *@
|
||||||
type="button"
|
<ThemeShell Product="MXAccess Gateway" Accent="#2f5fd0">
|
||||||
data-bs-toggle="collapse"
|
<Nav>
|
||||||
data-bs-target="#sidebar-collapse"
|
<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
|
||||||
aria-controls="sidebar-collapse"
|
<NavRailSection Title="Runtime" Key="runtime">
|
||||||
aria-expanded="false"
|
<NavRailItem Href="/sessions" Text="Sessions" />
|
||||||
aria-label="Toggle navigation">
|
<NavRailItem Href="/workers" Text="Workers" />
|
||||||
☰
|
<NavRailItem Href="/events" Text="Events" />
|
||||||
</button>
|
<NavRailItem Href="/alarms" Text="Alarms" />
|
||||||
|
</NavRailSection>
|
||||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
<NavRailSection Title="Galaxy" Key="galaxy">
|
||||||
<nav class="sidebar d-flex flex-column">
|
<NavRailItem Href="/galaxy" Text="Repository" />
|
||||||
<a class="brand" href="/"><span class="mark">▮</span> MXAccess Gateway</a>
|
<NavRailItem Href="/browse" Text="Browse" />
|
||||||
|
</NavRailSection>
|
||||||
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
|
<NavRailSection Title="Admin" Key="admin">
|
||||||
<ul class="nav flex-column">
|
<NavRailItem Href="/apikeys" Text="API Keys" />
|
||||||
<li class="nav-item">
|
<NavRailItem Href="/settings" Text="Settings" />
|
||||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
</NavRailSection>
|
||||||
</li>
|
</Nav>
|
||||||
|
<RailFooter>
|
||||||
<NavSection Title="Runtime"
|
<AuthorizeView>
|
||||||
Expanded="@_expanded.Contains("runtime")"
|
<Authorized Context="authState">
|
||||||
OnToggle="@(() => ToggleAsync("runtime"))">
|
<span class="rail-user">@authState.User.Identity?.Name</span>
|
||||||
<li class="nav-item">
|
<form method="post" action="/logout" data-enhance="false">
|
||||||
<NavLink class="nav-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
|
<AntiforgeryToken />
|
||||||
</li>
|
<button class="rail-btn" type="submit">Sign Out</button>
|
||||||
<li class="nav-item">
|
</form>
|
||||||
<NavLink class="nav-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
|
</Authorized>
|
||||||
</li>
|
<NotAuthorized>
|
||||||
<li class="nav-item">
|
<a class="rail-btn" href="/login">Sign In</a>
|
||||||
<NavLink class="nav-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
|
</NotAuthorized>
|
||||||
</li>
|
</AuthorizeView>
|
||||||
<li class="nav-item">
|
</RailFooter>
|
||||||
<NavLink class="nav-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
|
<ChildContent>@Body</ChildContent>
|
||||||
</li>
|
</ThemeShell>
|
||||||
</NavSection>
|
|
||||||
|
|
||||||
<NavSection Title="Galaxy"
|
|
||||||
Expanded="@_expanded.Contains("galaxy")"
|
|
||||||
OnToggle="@(() => ToggleAsync("galaxy"))">
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
|
|
||||||
</li>
|
|
||||||
</NavSection>
|
|
||||||
|
|
||||||
<NavSection Title="Admin"
|
|
||||||
Expanded="@_expanded.Contains("admin")"
|
|
||||||
OnToggle="@(() => ToggleAsync("admin"))">
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/apikeys" Match="NavLinkMatch.Prefix">API Keys</NavLink>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
|
|
||||||
</li>
|
|
||||||
</NavSection>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AuthorizeView>
|
|
||||||
<Authorized Context="authState">
|
|
||||||
<div class="border-top px-3 py-2">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<span class="text-body-secondary small">@authState.User.Identity?.Name</span>
|
|
||||||
<form method="post" action="/logout" data-enhance="false">
|
|
||||||
<AntiforgeryToken />
|
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Authorized>
|
|
||||||
<NotAuthorized>
|
|
||||||
<div class="border-top px-3 py-2">
|
|
||||||
<a href="/login" class="btn btn-outline-secondary btn-sm py-0 px-2 w-100">Sign In</a>
|
|
||||||
</div>
|
|
||||||
</NotAuthorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="page flex-grow-1">
|
|
||||||
@Body
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
// Sections whose collapsed/expanded state we persist. Acts as the allow-list
|
|
||||||
// when parsing the cookie so stale or attacker-supplied ids are ignored.
|
|
||||||
private static readonly string[] SectionIds = { "runtime", "galaxy", "admin" };
|
|
||||||
|
|
||||||
// The currently-expanded sections. Populated from the cookie on first
|
|
||||||
// render; mutated by ToggleAsync and by navigating into a section.
|
|
||||||
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
Navigation.LocationChanged += OnLocationChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (!firstRender)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hydrate from the cookie. Until this completes the sidebar paints
|
|
||||||
// collapsed, matching the CentralUI behaviour.
|
|
||||||
string saved;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var id in saved.Split(
|
|
||||||
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
||||||
{
|
|
||||||
if (Array.IndexOf(SectionIds, id) >= 0)
|
|
||||||
{
|
|
||||||
_expanded.Add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The section of the page we loaded on is always expanded.
|
|
||||||
if (EnsureCurrentSectionExpanded())
|
|
||||||
{
|
|
||||||
await PersistAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (EnsureCurrentSectionExpanded())
|
|
||||||
{
|
|
||||||
_ = PersistAsync();
|
|
||||||
_ = InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ToggleAsync(string id)
|
|
||||||
{
|
|
||||||
if (!_expanded.Remove(id))
|
|
||||||
{
|
|
||||||
_expanded.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await PersistAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds the current page's section to _expanded; returns true if it changed.
|
|
||||||
private bool EnsureCurrentSectionExpanded()
|
|
||||||
{
|
|
||||||
var section = CurrentSection();
|
|
||||||
return section is not null && _expanded.Add(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maps the current URL's first path segment to a section id, or null for
|
|
||||||
// sectionless pages (Dashboard, Login).
|
|
||||||
private string? CurrentSection()
|
|
||||||
{
|
|
||||||
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
|
||||||
var firstSegment = relative.Split('?', '#')[0]
|
|
||||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
return firstSegment switch
|
|
||||||
{
|
|
||||||
"sessions" or "workers" or "events" or "alarms" => "runtime",
|
|
||||||
"galaxy" or "browse" => "galaxy",
|
|
||||||
"apikeys" or "settings" => "admin",
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PersistAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
// The circuit is gone — nothing to persist to.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Navigation.LocationChanged -= OnLocationChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
@* A collapsible sidebar nav section. The header is a full-width button that
|
|
||||||
toggles ChildContent visibility. Pattern lifted from ScadaLink CentralUI
|
|
||||||
(Components/Layout/NavSection.razor) — see [[project-deployed-service]]. *@
|
|
||||||
|
|
||||||
<li class="nav-item">
|
|
||||||
<button type="button"
|
|
||||||
class="nav-section-toggle"
|
|
||||||
@onclick="OnToggle"
|
|
||||||
aria-expanded="@(Expanded ? "true" : "false")">
|
|
||||||
<span class="chevron" aria-hidden="true">@(Expanded ? "▾" : "▸")</span>
|
|
||||||
<span>@Title</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
@if (Expanded)
|
|
||||||
{
|
|
||||||
@ChildContent
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
/// <summary>Section label shown in the header (e.g. "Runtime").</summary>
|
|
||||||
[Parameter, EditorRequired]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Whether the section is expanded — its items rendered.</summary>
|
|
||||||
[Parameter]
|
|
||||||
public bool Expanded { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Raised when the header button is clicked.</summary>
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback OnToggle { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The section's nav items, rendered only while expanded.</summary>
|
|
||||||
[Parameter]
|
|
||||||
public RenderFragment? ChildContent { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
@page "/alarms"
|
@page "/alarms"
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
@using Microsoft.AspNetCore.SignalR.Client
|
||||||
|
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
|
||||||
@inject IDashboardLiveDataService LiveData
|
@inject IDashboardLiveDataService LiveData
|
||||||
@inject IOptions<GatewayOptions> GatewayOptions
|
@inject IOptions<GatewayOptions> GatewayOptions
|
||||||
|
@inject DashboardHubConnectionFactory HubFactory
|
||||||
|
|
||||||
<PageTitle>Dashboard Alarms</PageTitle>
|
<PageTitle>Dashboard Alarms</PageTitle>
|
||||||
|
|
||||||
@@ -10,6 +13,12 @@
|
|||||||
<h1>Alarms</h1>
|
<h1>Alarms</h1>
|
||||||
<div class="text-secondary">@HeaderLine()</div>
|
<div class="text-secondary">@HeaderLine()</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge @_providerStatus.BadgeCssClass"
|
||||||
|
title="@ProviderStatusTitle()">
|
||||||
|
@_providerStatus.Label
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!GatewayOptions.Value.Alarms.Enabled)
|
@if (!GatewayOptions.Value.Alarms.Enabled)
|
||||||
@@ -163,10 +172,44 @@
|
|||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private Task? _pollTask;
|
private Task? _pollTask;
|
||||||
|
|
||||||
|
private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy;
|
||||||
|
private HubConnection? _alarmsHub;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
_pollTask = PollLoopAsync();
|
_pollTask = PollLoopAsync();
|
||||||
|
_ = AttachAlarmsHubAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ProviderStatusTitle()
|
||||||
|
{
|
||||||
|
return _providerStatus.IsDegraded && !string.IsNullOrWhiteSpace(_providerStatus.Reason)
|
||||||
|
? _providerStatus.Reason
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AttachAlarmsHubAsync()
|
||||||
|
{
|
||||||
|
_alarmsHub = HubFactory.Create("/hubs/alarms");
|
||||||
|
_alarmsHub.On<AlarmFeedMessage>(AlarmsHub.AlarmMessage, async message =>
|
||||||
|
{
|
||||||
|
if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
|
||||||
|
{
|
||||||
|
_providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
|
||||||
|
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _alarmsHub.StartAsync(_cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The badge is best-effort; it stays at the healthy default until
|
||||||
|
// the hub reconnects and delivers a fresh provider-status message.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string HeaderLine()
|
private string HeaderLine()
|
||||||
@@ -268,6 +311,19 @@
|
|||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _cts.CancelAsync();
|
await _cts.CancelAsync();
|
||||||
|
|
||||||
|
if (_alarmsHub is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _alarmsHub.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Disposal-time errors are best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_pollTask is not null)
|
if (_pollTask is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@* Login MUST stay anonymously reachable — [AllowAnonymous] overrides the
|
||||||
|
RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies, so the
|
||||||
|
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
||||||
|
|
||||||
|
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
||||||
|
<form method="post" action="/auth/login"> (username/password + hidden returnUrl). A native
|
||||||
|
form submit is not a Blazor event, so it reaches the minimal-API POST /auth/login endpoint
|
||||||
|
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
|
||||||
|
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks.
|
||||||
|
|
||||||
|
NOTE: the POST target is /auth/login, NOT /login. This @page lives at "/login" and the
|
||||||
|
Razor Components endpoint matches ALL methods, so a POST to /login collided with the
|
||||||
|
minimal-API MapPost("/login") and threw AmbiguousMatchException (HTTP 500). Posting to a
|
||||||
|
distinct /auth/login path (mirroring ScadaBridge) keeps the GET page and POST handler from
|
||||||
|
sharing a route. *@
|
||||||
|
@attribute [AllowAnonymous]
|
||||||
|
|
||||||
|
<LoginCard Product="MXAccess Gateway" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
</LoginCard>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Original protected URL the operator was bounced from; round-tripped to POST /login.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "returnUrl")]
|
||||||
|
private string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Failure message surfaced by POST /login after a failed authentication.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "error")]
|
||||||
|
private string? Error { get; set; }
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ else
|
|||||||
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
||||||
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
||||||
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
||||||
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
|
<tr><th scope="row">LDAP transport</th><td>@Snapshot.Configuration.Ldap.Transport</td></tr>
|
||||||
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
||||||
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
||||||
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<span class="chip @CssClass">@Text</span>
|
@* Thin adapter: maps MxGateway runtime state text → kit StatusPill state.
|
||||||
|
The bespoke .chip rendering now lives in the kit; only the app's domain
|
||||||
|
text→state vocabulary remains here. Call sites (<StatusBadge Text="..."/>) unchanged. *@
|
||||||
|
<StatusPill State="MapState(Text)">@Text</StatusPill>
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter] public string? Text { get; set; }
|
||||||
public string? Text { get; set; }
|
|
||||||
|
|
||||||
private string CssClass => Text switch
|
private static StatusState MapState(string? text) => text switch
|
||||||
{
|
{
|
||||||
"Ready" or "Healthy" or "Active" => "chip-ok",
|
"Ready" or "Healthy" or "Active" => StatusState.Ok,
|
||||||
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn",
|
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing"
|
||||||
"Stale" or "Degraded" => "chip-warn",
|
or "Stale" or "Degraded" => StatusState.Warn,
|
||||||
"Faulted" or "Unavailable" => "chip-bad",
|
"Faulted" or "Unavailable" => StatusState.Bad,
|
||||||
"Closed" or "Revoked" or "Unknown" => "chip-idle",
|
_ => StatusState.Idle,
|
||||||
_ => "chip-idle"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,5 @@
|
|||||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
|
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization
|
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Workers
|
@using ZB.MOM.WW.MxGateway.Server.Workers
|
||||||
|
@using ZB.MOM.WW.Theme
|
||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dashboard projection of an <see cref="AlarmProviderStatus" /> message
|
||||||
|
/// carried on the alarm feed. Maps the protobuf provider mode / degraded
|
||||||
|
/// flag into Bootstrap-only display fields so the Alarms page can render a
|
||||||
|
/// status badge without touching protobuf types.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DashboardAlarmProviderStatus(
|
||||||
|
AlarmProviderMode Mode,
|
||||||
|
bool IsDegraded,
|
||||||
|
string Label,
|
||||||
|
string BadgeCssClass,
|
||||||
|
string Reason,
|
||||||
|
DateTimeOffset? SinceUtc)
|
||||||
|
{
|
||||||
|
/// <summary>Badge label shown when the alarm-manager provider is healthy.</summary>
|
||||||
|
public const string AlarmManagerLabel = "Alarm Manager";
|
||||||
|
|
||||||
|
/// <summary>Badge label shown when the feed has fallen back to subtag monitoring.</summary>
|
||||||
|
public const string DegradedLabel = "Subtag monitoring (degraded)";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Badge label shown when the feed is in subtag monitoring because it was
|
||||||
|
/// deliberately configured (<c>Fallback:Mode=ForceSubtag</c>), as opposed
|
||||||
|
/// to an unexpected failover. A stable, intended state rather than a fault.
|
||||||
|
/// </summary>
|
||||||
|
public const string ForcedSubtagLabel = "Subtag monitoring (forced)";
|
||||||
|
|
||||||
|
private const string HealthyBadge = "bg-success";
|
||||||
|
private const string DegradedBadge = "bg-warning text-dark";
|
||||||
|
|
||||||
|
// Cyan/info badge: visually distinct from the amber failover-degraded badge —
|
||||||
|
// forced subtag is an intentional configuration, not an alarm-manager fault.
|
||||||
|
private const string ForcedSubtagBadge = "bg-info text-dark";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The default status assumed before the first provider-status message
|
||||||
|
/// arrives: healthy alarm-manager mode.
|
||||||
|
/// </summary>
|
||||||
|
public static DashboardAlarmProviderStatus Healthy { get; } = new(
|
||||||
|
Mode: AlarmProviderMode.Alarmmgr,
|
||||||
|
IsDegraded: false,
|
||||||
|
Label: AlarmManagerLabel,
|
||||||
|
BadgeCssClass: HealthyBadge,
|
||||||
|
Reason: string.Empty,
|
||||||
|
SinceUtc: null);
|
||||||
|
|
||||||
|
/// <summary>Projects an alarm-feed provider-status payload into a dashboard badge model.</summary>
|
||||||
|
/// <param name="status">The provider-status payload from an <see cref="AlarmFeedMessage" />.</param>
|
||||||
|
/// <returns>The projected dashboard status.</returns>
|
||||||
|
public static DashboardAlarmProviderStatus FromProviderStatus(AlarmProviderStatus status)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
|
|
||||||
|
// Treat the explicit degraded flag and the SUBTAG mode as equivalent;
|
||||||
|
// the contract sets degraded=true whenever mode == SUBTAG, but guard
|
||||||
|
// against either being set independently.
|
||||||
|
bool degraded = status.Degraded || status.Mode == AlarmProviderMode.Subtag;
|
||||||
|
string reason = status.Reason ?? string.Empty;
|
||||||
|
|
||||||
|
// A configured ForceSubtag start carries the well-known forced reason and
|
||||||
|
// is a deliberate mode, not a failover — render it distinctly so an
|
||||||
|
// operator isn't alarmed by a "(degraded)" badge for an intended config.
|
||||||
|
bool forced = degraded
|
||||||
|
&& status.Mode == AlarmProviderMode.Subtag
|
||||||
|
&& string.Equals(reason, AlarmProviderReasons.ForcedSubtag, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
string label = !degraded ? AlarmManagerLabel : forced ? ForcedSubtagLabel : DegradedLabel;
|
||||||
|
string badge = !degraded ? HealthyBadge : forced ? ForcedSubtagBadge : DegradedBadge;
|
||||||
|
|
||||||
|
return new DashboardAlarmProviderStatus(
|
||||||
|
Mode: status.Mode,
|
||||||
|
IsDegraded: degraded,
|
||||||
|
Label: label,
|
||||||
|
BadgeCssClass: badge,
|
||||||
|
Reason: reason,
|
||||||
|
SinceUtc: status.Since?.ToDateTimeOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects an alarm-feed message into a dashboard badge model.</summary>
|
||||||
|
/// <param name="message">An alarm-feed message whose payload is a provider status.</param>
|
||||||
|
/// <returns>The projected dashboard status.</returns>
|
||||||
|
/// <exception cref="ArgumentException">The message does not carry a provider-status payload.</exception>
|
||||||
|
public static DashboardAlarmProviderStatus FromFeed(AlarmFeedMessage message)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(message);
|
||||||
|
|
||||||
|
if (message.PayloadCase != AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Alarm-feed message does not carry a provider-status payload.",
|
||||||
|
nameof(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return FromProviderStatus(message.ProviderStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
@@ -7,12 +12,13 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
|
|
||||||
public sealed class DashboardApiKeyManagementService(
|
public sealed class DashboardApiKeyManagementService(
|
||||||
DashboardApiKeyAuthorization authorization,
|
DashboardApiKeyAuthorization authorization,
|
||||||
|
ApiKeyAdminCommands adminCommands,
|
||||||
IApiKeyAdminStore adminStore,
|
IApiKeyAdminStore adminStore,
|
||||||
IApiKeyAuditStore auditStore,
|
IAuditWriter auditWriter,
|
||||||
IApiKeySecretHasher hasher,
|
|
||||||
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
||||||
{
|
{
|
||||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||||
|
private const string PepperUnavailableMarker = "pepper unavailable";
|
||||||
|
|
||||||
/// <summary>Determines whether the user can manage API keys.</summary>
|
/// <summary>Determines whether the user can manage API keys.</summary>
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
/// <param name="user">The authenticated user principal.</param>
|
||||||
@@ -42,28 +48,31 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string keyId = request.KeyId.Trim();
|
string keyId = request.KeyId.Trim();
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
|
||||||
string apiKey = FormatApiKey(keyId, secret);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await adminStore.CreateAsync(
|
// The shared command set generates the secret, hashes it with the pepper, persists the
|
||||||
new ApiKeyCreateRequest(
|
// record and assembles the mxgw_<id>_<secret> token (shown once). It also appends its own
|
||||||
KeyId: keyId,
|
// "create-key" audit entry (now canonicalized through the IApiKeyAuditStore->IAuditWriter
|
||||||
KeyPrefix: $"mxgw_{keyId}",
|
// adapter); the dashboard layers a richer "dashboard-create-key" canonical AuditEvent
|
||||||
SecretHash: hasher.HashSecret(secret),
|
// (Target + CorrelationId + remote address) on top via IAuditWriter to preserve the
|
||||||
DisplayName: request.DisplayName.Trim(),
|
// dashboard audit vocabulary — both rows land in the canonical audit_event store.
|
||||||
Scopes: request.Scopes,
|
CreateKeyResult created = await adminCommands.CreateKeyAsync(
|
||||||
Constraints: request.Constraints,
|
keyId,
|
||||||
CreatedUtc: DateTimeOffset.UtcNow),
|
request.DisplayName.Trim(),
|
||||||
|
request.Scopes,
|
||||||
|
ApiKeyConstraintSerializer.Serialize(request.Constraints),
|
||||||
|
RemoteAddress(),
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
await WriteDashboardAuditAsync(user, keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey);
|
return DashboardApiKeyManagementResult.Success(
|
||||||
|
"API key created. Copy the key now; it will not be shown again.",
|
||||||
|
created.Token);
|
||||||
}
|
}
|
||||||
catch (ApiKeyPepperUnavailableException)
|
catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
|
||||||
{
|
{
|
||||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||||
}
|
}
|
||||||
@@ -94,18 +103,19 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string normalizedKeyId = keyId.Trim();
|
string normalizedKeyId = keyId.Trim();
|
||||||
bool revoked = await adminStore
|
KeyActionResult result = await adminCommands
|
||||||
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
|
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-revoke-key",
|
"dashboard-revoke-key",
|
||||||
revoked ? "revoked" : "not-found-or-already-revoked",
|
result.Succeeded ? "revoked" : "not-found-or-already-revoked",
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return revoked
|
return result.Succeeded
|
||||||
? DashboardApiKeyManagementResult.Success("API key revoked.")
|
? DashboardApiKeyManagementResult.Success("API key revoked.")
|
||||||
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
||||||
}
|
}
|
||||||
@@ -131,27 +141,30 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string normalizedKeyId = keyId.Trim();
|
string normalizedKeyId = keyId.Trim();
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
|
||||||
string apiKey = FormatApiKey(normalizedKeyId, secret);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
bool rotated = await adminStore
|
CreateKeyResult rotated = await adminCommands
|
||||||
.RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
|
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
bool succeeded = rotated.Token is not null;
|
||||||
|
|
||||||
|
await WriteDashboardAuditAsync(
|
||||||
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-rotate-key",
|
"dashboard-rotate-key",
|
||||||
rotated ? "rotated" : "not-found",
|
succeeded ? "rotated" : "not-found",
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return rotated
|
return succeeded
|
||||||
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
|
? DashboardApiKeyManagementResult.Success(
|
||||||
|
"API key rotated. Copy the key now; it will not be shown again.",
|
||||||
|
rotated.Token)
|
||||||
: DashboardApiKeyManagementResult.Fail("API key was not found.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found.");
|
||||||
}
|
}
|
||||||
catch (ApiKeyPepperUnavailableException)
|
catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
|
||||||
{
|
{
|
||||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||||
}
|
}
|
||||||
@@ -182,7 +195,8 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
.DeleteAsync(normalizedKeyId, cancellationToken)
|
.DeleteAsync(normalizedKeyId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-delete-key",
|
"dashboard-delete-key",
|
||||||
deleted ? "deleted" : "not-found-or-active",
|
deleted ? "deleted" : "not-found-or-active",
|
||||||
@@ -194,22 +208,92 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AppendAuditAsync(
|
private string? RemoteAddress() =>
|
||||||
string? keyId,
|
httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||||
string eventType,
|
|
||||||
string? details,
|
/// <summary>
|
||||||
|
/// Resolves the operator's username from the authenticated dashboard principal.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The passed <paramref name="user"/> is preferred over the ambient HTTP context because it
|
||||||
|
/// is already in scope at every call site (the callers gate on <see cref="CanManage"/> using
|
||||||
|
/// it) and is unambiguous. Falls back to <see cref="IAuditActorAccessor.CurrentActor"/> for
|
||||||
|
/// defensive coverage, then to <c>"unknown"</c> when neither is available.
|
||||||
|
/// </remarks>
|
||||||
|
private static string ResolveOperatorActor(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
// ZbClaimTypes.Username = "zb:username" — the canonical LDAP login name.
|
||||||
|
string? username = user.FindFirstValue(ZB.MOM.WW.Auth.AspNetCore.ZbClaimTypes.Username);
|
||||||
|
if (!string.IsNullOrWhiteSpace(username))
|
||||||
|
{
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Framework fallback: Identity.Name is driven by the nameClaimType on the ClaimsIdentity
|
||||||
|
// (set to ZbClaimTypes.Name = ClaimTypes.Name by DashboardAuthenticator → display name).
|
||||||
|
string? identityName = user.Identity?.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(identityName))
|
||||||
|
{
|
||||||
|
return identityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
|
||||||
|
/// directly through the best-effort <see cref="IAuditWriter"/> (Task 2.3 #6). This is in
|
||||||
|
/// addition to the <c>create/revoke/rotate-key</c> event that <see cref="ApiKeyAdminCommands"/>
|
||||||
|
/// emits via the canonical-forwarding <c>IApiKeyAuditStore</c> adapter — the doubled-audit
|
||||||
|
/// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Phase 3 (Actor = operator principal): <c>Actor</c> is the LDAP operator who performed the
|
||||||
|
/// action (resolved from the <paramref name="user"/> principal); <c>Target</c> is the managed
|
||||||
|
/// API key id. This fixes the pre-Phase-3 semantic gap where both fields held the keyId.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task WriteDashboardAuditAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
string keyId,
|
||||||
|
string action,
|
||||||
|
string? detail,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await auditStore.AppendAsync(
|
AuditEvent auditEvent = new()
|
||||||
new ApiKeyAuditEntry(
|
{
|
||||||
KeyId: keyId,
|
EventId = Guid.NewGuid(),
|
||||||
EventType: eventType,
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
|
Actor = ResolveOperatorActor(user),
|
||||||
Details: details),
|
Action = action,
|
||||||
cancellationToken)
|
Outcome = AuditOutcome.Success,
|
||||||
.ConfigureAwait(false);
|
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
|
||||||
|
Target = keyId,
|
||||||
|
SourceNode = RemoteAddress(),
|
||||||
|
CorrelationId = ParseCorrelationId(),
|
||||||
|
DetailsJson = WrapDetail(detail),
|
||||||
|
};
|
||||||
|
|
||||||
|
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a correlation id from the ASP.NET Core request trace identifier when it is a
|
||||||
|
/// well-formed GUID; otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the
|
||||||
|
/// connection:request form, not a GUID, so it correlates to null rather than fabricating one).
|
||||||
|
/// </summary>
|
||||||
|
private Guid? ParseCorrelationId() =>
|
||||||
|
Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId)
|
||||||
|
? correlationId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string? WrapDetail(string? detail) =>
|
||||||
|
detail is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = detail });
|
||||||
|
|
||||||
|
private static bool IsPepperUnavailable(InvalidOperationException exception) =>
|
||||||
|
exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
|
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
|
||||||
{
|
{
|
||||||
string? keyIdValidation = ValidateKeyId(request.KeyId);
|
string? keyIdValidation = ValidateKeyId(request.KeyId);
|
||||||
@@ -248,9 +332,4 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
? null
|
? null
|
||||||
: "API key id may contain only letters, numbers, periods, and hyphens.";
|
: "API key id may contain only letters, numbers, periods, and hyphens.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatApiKey(string keyId, string secret)
|
|
||||||
{
|
|
||||||
return $"mxgw_{keyId}_{secret}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
using Microsoft.Extensions.Options;
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
||||||
using Novell.Directory.Ldap;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates interactive dashboard logins against LDAP. The bind/search
|
||||||
|
/// mechanics are delegated to the shared <see cref="ILdapAuthService"/>
|
||||||
|
/// (<c>ZB.MOM.WW.Auth.Ldap</c>), which performs bind-then-search, fails closed,
|
||||||
|
/// and never throws — returning the user's display name and LDAP groups on
|
||||||
|
/// success. This class keeps the dashboard-specific policy: groups are resolved
|
||||||
|
/// to dashboard roles via <see cref="IGroupRoleMapper{TRole}"/>, a login with no
|
||||||
|
/// matching role is denied, and the resulting <see cref="ClaimsPrincipal"/> is
|
||||||
|
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
|
||||||
|
/// <param name="roleMapper">Maps LDAP groups to dashboard roles (Task 1.1 seam).</param>
|
||||||
|
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
||||||
public sealed class DashboardAuthenticator(
|
public sealed class DashboardAuthenticator(
|
||||||
IOptions<GatewayOptions> options,
|
ILdapAuthService ldapAuthService,
|
||||||
|
IGroupRoleMapper<string> roleMapper,
|
||||||
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
||||||
{
|
{
|
||||||
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
||||||
@@ -19,240 +31,72 @@ public sealed class DashboardAuthenticator(
|
|||||||
string? password,
|
string? password,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
LdapOptions ldapOptions = options.Value.Ldap;
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
DashboardOptions dashboardOptions = options.Value.Dashboard;
|
|
||||||
if (!ldapOptions.Enabled
|
|
||||||
|| string.IsNullOrWhiteSpace(username)
|
|
||||||
|| string.IsNullOrWhiteSpace(password))
|
|
||||||
{
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
|
|
||||||
{
|
{
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
string normalizedUsername = username.Trim();
|
string normalizedUsername = username.Trim();
|
||||||
|
|
||||||
try
|
// The shared service owns connect/bind/search and the fail-closed contract:
|
||||||
|
// it returns Fail(Disabled) when LDAP is off, enforces TLS-or-AllowInsecure via
|
||||||
|
// its startup validator, and never throws. We only translate its outcome into a
|
||||||
|
// dashboard principal here.
|
||||||
|
LdapAuthResult ldapResult = await ldapAuthService
|
||||||
|
.AuthenticateAsync(normalizedUsername, password, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!ldapResult.Succeeded)
|
||||||
{
|
{
|
||||||
using LdapConnection connection = new();
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
connection.SecureSocketLayer = ldapOptions.UseTls;
|
|
||||||
|
|
||||||
await Task.Run(
|
|
||||||
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
|
||||||
LdapEntry? candidate = await SearchUserAsync(
|
|
||||||
connection,
|
|
||||||
ldapOptions,
|
|
||||||
normalizedUsername,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (candidate is null)
|
|
||||||
{
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Run(
|
|
||||||
() => connection.Bind(candidate.Dn, password),
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
|
||||||
LdapEntry? authenticatedEntry = await SearchUserAsync(
|
|
||||||
connection,
|
|
||||||
ldapOptions,
|
|
||||||
normalizedUsername,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (authenticatedEntry is null)
|
|
||||||
{
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
|
|
||||||
?? normalizedUsername;
|
|
||||||
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
|
|
||||||
|
|
||||||
IReadOnlyList<string> roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole);
|
|
||||||
if (roles.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation(
|
|
||||||
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
|
|
||||||
normalizedUsername);
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
|
||||||
normalizedUsername,
|
|
||||||
displayName,
|
|
||||||
groups,
|
|
||||||
roles));
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
GroupRoleMapping<string> mapping = await roleMapper
|
||||||
throw;
|
.MapAsync(ldapResult.Groups, cancellationToken)
|
||||||
}
|
.ConfigureAwait(false);
|
||||||
catch (LdapException ex)
|
|
||||||
|
IReadOnlyList<string> roles = mapping.Roles;
|
||||||
|
if (roles.Count == 0)
|
||||||
{
|
{
|
||||||
|
// Preserve the long-standing "no roles matched -> login denied" rule.
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
|
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
|
||||||
normalizedUsername,
|
ldapResult.Username);
|
||||||
ex.ResultCode);
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||||
}
|
ldapResult.Username,
|
||||||
}
|
ldapResult.DisplayName,
|
||||||
|
ldapResult.Groups,
|
||||||
/// <summary>Escapes special characters in LDAP filter strings.</summary>
|
roles));
|
||||||
/// <param name="value">The string value to escape.</param>
|
|
||||||
internal static string EscapeLdapFilter(string value)
|
|
||||||
{
|
|
||||||
StringBuilder builder = new(value.Length);
|
|
||||||
foreach (char character in value)
|
|
||||||
{
|
|
||||||
builder.Append(character switch
|
|
||||||
{
|
|
||||||
'\\' => @"\5c",
|
|
||||||
'*' => @"\2a",
|
|
||||||
'(' => @"\28",
|
|
||||||
')' => @"\29",
|
|
||||||
'\0' => @"\00",
|
|
||||||
_ => character.ToString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
|
/// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
|
||||||
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
|
||||||
/// an empty list when no group matches (caller rejects the login).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
/// <param name="username">
|
||||||
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
/// The (trimmed) login name. Emitted as <see cref="ClaimTypes.NameIdentifier"/> (kept for
|
||||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
/// back-compat reads) and as the canonical <see cref="ZbClaimTypes.Username"/> ("zb:username").
|
||||||
IEnumerable<string> groups,
|
/// </param>
|
||||||
IReadOnlyDictionary<string, string> groupToRole)
|
/// <param name="displayName">
|
||||||
{
|
/// The user's display name. Emitted as <see cref="ZbClaimTypes.Name"/> (= <see cref="ClaimTypes.Name"/>
|
||||||
if (groupToRole.Count == 0)
|
/// so <c>Identity.Name</c> resolves) and as <see cref="ZbClaimTypes.DisplayName"/> ("zb:displayname")
|
||||||
{
|
/// for cross-app consistency.
|
||||||
return [];
|
/// </param>
|
||||||
}
|
/// <param name="groups">
|
||||||
|
/// The user's LDAP groups, as returned by <see cref="ILdapAuthService"/>. NOTE
|
||||||
HashSet<string> roles = new(StringComparer.Ordinal);
|
/// (review C1): these are <b>already-normalized short RDN names</b> (e.g.
|
||||||
foreach (string group in groups)
|
/// <c>GwAdmin</c>), not raw distinguished names. The shared
|
||||||
{
|
/// <c>ZB.MOM.WW.Auth.Ldap</c> provider strips each group DN to its first RDN
|
||||||
string normalizedGroup = group.Trim();
|
/// value before returning it, so the <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/>
|
||||||
|
/// claim carries the short name. This differs from the pre-cutover behaviour,
|
||||||
// Lookup precedence (Server-040): the full literal group string is
|
/// which surfaced the raw <c>memberOf</c> values (full DNs) on the claim; the
|
||||||
// tried first; only if that misses do we fall back to the leading
|
/// claim is informational only (no policy or UI reads its value — authorization
|
||||||
// RDN value (e.g. "GwAdmin" extracted from
|
/// is role-based), so the shape change is non-breaking for dashboard consumers.
|
||||||
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
|
/// </param>
|
||||||
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
|
/// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
|
||||||
// "GwAdmin" and "gwadmin" both match.
|
|
||||||
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|
|
||||||
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
|
||||||
{
|
|
||||||
roles.Add(mapped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [.. roles];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
|
||||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
|
||||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
|
||||||
{
|
|
||||||
int equalsIndex = distinguishedName.IndexOf('=');
|
|
||||||
if (equalsIndex < 0)
|
|
||||||
{
|
|
||||||
return distinguishedName;
|
|
||||||
}
|
|
||||||
|
|
||||||
int valueStart = equalsIndex + 1;
|
|
||||||
int commaIndex = distinguishedName.IndexOf(',', valueStart);
|
|
||||||
|
|
||||||
return commaIndex > valueStart
|
|
||||||
? distinguishedName[valueStart..commaIndex]
|
|
||||||
: distinguishedName[valueStart..];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Task BindServiceAccountAsync(
|
|
||||||
LdapConnection connection,
|
|
||||||
LdapOptions ldapOptions,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return Task.Run(
|
|
||||||
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<LdapEntry?> SearchUserAsync(
|
|
||||||
LdapConnection connection,
|
|
||||||
LdapOptions ldapOptions,
|
|
||||||
string username,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
|
|
||||||
ILdapSearchResults results = await Task.Run(
|
|
||||||
() => connection.Search(
|
|
||||||
ldapOptions.SearchBase,
|
|
||||||
LdapConnection.ScopeSub,
|
|
||||||
filter,
|
|
||||||
attrs: null,
|
|
||||||
typesOnly: false),
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
LdapEntry? entry = null;
|
|
||||||
while (results.HasMore())
|
|
||||||
{
|
|
||||||
LdapEntry next = results.Next();
|
|
||||||
if (entry is not null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ReadAttribute(LdapEntry entry, string attributeName)
|
|
||||||
{
|
|
||||||
return ReadLdapAttribute(entry, attributeName)?.StringValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
|
|
||||||
{
|
|
||||||
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
|
|
||||||
return attribute?.StringValueArray ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
|
|
||||||
{
|
|
||||||
return entry.GetAttribute(attributeName)
|
|
||||||
?? entry.GetAttribute(attributeName.ToLowerInvariant())
|
|
||||||
?? entry.GetAttribute(attributeName.ToUpperInvariant());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ClaimsPrincipal CreatePrincipal(
|
private static ClaimsPrincipal CreatePrincipal(
|
||||||
string username,
|
string username,
|
||||||
string displayName,
|
string displayName,
|
||||||
@@ -261,11 +105,21 @@ public sealed class DashboardAuthenticator(
|
|||||||
{
|
{
|
||||||
List<Claim> claims =
|
List<Claim> claims =
|
||||||
[
|
[
|
||||||
|
// Keep NameIdentifier so any existing read-site that uses it continues to work.
|
||||||
new Claim(ClaimTypes.NameIdentifier, username),
|
new Claim(ClaimTypes.NameIdentifier, username),
|
||||||
new Claim(ClaimTypes.Name, displayName),
|
// Canonical login-username claim (Task 1.5).
|
||||||
|
new Claim(ZbClaimTypes.Username, username),
|
||||||
|
// ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution.
|
||||||
|
new Claim(ZbClaimTypes.Name, displayName),
|
||||||
|
// Canonical display-name claim for cross-app consistency (Task 1.5).
|
||||||
|
new Claim(ZbClaimTypes.DisplayName, displayName),
|
||||||
];
|
];
|
||||||
|
|
||||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
// ZbClaimTypes.Role == ClaimTypes.Role — drives IsInRole and [Authorize(Roles=...)].
|
||||||
|
claims.AddRange(roles.Select(role => new Claim(ZbClaimTypes.Role, role)));
|
||||||
|
// Groups are short RDN names from ILdapAuthService (see param doc above), so
|
||||||
|
// this claim value is the short group name, not the original DN.
|
||||||
|
// LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
|
||||||
claims.AddRange(groups.Select(group => new Claim(
|
claims.AddRange(groups.Select(group => new Claim(
|
||||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||||
group)));
|
group)));
|
||||||
@@ -273,8 +127,8 @@ public sealed class DashboardAuthenticator(
|
|||||||
ClaimsIdentity claimsIdentity = new(
|
ClaimsIdentity claimsIdentity = new(
|
||||||
claims,
|
claims,
|
||||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
ClaimTypes.Name,
|
ZbClaimTypes.Name,
|
||||||
ClaimTypes.Role);
|
ZbClaimTypes.Role);
|
||||||
|
|
||||||
return new ClaimsPrincipal(claimsIdentity);
|
return new ClaimsPrincipal(claimsIdentity);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-60
@@ -1,7 +1,6 @@
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||||
@@ -25,14 +24,19 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoints.MapGet(
|
// GET /login is served by the [AllowAnonymous] Blazor <Login> component
|
||||||
"/login",
|
// (Components/Pages/Login.razor → @page "/login"), which renders the shared
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery))
|
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
||||||
.AllowAnonymous()
|
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
||||||
.WithName("DashboardLogin");
|
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
||||||
|
//
|
||||||
|
// The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
|
||||||
|
// Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
|
||||||
|
// the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
|
||||||
|
// A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
|
||||||
|
// handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
|
||||||
endpoints.MapPost(
|
endpoints.MapPost(
|
||||||
"/login",
|
"/auth/login",
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
PostLoginAsync(httpContext, antiforgery, authenticator))
|
PostLoginAsync(httpContext, antiforgery, authenticator))
|
||||||
.AllowAnonymous()
|
.AllowAnonymous()
|
||||||
@@ -92,17 +96,6 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<ContentHttpResult> GetLoginAsync(
|
|
||||||
HttpContext httpContext,
|
|
||||||
IAntiforgery antiforgery)
|
|
||||||
{
|
|
||||||
string returnUrl = SanitizeReturnUrl(httpContext.Request.Query["returnUrl"].ToString());
|
|
||||||
|
|
||||||
return Task.FromResult(TypedResults.Content(
|
|
||||||
RenderLoginPage(httpContext, antiforgery, returnUrl, failureMessage: null),
|
|
||||||
"text/html"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> PostLoginAsync(
|
private static async Task<IResult> PostLoginAsync(
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
IAntiforgery antiforgery,
|
IAntiforgery antiforgery,
|
||||||
@@ -124,10 +117,13 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
|
|
||||||
if (!result.Succeeded || result.Principal is null)
|
if (!result.Succeeded || result.Principal is null)
|
||||||
{
|
{
|
||||||
return TypedResults.Content(
|
// Round-trip the failure back to the anonymous Blazor /login page, carrying
|
||||||
RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage),
|
// the (sanitized) returnUrl so a successful retry still lands on the target.
|
||||||
"text/html",
|
string failureMessage = result.FailureMessage
|
||||||
statusCode: StatusCodes.Status401Unauthorized);
|
?? "The username or password is invalid, or the user is not authorized.";
|
||||||
|
return Results.Redirect(
|
||||||
|
$"/login?error={Uri.EscapeDataString(failureMessage)}"
|
||||||
|
+ $"&returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
await httpContext
|
await httpContext
|
||||||
@@ -158,42 +154,6 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return Results.LocalRedirect("/login");
|
return Results.LocalRedirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string RenderLoginPage(
|
|
||||||
HttpContext httpContext,
|
|
||||||
IAntiforgery antiforgery,
|
|
||||||
string returnUrl,
|
|
||||||
string? failureMessage)
|
|
||||||
{
|
|
||||||
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
|
|
||||||
string requestToken = tokens.RequestToken ?? string.Empty;
|
|
||||||
string alert = string.IsNullOrWhiteSpace(failureMessage)
|
|
||||||
? string.Empty
|
|
||||||
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
|
||||||
|
|
||||||
string body = $"""
|
|
||||||
<section class="dashboard-login">
|
|
||||||
{alert}
|
|
||||||
<form method="post" action="/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="username" class="form-label">Username</label>
|
|
||||||
<input id="username" name="username" type="text" autocomplete="username" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Password</label>
|
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" class="form-control" />
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
""";
|
|
||||||
|
|
||||||
return RenderPage("Dashboard Sign In", heading: null, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string RenderPage(string title, string body)
|
private static string RenderPage(string title, string body)
|
||||||
=> RenderPage(title, heading: title, body);
|
=> RenderPage(title, heading: title, body);
|
||||||
|
|
||||||
@@ -215,7 +175,8 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="/css/theme.css" />
|
<link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/theme.css" />
|
||||||
|
<link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/layout.css" />
|
||||||
<link rel="stylesheet" href="/css/site.css" />
|
<link rel="stylesheet" href="/css/site.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="dashboard-body">
|
<body class="dashboard-body">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user