Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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 bool UseTls { get; init; }
|
||||
public string? CaCertificatePath { get; init; }
|
||||
public bool RequireCertificateValidation { get; init; }
|
||||
public string? ServerNameOverride { get; init; }
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
`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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -106,6 +106,8 @@ public sealed class LazyBrowseNodeTests
|
||||
new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
|
||||
|
||||
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
|
||||
Assert.False(roots[0].IsExpanded);
|
||||
Assert.Empty(roots[0].Children);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -142,6 +144,37 @@ public sealed class LazyBrowseNodeTests
|
||||
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>
|
||||
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||
CreateHttpHandlerForTests(options);
|
||||
|
||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||
{
|
||||
SocketsHttpHandler handler = new()
|
||||
{
|
||||
@@ -510,6 +513,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||
{
|
||||
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
@@ -525,6 +533,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
return customChain.Build(certificateToValidate);
|
||||
};
|
||||
}
|
||||
else if (!options.RequireCertificateValidation)
|
||||
{
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||
}
|
||||
}
|
||||
|
||||
return handler;
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class LazyBrowseNode
|
||||
private readonly GalaxyRepositoryClient _client;
|
||||
private readonly BrowseChildrenOptions _options;
|
||||
private readonly List<LazyBrowseNode> _children = [];
|
||||
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
||||
private bool _isExpanded;
|
||||
|
||||
internal LazyBrowseNode(
|
||||
@@ -43,6 +44,10 @@ public sealed class LazyBrowseNode
|
||||
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
|
||||
/// Idempotent: subsequent calls are no-ops.
|
||||
/// </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>
|
||||
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -51,33 +56,46 @@ public sealed class LazyBrowseNode
|
||||
return;
|
||||
}
|
||||
|
||||
string pageToken = string.Empty;
|
||||
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||
do
|
||||
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
|
||||
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++)
|
||||
if (_isExpanded)
|
||||
{
|
||||
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
||||
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
|
||||
return;
|
||||
}
|
||||
|
||||
pageToken = reply.NextPageToken;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
|
||||
string pageToken = string.Empty;
|
||||
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||
do
|
||||
{
|
||||
throw new MxGatewayException(
|
||||
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
|
||||
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
|
||||
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);
|
||||
}
|
||||
|
||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||
CreateHttpHandlerForTests(options);
|
||||
|
||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||
{
|
||||
SocketsHttpHandler handler = new()
|
||||
{
|
||||
@@ -335,6 +338,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||
{
|
||||
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
@@ -350,6 +358,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
return customChain.Build(certificateToValidate);
|
||||
};
|
||||
}
|
||||
else if (!options.RequireCertificateValidation)
|
||||
{
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||
}
|
||||
}
|
||||
|
||||
return handler;
|
||||
|
||||
@@ -7,9 +7,11 @@ namespace ZB.MOM.WW.MxGateway.Client;
|
||||
/// </summary>
|
||||
public static class MxGatewayClientContractInfo
|
||||
{
|
||||
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
|
||||
public const uint GatewayProtocolVersion =
|
||||
GatewayContractInfo.GatewayProtocolVersion;
|
||||
|
||||
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
|
||||
public const uint WorkerProtocolVersion =
|
||||
GatewayContractInfo.WorkerProtocolVersion;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,14 @@ public sealed class MxGatewayClientOptions
|
||||
/// </summary>
|
||||
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>
|
||||
/// Gets the server name override for SNI during TLS handshake.
|
||||
/// </summary>
|
||||
|
||||
@@ -16,4 +16,21 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</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>
|
||||
|
||||
@@ -104,6 +104,23 @@ Support:
|
||||
- `credentials.NewClientTLSFromFile`,
|
||||
- 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
|
||||
|
||||
`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`,
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
||||
`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
|
||||
|
||||
`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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -222,10 +222,22 @@ func resolveTransportCredentials(opts Options) (credentials.TransportCredentials
|
||||
return credentials.NewTLS(cfg), nil
|
||||
}
|
||||
|
||||
return credentials.NewTLS(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: opts.ServerNameOverride,
|
||||
}), nil
|
||||
return credentials.NewTLS(tlsConfigForOptions(opts)), 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.
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
// Galaxy Repository service exposed for callers that need direct contract
|
||||
// access.
|
||||
@@ -155,16 +160,35 @@ func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool,
|
||||
|
||||
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
||||
// 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) {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
||||
var objects []*GalaxyObject
|
||||
pageToken := ""
|
||||
seen := map[string]struct{}{}
|
||||
for {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
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
|
||||
@@ -249,15 +273,25 @@ func (c *GalaxyClient) Close() error {
|
||||
|
||||
// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by
|
||||
// (*GalaxyClient).Browse. Children are not fetched until Expand is called.
|
||||
// The node is safe for concurrent use; concurrent Expand calls collapse to a
|
||||
// single RPC.
|
||||
// The node is safe for concurrent use; concurrent Expand calls coalesce onto
|
||||
// a single in-flight RPC and do not block snapshot accessors.
|
||||
type LazyBrowseNode struct {
|
||||
client *GalaxyClient
|
||||
object *pb.GalaxyObject
|
||||
hasChildrenHint bool
|
||||
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
|
||||
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
|
||||
// an empty slice when Expand has not yet been called.
|
||||
func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.mu.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
out := make([]*LazyBrowseNode, len(n.children))
|
||||
copy(out, n.children)
|
||||
return out
|
||||
@@ -281,28 +315,81 @@ func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
|
||||
|
||||
// IsExpanded reports whether Expand has completed successfully on this node.
|
||||
func (n *LazyBrowseNode) IsExpanded() bool {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.mu.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
return n.isExpanded
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
// Fast path: already expanded.
|
||||
n.mu.RLock()
|
||||
if n.isExpanded {
|
||||
n.mu.RUnlock()
|
||||
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()
|
||||
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
|
||||
return nil
|
||||
|
||||
// Publish result to waiters and clear the in-flight marker so a failed
|
||||
// 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
|
||||
@@ -375,7 +462,10 @@ func (c *GalaxyClient) browseChildrenInner(
|
||||
return nodes, nil
|
||||
}
|
||||
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{}{}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"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) {
|
||||
fake := &fakeGalaxyServer{failTest: true}
|
||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||
@@ -372,18 +414,20 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
|
||||
type fakeGalaxyServer struct {
|
||||
pb.UnimplementedGalaxyRepositoryServer
|
||||
|
||||
testReply *pb.TestConnectionReply
|
||||
testAuth string
|
||||
failTest bool
|
||||
deployReply *pb.GetLastDeployTimeReply
|
||||
discoverReply *pb.DiscoverHierarchyReply
|
||||
watchEvents []*pb.DeployEvent
|
||||
watchRequest *pb.WatchDeployEventsRequest
|
||||
watchSendInterval time.Duration
|
||||
watchHoldOpen bool
|
||||
browseChildrenCalls []*pb.BrowseChildrenRequest
|
||||
browseChildrenReplies []*pb.BrowseChildrenReply
|
||||
browseChildrenError error
|
||||
testReply *pb.TestConnectionReply
|
||||
testAuth string
|
||||
failTest bool
|
||||
deployReply *pb.GetLastDeployTimeReply
|
||||
discoverReply *pb.DiscoverHierarchyReply
|
||||
discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
|
||||
discoverHierarchyReplies []*pb.DiscoverHierarchyReply
|
||||
watchEvents []*pb.DeployEvent
|
||||
watchRequest *pb.WatchDeployEventsRequest
|
||||
watchSendInterval time.Duration
|
||||
watchHoldOpen bool
|
||||
browseChildrenCalls []*pb.BrowseChildrenRequest
|
||||
browseChildrenReplies []*pb.BrowseChildrenReply
|
||||
browseChildrenError 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) {
|
||||
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 {
|
||||
return s.discoverReply, nil
|
||||
}
|
||||
@@ -738,3 +788,77 @@ func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
|
||||
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
|
||||
// DialOptions are appended to the gRPC dial options after the defaults.
|
||||
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
|
||||
|
||||
@@ -112,6 +112,23 @@ Support:
|
||||
- custom CA certificate file,
|
||||
- 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
|
||||
|
||||
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`,
|
||||
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
||||
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
|
||||
|
||||
`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"
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -37,4 +37,44 @@ subprojects {
|
||||
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 {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'com.google.protobuf'
|
||||
id 'maven-publish'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -30,6 +31,11 @@ sourceSets {
|
||||
}
|
||||
}
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
withJavadocJar()
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
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.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
|
||||
@@ -16,7 +19,14 @@ public final class LazyBrowseNode {
|
||||
private final GalaxyObject object;
|
||||
private final boolean hasChildrenHint;
|
||||
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 boolean isExpanded;
|
||||
|
||||
@@ -43,15 +53,21 @@ public final class LazyBrowseNode {
|
||||
|
||||
/** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */
|
||||
public List<LazyBrowseNode> getChildren() {
|
||||
synchronized (lock) {
|
||||
readWriteLock.readLock().lock();
|
||||
try {
|
||||
return List.copyOf(children);
|
||||
} finally {
|
||||
readWriteLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return {@code true} after the first {@link #expand()} call completes. */
|
||||
public boolean isExpanded() {
|
||||
synchronized (lock) {
|
||||
readWriteLock.readLock().lock();
|
||||
try {
|
||||
return isExpanded;
|
||||
} finally {
|
||||
readWriteLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,17 +75,76 @@ public final class LazyBrowseNode {
|
||||
* Fetches direct children from the gateway and populates {@link #getChildren()}.
|
||||
* 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
|
||||
*/
|
||||
public void expand() {
|
||||
synchronized (lock) {
|
||||
if (isExpanded) {
|
||||
if (isExpanded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
CompletableFuture<Void> future;
|
||||
boolean iAmTheLeader;
|
||||
synchronized (expandLock) {
|
||||
if (isExpanded()) {
|
||||
return;
|
||||
}
|
||||
List<LazyBrowseNode> loaded =
|
||||
client.browseChildrenInner(Integer.valueOf(object.getGobjectId()), options);
|
||||
this.children = loaded;
|
||||
this.isExpanded = true;
|
||||
if (inFlight != null) {
|
||||
future = inFlight;
|
||||
iAmTheLeader = false;
|
||||
} 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) {
|
||||
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 {
|
||||
builder.useTransportSecurity();
|
||||
}
|
||||
@@ -393,6 +402,19 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
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) {
|
||||
if (options.callTimeout().isNegative()) {
|
||||
return stub;
|
||||
|
||||
+32
@@ -20,6 +20,7 @@ public final class MxGatewayClientOptions {
|
||||
private final String apiKey;
|
||||
private final boolean plaintext;
|
||||
private final Path caCertificatePath;
|
||||
private final boolean requireCertificateValidation;
|
||||
private final String serverNameOverride;
|
||||
private final Duration connectTimeout;
|
||||
private final Duration callTimeout;
|
||||
@@ -31,6 +32,7 @@ public final class MxGatewayClientOptions {
|
||||
apiKey = builder.apiKey == null ? "" : builder.apiKey;
|
||||
plaintext = builder.plaintext;
|
||||
caCertificatePath = builder.caCertificatePath;
|
||||
requireCertificateValidation = builder.requireCertificateValidation;
|
||||
serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride;
|
||||
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
|
||||
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
|
||||
@@ -95,6 +97,18 @@ public final class MxGatewayClientOptions {
|
||||
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.
|
||||
*
|
||||
@@ -148,6 +162,8 @@ public final class MxGatewayClientOptions {
|
||||
+ plaintext
|
||||
+ ", caCertificatePath="
|
||||
+ caCertificatePath
|
||||
+ ", requireCertificateValidation="
|
||||
+ requireCertificateValidation
|
||||
+ ", serverNameOverride='"
|
||||
+ serverNameOverride
|
||||
+ '\''
|
||||
@@ -177,6 +193,7 @@ public final class MxGatewayClientOptions {
|
||||
private String apiKey;
|
||||
private boolean plaintext;
|
||||
private Path caCertificatePath;
|
||||
private boolean requireCertificateValidation;
|
||||
private String serverNameOverride;
|
||||
private Duration connectTimeout;
|
||||
private Duration callTimeout;
|
||||
@@ -230,6 +247,21 @@ public final class MxGatewayClientOptions {
|
||||
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.
|
||||
*
|
||||
|
||||
+112
-1
@@ -40,9 +40,14 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
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.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
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
|
||||
void watchDeployEventsReceivesEventsInOrder() throws Exception {
|
||||
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
|
||||
void browseWithFilterForwardsToRequest() throws Exception {
|
||||
BrowseChildrenService service = new BrowseChildrenService();
|
||||
@@ -486,7 +597,7 @@ final class GalaxyRepositoryClientTests {
|
||||
return b.build();
|
||||
}
|
||||
|
||||
private static final class BrowseChildrenService extends TestService {
|
||||
private static class BrowseChildrenService extends TestService {
|
||||
final List<BrowseChildrenRequest> calls =
|
||||
Collections.synchronizedList(new CopyOnWriteArrayList<>());
|
||||
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,
|
||||
- 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
|
||||
|
||||
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))
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
`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.
|
||||
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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -13,12 +13,35 @@ dependencies = [
|
||||
"grpcio>=1.80,<2",
|
||||
"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]
|
||||
dev = [
|
||||
"grpcio-tools>=1.80,<2",
|
||||
"pytest>=9,<10",
|
||||
"pytest-asyncio>=1.3,<2",
|
||||
"build>=1.2,<2",
|
||||
"twine>=5,<6",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -140,6 +140,22 @@ class GalaxyRepositoryClient:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
options: BrowseChildrenOptions | None = None,
|
||||
@@ -292,6 +308,7 @@ class LazyBrowseNode:
|
||||
self._options = options
|
||||
self._children: list[LazyBrowseNode] = []
|
||||
self._is_expanded = False
|
||||
self._expand_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def object(self) -> galaxy_pb.GalaxyObject:
|
||||
@@ -317,14 +334,17 @@ class LazyBrowseNode:
|
||||
"""Fetch direct children of this node; no-op on subsequent calls."""
|
||||
if self._is_expanded:
|
||||
return
|
||||
new_children: list[LazyBrowseNode] = []
|
||||
async for child in self._client._iter_browse_children(
|
||||
parent_gobject_id=self._object.gobject_id,
|
||||
options=self._options,
|
||||
):
|
||||
new_children.append(child)
|
||||
self._children.extend(new_children)
|
||||
self._is_expanded = True
|
||||
async with self._expand_lock:
|
||||
if self._is_expanded:
|
||||
return
|
||||
new_children: list[LazyBrowseNode] = []
|
||||
async for child in self._client._iter_browse_children(
|
||||
parent_gobject_id=self._object.gobject_id,
|
||||
options=self._options,
|
||||
):
|
||||
new_children.append(child)
|
||||
self._children.extend(new_children)
|
||||
self._is_expanded = True
|
||||
|
||||
|
||||
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ssl
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -9,6 +10,7 @@ from pathlib import Path
|
||||
import grpc
|
||||
|
||||
from .auth import REDACTED, ApiKey
|
||||
from .errors import MxGatewayTransportError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,6 +21,7 @@ class ClientOptions:
|
||||
api_key: str | ApiKey | None = None
|
||||
plaintext: bool = False
|
||||
ca_file: str | None = None
|
||||
require_certificate_validation: bool = False
|
||||
server_name_override: str | None = None
|
||||
call_timeout: float | None = 30.0
|
||||
stream_timeout: float | None = None
|
||||
@@ -45,6 +48,7 @@ class ClientOptions:
|
||||
f"{type(self).__name__}(endpoint={self.endpoint!r}, "
|
||||
f"api_key={api_key!r}, plaintext={self.plaintext!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"call_timeout={self.call_timeout!r}, "
|
||||
f"stream_timeout={self.stream_timeout!r}, "
|
||||
@@ -69,8 +73,34 @@ class BrowseChildrenOptions:
|
||||
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:
|
||||
"""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]] = [
|
||||
("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:
|
||||
return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
|
||||
|
||||
root_certificates = None
|
||||
if options.ca_file:
|
||||
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(
|
||||
options.endpoint,
|
||||
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:
|
||||
calls: list[tuple[str, object, object]] = []
|
||||
def test_create_channel_uses_tls_channel_tofu_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""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:
|
||||
assert root_certificates is None
|
||||
def fake_get_server_certificate(addr: tuple[str, int]) -> str:
|
||||
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"
|
||||
|
||||
channel_calls: list[tuple[str, object, object]] = []
|
||||
|
||||
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"
|
||||
|
||||
monkeypatch.setattr(
|
||||
options_module.grpc,
|
||||
"ssl_channel_credentials",
|
||||
fake_credentials,
|
||||
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"),
|
||||
)
|
||||
|
||||
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(
|
||||
options_module.grpc.aio,
|
||||
"secure_channel",
|
||||
fake_secure_channel,
|
||||
options_module.ssl,
|
||||
"get_server_certificate",
|
||||
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(
|
||||
ClientOptions(
|
||||
@@ -102,14 +158,121 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
)
|
||||
|
||||
assert channel == "tls-channel"
|
||||
assert 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", "gateway.test"),
|
||||
],
|
||||
),
|
||||
]
|
||||
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
|
||||
assert channel_calls == [
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("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
|
||||
|
||||
|
||||
@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
|
||||
async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
@@ -485,6 +507,35 @@ async def test_browse_with_filter_forwards_to_request() -> None:
|
||||
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:
|
||||
def __init__(self) -> None:
|
||||
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
|
||||
@@ -506,7 +557,8 @@ class FakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
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
|
||||
|
||||
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.
|
||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
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"
|
||||
version = "0.1.0"
|
||||
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"
|
||||
|
||||
[workspace]
|
||||
@@ -12,7 +21,10 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
publish = false
|
||||
authors = ["Joseph Doherty"]
|
||||
license = "Proprietary"
|
||||
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
publish = ["dohertj2-gitea"]
|
||||
|
||||
[workspace.dependencies]
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
`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
|
||||
|
||||
`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)
|
||||
- [Rust Client Detailed Design](./RustClientDesign.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,
|
||||
- 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
|
||||
|
||||
Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "mxgw-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "mxgw"
|
||||
|
||||
@@ -426,6 +426,11 @@ struct ConnectionArgs {
|
||||
ca_file: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
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)]
|
||||
connect_timeout_seconds: u64,
|
||||
#[arg(long, default_value_t = 30)]
|
||||
@@ -453,6 +458,9 @@ impl ConnectionArgs {
|
||||
if let Some(server_name_override) = &self.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
|
||||
}
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
//! code should prefer [`GatewayClient::open_session`] and the [`Session`]
|
||||
//! handle it returns, rather than the `*_raw` methods.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tonic::codegen::InterceptedService;
|
||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
||||
use tonic::transport::Channel;
|
||||
use tonic::Request;
|
||||
|
||||
use crate::auth::AuthInterceptor;
|
||||
@@ -21,7 +19,7 @@ use crate::generated::mxaccess_gateway::v1::{
|
||||
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
|
||||
StreamEventsRequest,
|
||||
};
|
||||
use crate::options::ClientOptions;
|
||||
use crate::options::{build_tls_config, ClientOptions};
|
||||
use crate::session::Session;
|
||||
|
||||
/// Generated gateway client wrapped in the auth interceptor that
|
||||
@@ -78,18 +76,7 @@ impl GatewayClient {
|
||||
})?;
|
||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||
|
||||
if !options.plaintext() {
|
||||
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));
|
||||
}
|
||||
if let Some(tls) = build_tls_config(&options)? {
|
||||
endpoint = endpoint.tls_config(tls)?;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
//! re-exported through [`crate::generated::galaxy_repository::v1`].
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use prost_types::Timestamp;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tonic::codegen::InterceptedService;
|
||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
||||
use tonic::transport::Channel;
|
||||
use tonic::Request;
|
||||
|
||||
use crate::auth::AuthInterceptor;
|
||||
@@ -23,7 +22,7 @@ use crate::generated::galaxy_repository::v1::{
|
||||
DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest,
|
||||
WatchDeployEventsRequest,
|
||||
};
|
||||
use crate::options::ClientOptions;
|
||||
use crate::options::{build_tls_config, ClientOptions};
|
||||
|
||||
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
|
||||
const BROWSE_CHILDREN_PAGE_SIZE: i32 = 500;
|
||||
@@ -183,18 +182,7 @@ impl GalaxyClient {
|
||||
})?;
|
||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||
|
||||
if !options.plaintext() {
|
||||
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));
|
||||
}
|
||||
if let Some(tls) = build_tls_config(&options)? {
|
||||
endpoint = endpoint.tls_config(tls)?;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
//! chain of `with_*` setters; the `Debug` impl redacts the API key.
|
||||
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use tonic::transport::{Certificate, ClientTlsConfig};
|
||||
|
||||
use crate::auth::ApiKey;
|
||||
use crate::error::Error;
|
||||
|
||||
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
@@ -22,6 +26,7 @@ pub struct ClientOptions {
|
||||
api_key: Option<ApiKey>,
|
||||
plaintext: bool,
|
||||
ca_file: Option<PathBuf>,
|
||||
require_certificate_validation: bool,
|
||||
server_name_override: Option<String>,
|
||||
connect_timeout: Duration,
|
||||
call_timeout: Duration,
|
||||
@@ -38,6 +43,7 @@ impl ClientOptions {
|
||||
api_key: None,
|
||||
plaintext: true,
|
||||
ca_file: None,
|
||||
require_certificate_validation: false,
|
||||
server_name_override: None,
|
||||
connect_timeout: Duration::from_secs(10),
|
||||
call_timeout: Duration::from_secs(30),
|
||||
@@ -67,6 +73,22 @@ impl ClientOptions {
|
||||
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
|
||||
/// 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 {
|
||||
@@ -121,6 +143,12 @@ impl ClientOptions {
|
||||
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.
|
||||
pub fn server_name_override(&self) -> Option<&str> {
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
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("plaintext", &self.plaintext)
|
||||
.field("ca_file", &self.ca_file)
|
||||
.field(
|
||||
"require_certificate_validation",
|
||||
&self.require_certificate_validation,
|
||||
)
|
||||
.field("server_name_override", &self.server_name_override)
|
||||
.field("connect_timeout", &self.connect_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-----
|
||||
";
|
||||
@@ -51,6 +51,19 @@ The shared inputs are:
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
Every command in the matrix requests JSON output. A runner can compare the
|
||||
|
||||
@@ -375,6 +375,42 @@ deployment-heavy box, multiply per-session SQL connections, and complicate the
|
||||
cold-start path. Wire-side laziness solves the actual pain (oversized gRPC
|
||||
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.
|
||||
|
||||
## Later Revisit Items
|
||||
|
||||
These are explicit post-v1 revisit items, not open blockers:
|
||||
|
||||
@@ -229,6 +229,185 @@ behavior.
|
||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||
|
||||
## 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
|
||||
|
||||
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
||||
|
||||
@@ -243,9 +243,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.
|
||||
|
||||
## 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
|
||||
|
||||
- [Contracts](./Contracts.md)
|
||||
- [Sessions](./Sessions.md)
|
||||
- [Authorization](./Authorization.md)
|
||||
- [Gateway Configuration](./GatewayConfiguration.md)
|
||||
- [Gateway Process Design](./GatewayProcessDesign.md)
|
||||
|
||||
@@ -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,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>
|
||||
public static class GatewayContractInfo
|
||||
{
|
||||
/// <summary>Protocol version advertised to clients in <c>OpenSessionReply</c>.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Default backend name identifying the MXAccess worker process type.</summary>
|
||||
public const string DefaultBackendName = "mxaccess-worker";
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,24 @@
|
||||
<TargetFrameworks>net10.0;net48</TargetFrameworks>
|
||||
</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>
|
||||
<Compile Remove="Generated\**\*.cs" />
|
||||
<Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
|
||||
|
||||
@@ -43,4 +43,7 @@ public sealed class GatewayOptions
|
||||
/// behaviour (alarms disabled).
|
||||
/// </summary>
|
||||
public AlarmsOptions Alarms { get; init; } = new();
|
||||
|
||||
/// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
|
||||
public TlsOptions Tls { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
ValidateDashboard(options.Dashboard, failures);
|
||||
ValidateProtocol(options.Protocol, failures);
|
||||
ValidateAlarms(options.Alarms, failures);
|
||||
ValidateTls(options.Tls, failures);
|
||||
|
||||
return failures.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
@@ -262,6 +263,36 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
}
|
||||
}
|
||||
|
||||
private const int MinimumCertValidityYears = 1;
|
||||
private const int MaximumCertValidityYears = 100;
|
||||
|
||||
private static void ValidateTls(TlsOptions options, List<string> failures)
|
||||
{
|
||||
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
|
||||
{
|
||||
failures.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.",
|
||||
failures);
|
||||
AddIfInvalidPath(
|
||||
options.SelfSignedCertPath,
|
||||
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
||||
failures);
|
||||
|
||||
foreach (string dns in options.AdditionalDnsNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dns))
|
||||
{
|
||||
failures.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateProtocol(ProtocolOptions options, List<string> failures)
|
||||
{
|
||||
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -62,7 +62,16 @@ public static class GalaxyBrowseProjector
|
||||
return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature);
|
||||
}
|
||||
|
||||
private static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
||||
/// <summary>
|
||||
/// Resolves the request's parent oneof to a gobject id, throwing
|
||||
/// <see cref="RpcException"/> with <see cref="StatusCode.NotFound"/> when the
|
||||
/// parent does not exist. Public so the gRPC handler can compute the same
|
||||
/// parent id (needed for the page-token signature) without reimplementing the
|
||||
/// resolution rules.
|
||||
/// </summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
||||
/// <param name="request">The browse-children request.</param>
|
||||
public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
||||
{
|
||||
switch (request.ParentCase)
|
||||
{
|
||||
@@ -80,9 +89,7 @@ public static class GalaxyBrowseProjector
|
||||
return request.ParentGobjectId;
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentTagName:
|
||||
{
|
||||
GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
|
||||
view => string.Equals(view.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is null)
|
||||
if (!entry.Index.ObjectViewsByTagName.TryGetValue(request.ParentTagName, out GalaxyObjectView? match))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
@@ -90,9 +97,7 @@ public static class GalaxyBrowseProjector
|
||||
}
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath:
|
||||
{
|
||||
GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
|
||||
view => string.Equals(view.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is null)
|
||||
if (!entry.Index.ObjectViewsByContainedPath.TryGetValue(request.ParentContainedPath, out GalaxyObjectView? match))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
@@ -163,10 +168,17 @@ public static class GalaxyBrowseProjector
|
||||
return false;
|
||||
}
|
||||
|
||||
// Defend against pathological cycles in Galaxy data (e.g. a corrupt A→B→A chain).
|
||||
// BuildContainedPath uses the same visited-id pattern; mirror it so this walk
|
||||
// terminates even when ChildrenByParent forms a cycle.
|
||||
HashSet<int> visited = new() { parent.Object.GobjectId };
|
||||
Stack<GalaxyObjectView> stack = new();
|
||||
foreach (GalaxyObjectView child in children)
|
||||
{
|
||||
stack.Push(child);
|
||||
if (visited.Add(child.Object.GobjectId))
|
||||
{
|
||||
stack.Push(child);
|
||||
}
|
||||
}
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
@@ -180,7 +192,10 @@ public static class GalaxyBrowseProjector
|
||||
{
|
||||
foreach (GalaxyObjectView grandchild in grandchildren)
|
||||
{
|
||||
stack.Push(grandchild);
|
||||
if (visited.Add(grandchild.Object.GobjectId))
|
||||
{
|
||||
stack.Push(grandchild);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,16 @@ public sealed class GalaxyHierarchyIndex
|
||||
IReadOnlyList<GalaxyObjectView> objectViews,
|
||||
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
|
||||
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> childrenByParent)
|
||||
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> childrenByParent,
|
||||
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByTagName,
|
||||
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByContainedPath)
|
||||
{
|
||||
ObjectViews = objectViews;
|
||||
ObjectViewsById = objectViewsById;
|
||||
TagsByAddress = tagsByAddress;
|
||||
ChildrenByParent = childrenByParent;
|
||||
ObjectViewsByTagName = objectViewsByTagName;
|
||||
ObjectViewsByContainedPath = objectViewsByContainedPath;
|
||||
}
|
||||
|
||||
/// <summary>Gets an empty Galaxy hierarchy index.</summary>
|
||||
@@ -21,7 +25,9 @@ public sealed class GalaxyHierarchyIndex
|
||||
Array.Empty<GalaxyObjectView>(),
|
||||
new Dictionary<int, GalaxyObjectView>(),
|
||||
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<int, IReadOnlyList<GalaxyObjectView>>());
|
||||
new Dictionary<int, IReadOnlyList<GalaxyObjectView>>(),
|
||||
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Gets the object views.</summary>
|
||||
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
|
||||
@@ -35,6 +41,12 @@ public sealed class GalaxyHierarchyIndex
|
||||
/// <summary>Gets direct children grouped by parent gobject id. Root objects (no parent, or self-parented) live under key 0. Each list is sorted areas-first, then by display name (OrdinalIgnoreCase).</summary>
|
||||
public IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> ChildrenByParent { get; }
|
||||
|
||||
/// <summary>Gets object views indexed by <see cref="GalaxyObject.TagName"/> (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by tag name in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
||||
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByTagName { get; }
|
||||
|
||||
/// <summary>Gets object views indexed by contained path (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by path in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
||||
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByContainedPath { get; }
|
||||
|
||||
/// <summary>Builds a Galaxy hierarchy index from the given objects.</summary>
|
||||
/// <param name="objects">The Galaxy objects to index.</param>
|
||||
/// <returns>A new Galaxy hierarchy index.</returns>
|
||||
@@ -54,6 +66,8 @@ public sealed class GalaxyHierarchyIndex
|
||||
List<GalaxyObjectView> views = new(objects.Count);
|
||||
Dictionary<int, GalaxyObjectView> viewsById = new();
|
||||
Dictionary<string, GalaxyTagLookup> tagsByAddress = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, GalaxyObjectView> viewsByTagName = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, GalaxyObjectView> viewsByContainedPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyObject obj in objects)
|
||||
{
|
||||
@@ -66,6 +80,12 @@ public sealed class GalaxyHierarchyIndex
|
||||
if (!string.IsNullOrWhiteSpace(obj.TagName))
|
||||
{
|
||||
tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path));
|
||||
viewsByTagName.TryAdd(obj.TagName, view);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
viewsByContainedPath.TryAdd(path, view);
|
||||
}
|
||||
|
||||
foreach (GalaxyAttribute attribute in obj.Attributes)
|
||||
@@ -109,7 +129,9 @@ public sealed class GalaxyHierarchyIndex
|
||||
views,
|
||||
viewsById,
|
||||
tagsByAddress,
|
||||
readOnlyChildren);
|
||||
readOnlyChildren,
|
||||
viewsByTagName,
|
||||
viewsByContainedPath);
|
||||
}
|
||||
|
||||
private static string BuildContainedPath(
|
||||
|
||||
@@ -103,7 +103,7 @@ public static class GalaxyHierarchyProjector
|
||||
// ResolveRoot can throw RpcException(NotFound); run it before consulting the
|
||||
// memo so a bad root surfaces consistently regardless of cache state.
|
||||
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
|
||||
GalaxyObjectView? root = ResolveRoot(request, views);
|
||||
GalaxyObjectView? root = ResolveRoot(request, entry.Index);
|
||||
|
||||
ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>> memo =
|
||||
FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>(StringComparer.Ordinal));
|
||||
@@ -176,17 +176,17 @@ public static class GalaxyHierarchyProjector
|
||||
|
||||
private static GalaxyObjectView? ResolveRoot(
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<GalaxyObjectView> views)
|
||||
GalaxyHierarchyIndex index)
|
||||
{
|
||||
GalaxyObjectView? root = request.RootCase switch
|
||||
{
|
||||
DiscoverHierarchyRequest.RootOneofCase.None => null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault(
|
||||
view => view.Object.GobjectId == request.RootGobjectId),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootTagName => views.FirstOrDefault(
|
||||
view => string.Equals(view.Object.TagName, request.RootTagName, StringComparison.OrdinalIgnoreCase)),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => views.FirstOrDefault(
|
||||
view => string.Equals(view.ContainedPath, request.RootContainedPath, StringComparison.OrdinalIgnoreCase)),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId =>
|
||||
index.ObjectViewsById.TryGetValue(request.RootGobjectId, out GalaxyObjectView? byId) ? byId : null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootTagName =>
|
||||
index.ObjectViewsByTagName.TryGetValue(request.RootTagName, out GalaxyObjectView? byTag) ? byTag : null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath =>
|
||||
index.ObjectViewsByContainedPath.TryGetValue(request.RootContainedPath, out GalaxyObjectView? byPath) ? byPath : null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
@@ -55,6 +58,8 @@ public static class GatewayApplication
|
||||
});
|
||||
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
|
||||
|
||||
ConfigureSelfSignedTls(builder);
|
||||
|
||||
builder.Services.AddGatewayConfiguration();
|
||||
builder.Services.AddSqliteAuthStore();
|
||||
builder.Services.AddGatewayGrpcAuthorization();
|
||||
@@ -72,6 +77,34 @@ public static class GatewayApplication
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void ConfigureSelfSignedTls(WebApplicationBuilder builder)
|
||||
{
|
||||
if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Configuration.TlsOptions tlsOptions =
|
||||
builder.Configuration.GetSection("MxGateway:Tls").Get<Configuration.TlsOptions>()
|
||||
?? new Configuration.TlsOptions();
|
||||
|
||||
using ILoggerFactory loggerFactory = LoggerFactory.Create(logging =>
|
||||
{
|
||||
logging.AddConfiguration(builder.Configuration.GetSection("Logging"));
|
||||
logging.AddConsole();
|
||||
});
|
||||
Security.Tls.SelfSignedCertificateProvider provider = new(
|
||||
tlsOptions,
|
||||
loggerFactory.CreateLogger<Security.Tls.SelfSignedCertificateProvider>(),
|
||||
TimeProvider.System);
|
||||
|
||||
X509Certificate2 certificate = provider.LoadOrCreate();
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
// The certificate is intentionally owned by Kestrel for the application
|
||||
// lifetime; it is not disposed here.
|
||||
options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate));
|
||||
}
|
||||
|
||||
private static string ResolveContentRootPath()
|
||||
{
|
||||
string? configuredContentRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT");
|
||||
|
||||
@@ -128,8 +128,11 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
|
||||
|
||||
// Resolve the parent id once so the page-token signature can include it
|
||||
// and the projector sees the same resolved id when memoizing.
|
||||
int parentId = ResolveParentIdForToken(entry, request);
|
||||
// and the projector sees the same resolved id when memoizing. The projector
|
||||
// re-resolves internally; with the by-name/by-path indexes on
|
||||
// GalaxyHierarchyIndex that second call is O(1), so the redundancy is cheap
|
||||
// and keeps the projector self-contained.
|
||||
int parentId = GalaxyDb.GalaxyBrowseProjector.ResolveParentId(entry, request);
|
||||
string filterSignature = GalaxyDb.GalaxyBrowseProjector.ComputeFilterSignature(
|
||||
request, browseSubtrees, parentId);
|
||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||
@@ -283,32 +286,6 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||
}
|
||||
|
||||
// Lightweight parent resolver used only for signature computation. Re-throws
|
||||
// NotFound consistently with the projector so the error surface matches.
|
||||
private static int ResolveParentIdForToken(
|
||||
GalaxyDb.GalaxyHierarchyCacheEntry entry,
|
||||
BrowseChildrenRequest request)
|
||||
{
|
||||
return request.ParentCase switch
|
||||
{
|
||||
BrowseChildrenRequest.ParentOneofCase.None => 0,
|
||||
BrowseChildrenRequest.ParentOneofCase.ParentGobjectId =>
|
||||
request.ParentGobjectId == 0 ? 0
|
||||
: entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId)
|
||||
? request.ParentGobjectId
|
||||
: throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
||||
BrowseChildrenRequest.ParentOneofCase.ParentTagName =>
|
||||
entry.Index.ObjectViews.FirstOrDefault(
|
||||
v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
|
||||
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
||||
BrowseChildrenRequest.ParentOneofCase.ParentContainedPath =>
|
||||
entry.Index.ObjectViews.FirstOrDefault(
|
||||
v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
|
||||
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> ResolveBrowseSubtrees()
|
||||
{
|
||||
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||
@@ -348,21 +325,21 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is invalid."));
|
||||
"page_token is invalid."));
|
||||
}
|
||||
|
||||
if (sequence != currentSequence)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is stale."));
|
||||
"page_token is stale."));
|
||||
}
|
||||
|
||||
if (!string.Equals(parts[1], currentFilterSignature, StringComparison.Ordinal))
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token does not match the current filters."));
|
||||
"page_token does not match the current filters."));
|
||||
}
|
||||
|
||||
return new PageToken(sequence, parts[1], offset);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Tls;
|
||||
|
||||
/// <summary>
|
||||
/// Inspects the Kestrel configuration to decide whether the gateway must supply
|
||||
/// a generated default certificate (an HTTPS endpoint exists with no certificate
|
||||
/// of its own).
|
||||
/// </summary>
|
||||
public static class KestrelTlsInspector
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when at least one HTTPS endpoint in
|
||||
/// <c>Kestrel:Endpoints</c> has no certificate of its own (no
|
||||
/// <c>Certificate:Path</c>, <c>Certificate:Subject</c>, or
|
||||
/// <c>Certificate:Thumbprint</c>), meaning the gateway must supply a
|
||||
/// generated fallback certificate.
|
||||
/// </summary>
|
||||
public static bool RequiresGeneratedCertificate(IConfiguration configuration)
|
||||
{
|
||||
// A Kestrel default certificate applies to every endpoint that lacks its own.
|
||||
// If the operator configured one, the gateway must not override it.
|
||||
if (HasConfiguredCertificate(configuration.GetSection("Kestrel:Certificates:Default")))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IConfigurationSection endpoints = configuration.GetSection("Kestrel:Endpoints");
|
||||
foreach (IConfigurationSection endpoint in endpoints.GetChildren())
|
||||
{
|
||||
string? url = endpoint["Url"];
|
||||
if (string.IsNullOrWhiteSpace(url) ||
|
||||
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!HasConfiguredCertificate(endpoint.GetSection("Certificate")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredCertificate(IConfigurationSection certificate)
|
||||
=> !string.IsNullOrWhiteSpace(certificate["Path"]) ||
|
||||
!string.IsNullOrWhiteSpace(certificate["Subject"]) ||
|
||||
!string.IsNullOrWhiteSpace(certificate["Thumbprint"]);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Tls;
|
||||
|
||||
/// <summary>
|
||||
/// Generates and persists a long-lived self-signed certificate used as the
|
||||
/// Kestrel HTTPS default when no operator certificate is configured.
|
||||
/// </summary>
|
||||
public sealed class SelfSignedCertificateProvider
|
||||
{
|
||||
private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1";
|
||||
private const string SubjectAltNameOid = "2.5.29.17";
|
||||
|
||||
private readonly TlsOptions _options;
|
||||
private readonly ILogger<SelfSignedCertificateProvider> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SelfSignedCertificateProvider(
|
||||
TlsOptions options,
|
||||
ILogger<SelfSignedCertificateProvider> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary>
|
||||
public X509Certificate2 GenerateCertificate()
|
||||
{
|
||||
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
CertificateRequest request = new(
|
||||
new X500DistinguishedName("CN=MxAccessGateway Self-Signed"),
|
||||
key,
|
||||
HashAlgorithmName.SHA256);
|
||||
|
||||
// End-entity (non-CA) certificate: RFC 5280 marks BasicConstraints critical only for CA certs.
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.DigitalSignature,
|
||||
critical: true));
|
||||
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
|
||||
[new Oid(ServerAuthOid, "Server Authentication")],
|
||||
critical: false));
|
||||
|
||||
SubjectAlternativeNameBuilder san = new();
|
||||
san.AddDnsName("localhost");
|
||||
string machine = Environment.MachineName;
|
||||
if (!string.IsNullOrWhiteSpace(machine))
|
||||
{
|
||||
san.AddDnsName(machine);
|
||||
}
|
||||
|
||||
// Best-effort: add the machine FQDN when it differs from the short name and "localhost".
|
||||
// GetHostEntry may fail if DNS is unavailable; skip silently in that case.
|
||||
try
|
||||
{
|
||||
string fqdn = Dns.GetHostEntry(machine).HostName;
|
||||
if (!string.IsNullOrWhiteSpace(fqdn)
|
||||
&& !fqdn.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||
&& !fqdn.Equals(machine, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
san.AddDnsName(fqdn);
|
||||
}
|
||||
}
|
||||
catch (SocketException) { /* DNS not resolvable — FQDN SAN is optional */ }
|
||||
catch (ArgumentException) { /* invalid host name — skip */ }
|
||||
|
||||
foreach (string extra in _options.AdditionalDnsNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(extra))
|
||||
{
|
||||
san.AddDnsName(extra);
|
||||
}
|
||||
}
|
||||
|
||||
san.AddIpAddress(IPAddress.Loopback);
|
||||
san.AddIpAddress(IPAddress.IPv6Loopback);
|
||||
request.CertificateExtensions.Add(san.Build());
|
||||
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(_options.ValidityYears));
|
||||
}
|
||||
|
||||
/// <summary>Loads the persisted certificate, regenerating when missing,
|
||||
/// expired (and allowed), or unreadable.</summary>
|
||||
public X509Certificate2 LoadOrCreate()
|
||||
{
|
||||
string path = _options.SelfSignedCertPath;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"MxGateway:Tls:SelfSignedCertPath must be set when an HTTPS endpoint has no certificate.");
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
X509Certificate2 existing = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
|
||||
if (existing.NotAfter.ToUniversalTime() > _timeProvider.GetUtcNow().UtcDateTime)
|
||||
{
|
||||
Log("Loaded", existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!_options.RegenerateIfExpired)
|
||||
{
|
||||
string notAfter = existing.NotAfter.ToUniversalTime().ToString("u");
|
||||
existing.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"Persisted gateway certificate at '{path}' expired on {notAfter} " +
|
||||
"and MxGateway:Tls:RegenerateIfExpired is false.");
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Persisted gateway certificate at {Path} expired on {NotAfter:u}; regenerating.",
|
||||
path, existing.NotAfter.ToUniversalTime());
|
||||
existing.Dispose();
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Persisted gateway certificate at {Path} is unreadable; regenerating.", path);
|
||||
}
|
||||
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
|
||||
{
|
||||
// A permission/IO error must fail fast: do not regenerate (which would
|
||||
// overwrite the operator's file), surface the cause instead.
|
||||
throw new InvalidOperationException(
|
||||
$"Persisted gateway certificate at '{path}' could not be read; the gateway " +
|
||||
"process lacks read access or the file is otherwise inaccessible.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return GenerateAndPersist(path);
|
||||
}
|
||||
|
||||
private X509Certificate2 GenerateAndPersist(string path)
|
||||
{
|
||||
using X509Certificate2 generated = GenerateCertificate();
|
||||
byte[] pfx = generated.Export(X509ContentType.Pkcs12);
|
||||
try
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
// The private-key bytes must never touch a world-readable file. Create the
|
||||
// temp file empty, harden its permissions, and only then write the PFX into
|
||||
// the already-protected file. The temp path is in the same directory as the
|
||||
// target so the Move is atomic and preserves the hardened DACL/mode.
|
||||
string temp = path + ".tmp";
|
||||
using (File.Create(temp)) { }
|
||||
HardenPermissions(temp);
|
||||
|
||||
// Writing into an existing file truncates content but preserves its ACL/mode.
|
||||
// If the write or move fails the hardened temp file (which may contain private-key
|
||||
// material) must not be left on disk; delete it best-effort before rethrowing.
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(temp, pfx);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
HardenPermissions(path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
try { File.Delete(temp); } catch { /* best effort */ }
|
||||
throw;
|
||||
}
|
||||
|
||||
X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
|
||||
Log("Generated", loaded);
|
||||
return loaded;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// pfx holds raw private-key material; clear it as soon as it is no longer needed.
|
||||
Array.Clear(pfx, 0, pfx.Length);
|
||||
}
|
||||
}
|
||||
|
||||
private static X509KeyStorageFlags KeyStorageFlags()
|
||||
=> OperatingSystem.IsWindows()
|
||||
? X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable
|
||||
: X509KeyStorageFlags.Exportable;
|
||||
|
||||
private static void HardenPermissions(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
HardenWindowsAcl(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private static void HardenWindowsAcl(string path)
|
||||
{
|
||||
FileInfo file = new(path);
|
||||
System.Security.AccessControl.FileSecurity security = new();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
|
||||
foreach (System.Security.Principal.WellKnownSidType sid in new[]
|
||||
{
|
||||
System.Security.Principal.WellKnownSidType.LocalSystemSid,
|
||||
System.Security.Principal.WellKnownSidType.BuiltinAdministratorsSid,
|
||||
})
|
||||
{
|
||||
System.Security.Principal.SecurityIdentifier identifier = new(sid, null);
|
||||
security.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
|
||||
identifier,
|
||||
System.Security.AccessControl.FileSystemRights.FullControl,
|
||||
System.Security.AccessControl.AccessControlType.Allow));
|
||||
}
|
||||
|
||||
file.SetAccessControl(security);
|
||||
}
|
||||
|
||||
private void Log(string action, X509Certificate2 cert)
|
||||
{
|
||||
string sans = cert.Extensions
|
||||
.FirstOrDefault(e => e.Oid?.Value == SubjectAltNameOid)?
|
||||
.Format(false) ?? "(none)";
|
||||
_logger.LogInformation(
|
||||
"{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}",
|
||||
action, cert.Thumbprint, cert.NotAfter.ToUniversalTime(), sans);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
public sealed class GatewayOptionsValidatorTests
|
||||
{
|
||||
// Constructs the minimal valid GatewayOptions by relying on each sub-option's
|
||||
// design-default values; those defaults are validated separately in GatewayOptionsTests.
|
||||
private static GatewayOptions ValidOptions() => new();
|
||||
|
||||
private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls)
|
||||
=> new()
|
||||
{
|
||||
Authentication = source.Authentication,
|
||||
Ldap = source.Ldap,
|
||||
Worker = source.Worker,
|
||||
Sessions = source.Sessions,
|
||||
Events = source.Events,
|
||||
Dashboard = source.Dashboard,
|
||||
Protocol = source.Protocol,
|
||||
Alarms = source.Alarms,
|
||||
Tls = tls,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WithDefaultTlsOptions()
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenTlsValidityYearsOutOfRange()
|
||||
{
|
||||
GatewayOptions withBadTls = CloneWithTls(ValidOptions(), new TlsOptions { ValidityYears = 0 });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenTlsValidityYearsTooLarge()
|
||||
{
|
||||
GatewayOptions withBadTls = CloneWithTls(ValidOptions(), new TlsOptions { ValidityYears = 101 });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenAdditionalDnsNameBlank()
|
||||
{
|
||||
GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { AdditionalDnsNames = [" "] });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenSelfSignedCertPathBlank()
|
||||
{
|
||||
GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { SelfSignedCertPath = " " });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
public sealed class TlsOptionsBindingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_AreApplied_WhenSectionAbsent()
|
||||
{
|
||||
TlsOptions options = new();
|
||||
Assert.Equal(10, options.ValidityYears);
|
||||
Assert.True(options.RegenerateIfExpired);
|
||||
Assert.Empty(options.AdditionalDnsNames);
|
||||
Assert.False(string.IsNullOrWhiteSpace(options.SelfSignedCertPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Binds_FromMxGatewayTlsSection()
|
||||
{
|
||||
IConfiguration config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MxGateway:Tls:ValidityYears"] = "5",
|
||||
["MxGateway:Tls:SelfSignedCertPath"] = @"C:\tmp\gw.pfx",
|
||||
["MxGateway:Tls:RegenerateIfExpired"] = "false",
|
||||
["MxGateway:Tls:AdditionalDnsNames:0"] = "gw.internal",
|
||||
})
|
||||
.Build();
|
||||
|
||||
GatewayOptions options = config.GetSection(GatewayOptions.SectionName).Get<GatewayOptions>()!;
|
||||
|
||||
Assert.Equal(5, options.Tls.ValidityYears);
|
||||
Assert.Equal(@"C:\tmp\gw.pfx", options.Tls.SelfSignedCertPath);
|
||||
Assert.False(options.Tls.RegenerateIfExpired);
|
||||
Assert.Equal("gw.internal", Assert.Single(options.Tls.AdditionalDnsNames));
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,77 @@ public sealed class GalaxyBrowseProjectorTests
|
||||
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="GalaxyBrowseProjector"/> terminates when the Galaxy data
|
||||
/// contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in
|
||||
/// <c>HasMatchingDescendant</c>, the depth-first walk would loop forever; the
|
||||
/// 5-second xUnit timeout asserts termination.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5000)]
|
||||
public async Task Project_CyclicDescendants_DoesNotInfiniteLoop()
|
||||
{
|
||||
await Task.Yield();
|
||||
// Construct a 3-node cycle: A(10)→B(11)→C(12)→A. Each node's ParentGobjectId
|
||||
// points to the next, so GalaxyHierarchyIndex.ChildrenByParent has
|
||||
// [12] = [A], [10] = [B], [11] = [C].
|
||||
// None of them are historized, so HistorizedOnly=true forces the projector to
|
||||
// call HasMatchingDescendant on every direct child, exercising the cycle walk.
|
||||
GalaxyObject a = new()
|
||||
{
|
||||
GobjectId = 10,
|
||||
ParentGobjectId = 12,
|
||||
ContainedName = "A",
|
||||
BrowseName = "A",
|
||||
TagName = "A",
|
||||
};
|
||||
GalaxyObject b = new()
|
||||
{
|
||||
GobjectId = 11,
|
||||
ParentGobjectId = 10,
|
||||
ContainedName = "B",
|
||||
BrowseName = "B",
|
||||
TagName = "B",
|
||||
};
|
||||
GalaxyObject c = new()
|
||||
{
|
||||
GobjectId = 12,
|
||||
ParentGobjectId = 11,
|
||||
ContainedName = "C",
|
||||
BrowseName = "C",
|
||||
TagName = "C",
|
||||
};
|
||||
|
||||
IReadOnlyList<GalaxyObject> objects = new[] { a, b, c };
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 1,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
Objects = objects,
|
||||
Index = GalaxyHierarchyIndex.Build(objects),
|
||||
DashboardSummary = DashboardGalaxySummary.Unknown with
|
||||
{
|
||||
Status = DashboardGalaxyStatus.Healthy,
|
||||
ObjectCount = objects.Count,
|
||||
},
|
||||
ObjectCount = objects.Count,
|
||||
};
|
||||
|
||||
// Browse children of A (id=10). Its direct child B fails HistorizedOnly, so the
|
||||
// projector falls back to HasMatchingDescendant(B), which walks B→C→A→B…
|
||||
// without the visited-id guard. With the guard, the walk terminates and returns
|
||||
// an empty page (no historized descendants exist anywhere in the cycle).
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 10, HistorizedOnly = true },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
Assert.Empty(result.Children);
|
||||
Assert.Equal(0, result.TotalChildCount);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyCacheEntry CreateEntry()
|
||||
{
|
||||
IReadOnlyList<GalaxyObject> objects = CreateObjects();
|
||||
|
||||
@@ -75,6 +75,47 @@ public sealed class GalaxyHierarchyIndexTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies <see cref="GalaxyHierarchyIndex.ObjectViewsByTagName"/> is OrdinalIgnoreCase and supports O(1) lookups.</summary>
|
||||
[Fact]
|
||||
public void ObjectViewsByTagName_IsCaseInsensitive_AndLookupsAreO1()
|
||||
{
|
||||
GalaxyObject root = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Plant", BrowseName = "Plant", TagName = "Plant" };
|
||||
GalaxyObject mixer = new() { GobjectId = 2, ParentGobjectId = 1, ContainedName = "Mixer_001", BrowseName = "Mixer_001", TagName = "Plant.Mixer_001" };
|
||||
|
||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, mixer]);
|
||||
|
||||
Assert.True(index.ObjectViewsByTagName.TryGetValue("Plant.Mixer_001", out GalaxyObjectView? exact));
|
||||
Assert.NotNull(exact);
|
||||
Assert.Equal(2, exact!.Object.GobjectId);
|
||||
|
||||
// Case-insensitive lookup must hit the same entry.
|
||||
Assert.True(index.ObjectViewsByTagName.TryGetValue("plant.mixer_001", out GalaxyObjectView? lower));
|
||||
Assert.NotNull(lower);
|
||||
Assert.Same(exact, lower);
|
||||
|
||||
Assert.False(index.ObjectViewsByTagName.ContainsKey("Plant.Missing"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies <see cref="GalaxyHierarchyIndex.ObjectViewsByContainedPath"/> is OrdinalIgnoreCase.</summary>
|
||||
[Fact]
|
||||
public void ObjectViewsByContainedPath_IsCaseInsensitive()
|
||||
{
|
||||
GalaxyObject root = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Plant", BrowseName = "Plant", TagName = "Plant" };
|
||||
GalaxyObject lineA = new() { GobjectId = 2, ParentGobjectId = 1, IsArea = true, ContainedName = "Line_A", BrowseName = "Line_A", TagName = "Plant.Line_A" };
|
||||
|
||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, lineA]);
|
||||
|
||||
Assert.True(index.ObjectViewsByContainedPath.TryGetValue("Plant/Line_A", out GalaxyObjectView? exact));
|
||||
Assert.NotNull(exact);
|
||||
Assert.Equal(2, exact!.Object.GobjectId);
|
||||
|
||||
Assert.True(index.ObjectViewsByContainedPath.TryGetValue("plant/line_a", out GalaxyObjectView? lower));
|
||||
Assert.NotNull(lower);
|
||||
Assert.Same(exact, lower);
|
||||
|
||||
Assert.False(index.ObjectViewsByContainedPath.ContainsKey("Plant/Missing"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies children sort areas-first, then by display name (case-insensitive).</summary>
|
||||
[Fact]
|
||||
public void ChildrenByParent_SortsAreasFirstThenByDisplayName()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.MxGateway.Server;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
||||
|
||||
public sealed class GatewayTlsBootstrapTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that when a Kestrel HTTPS endpoint is configured without its own certificate,
|
||||
/// the gateway supplies the generated self-signed certificate as the Kestrel HTTPS default.
|
||||
/// The host must start and bind, and the certificate served on the TLS handshake must be the
|
||||
/// gateway's generated cert (subject <c>CN=MxAccessGateway Self-Signed</c>) — not an ambient
|
||||
/// ASP.NET Core development certificate. On a host with no dev cert installed, starting a
|
||||
/// cert-less HTTPS endpoint throws "No server certificate was specified"; on a host that has a
|
||||
/// trusted dev cert, Kestrel would otherwise serve that dev cert (<c>CN=localhost</c>), so the
|
||||
/// subject assertion is what makes this test fail without the wiring on either kind of host.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Host_ServesGeneratedCertificate_WhenHttpsEndpointHasNoCertificate()
|
||||
{
|
||||
string certDir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", "https://127.0.0.1:0");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"MxGateway__Tls__SelfSignedCertPath", Path.Combine(certDir, "gw.pfx"));
|
||||
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
await app.StartAsync();
|
||||
try
|
||||
{
|
||||
string servedSubject = await ReadServedCertificateSubjectAsync(app);
|
||||
Assert.Contains("MxAccessGateway Self-Signed", servedSubject, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", null);
|
||||
Environment.SetEnvironmentVariable("MxGateway__Tls__SelfSignedCertPath", null);
|
||||
Directory.Delete(certDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadServedCertificateSubjectAsync(WebApplication app)
|
||||
{
|
||||
IServerAddressesFeature addresses =
|
||||
app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()
|
||||
?? throw new InvalidOperationException("Server addresses feature was not available.");
|
||||
Uri endpoint = new(addresses.Addresses.First());
|
||||
|
||||
using TcpClient client = new();
|
||||
await client.ConnectAsync(endpoint.Host, endpoint.Port);
|
||||
using SslStream ssl = new(
|
||||
client.GetStream(),
|
||||
leaveInnerStreamOpen: false,
|
||||
userCertificateValidationCallback: (_, _, _, _) => true);
|
||||
await ssl.AuthenticateAsClientAsync("127.0.0.1");
|
||||
|
||||
return ssl.RemoteCertificate?.Subject ?? "(none)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Tls;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls;
|
||||
|
||||
public sealed class KestrelTlsInspectorTests
|
||||
{
|
||||
private static IConfiguration Config(params (string Key, string Value)[] entries)
|
||||
=> new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value))
|
||||
.Build();
|
||||
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_True_WhenHttpsEndpointHasNoCertificate()
|
||||
=> Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120"))));
|
||||
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenAllEndpointsPlaintext()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(("Kestrel:Endpoints:Http:Url", "http://0.0.0.0:5120"))));
|
||||
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasOwnCertificate()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(
|
||||
("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120"),
|
||||
("Kestrel:Endpoints:Http:Certificate:Path", @"C:\certs\real.pfx"))));
|
||||
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenNoEndpointsConfigured()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(Config()));
|
||||
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasThumbprintOnly()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(
|
||||
("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"),
|
||||
("Kestrel:Endpoints:Https:Certificate:Thumbprint", "AABBCCDDEEFF00112233445566778899AABBCCDD"))));
|
||||
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasSubjectOnly()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(
|
||||
("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"),
|
||||
("Kestrel:Endpoints:Https:Certificate:Subject", "CN=myserver"))));
|
||||
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_True_WhenHttpsUrlIsUppercase()
|
||||
=> Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(("Kestrel:Endpoints:Https:Url", "HTTPS://0.0.0.0:5120"))));
|
||||
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_False_WhenKestrelDefaultCertificateConfigured()
|
||||
=> Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(
|
||||
("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"),
|
||||
("Kestrel:Certificates:Default:Path", @"C:\certs\default.pfx"))));
|
||||
|
||||
[Fact]
|
||||
public void RequiresGeneratedCertificate_True_WhenMixedEndpointsAndOneHttpsHasNoCert()
|
||||
=> Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate(
|
||||
Config(
|
||||
("Kestrel:Endpoints:Grpc:Url", "https://0.0.0.0:5120"),
|
||||
("Kestrel:Endpoints:Grpc:Certificate:Thumbprint", "AABBCCDDEEFF00112233445566778899AABBCCDD"),
|
||||
("Kestrel:Endpoints:Dashboard:Url", "https://0.0.0.0:5130"))));
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Tls;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls;
|
||||
|
||||
public sealed class SelfSignedCertificateProviderTests
|
||||
{
|
||||
private static SelfSignedCertificateProvider CreateProvider(TlsOptions options, FakeTimeProvider time)
|
||||
=> new(options, NullLogger<SelfSignedCertificateProvider>.Instance, time);
|
||||
|
||||
[Fact]
|
||||
public void GenerateCertificate_HasExpectedSansEkuAndValidity()
|
||||
{
|
||||
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
TlsOptions options = new() { ValidityYears = 7, AdditionalDnsNames = ["gw.internal"] };
|
||||
|
||||
using X509Certificate2 cert = CreateProvider(options, time).GenerateCertificate();
|
||||
|
||||
Assert.Equal(time.GetUtcNow().AddYears(7).UtcDateTime.Date, cert.NotAfter.ToUniversalTime().Date);
|
||||
Assert.True(cert.NotBefore.ToUniversalTime() < time.GetUtcNow().UtcDateTime);
|
||||
Assert.True(cert.HasPrivateKey);
|
||||
|
||||
string sans = ReadSubjectAltNames(cert);
|
||||
Assert.Contains("localhost", sans);
|
||||
Assert.Contains("gw.internal", sans);
|
||||
Assert.Contains(Environment.MachineName, sans);
|
||||
// Format() renders IP SANs as "IP Address:<addr>"; the IPv6 loopback may appear
|
||||
// as "::1" or its expanded form depending on the platform crypto library.
|
||||
Assert.Contains("127.0.0.1", sans);
|
||||
Assert.True(sans.Contains("::1") || sans.Contains("0:0:0:0:0:0:0:1"),
|
||||
$"Expected IPv6 loopback in SANs but got: {sans}");
|
||||
|
||||
X509EnhancedKeyUsageExtension eku = cert.Extensions.OfType<X509EnhancedKeyUsageExtension>().Single();
|
||||
Assert.Contains(eku.EnhancedKeyUsages.Cast<System.Security.Cryptography.Oid>(),
|
||||
o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreate_GeneratesPersistsAndReuses_SameThumbprint()
|
||||
{
|
||||
string dir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(dir, "gw.pfx");
|
||||
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
TlsOptions options = new() { SelfSignedCertPath = path };
|
||||
|
||||
using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
|
||||
Assert.True(File.Exists(path));
|
||||
using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();
|
||||
|
||||
Assert.Equal(first.Thumbprint, second.Thumbprint); // reused, not regenerated
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreate_Regenerates_WhenPersistedCertExpired()
|
||||
{
|
||||
string dir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(dir, "gw.pfx");
|
||||
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1 };
|
||||
|
||||
using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
|
||||
time.Advance(TimeSpan.FromDays(800)); // past 1-year validity
|
||||
using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();
|
||||
|
||||
Assert.NotEqual(first.Thumbprint, second.Thumbprint);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreate_Regenerates_WhenPersistedFileCorrupt()
|
||||
{
|
||||
string dir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(dir, "gw.pfx");
|
||||
File.WriteAllText(path, "not a pfx");
|
||||
TlsOptions options = new() { SelfSignedCertPath = path };
|
||||
using X509Certificate2 cert = CreateProvider(options, new FakeTimeProvider()).LoadOrCreate();
|
||||
Assert.True(cert.HasPrivateKey);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreate_Throws_WhenExpiredAndRegenerateDisabled()
|
||||
{
|
||||
string dir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(dir, "gw.pfx");
|
||||
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1, RegenerateIfExpired = false };
|
||||
using (CreateProvider(options, time).LoadOrCreate()) { }
|
||||
time.Advance(TimeSpan.FromDays(800));
|
||||
Assert.Throws<InvalidOperationException>(() => CreateProvider(options, time).LoadOrCreate());
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreate_Throws_WhenSelfSignedCertPathBlank()
|
||||
{
|
||||
TlsOptions options = new() { SelfSignedCertPath = " " };
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GenerateAndPersist cleans up the hardened .tmp file when persist fails.
|
||||
/// The failure is induced by setting SelfSignedCertPath to a path whose parent directory
|
||||
/// is an existing regular file, causing Directory.CreateDirectory (or the subsequent write)
|
||||
/// to throw an IOException/UnauthorizedAccessException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LoadOrCreate_DeletesTempFile_WhenPersistFails()
|
||||
{
|
||||
string outerDir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
// Create a regular file at what would be the parent directory of the cert path.
|
||||
// Any attempt to create that "directory" or write files into it must fail.
|
||||
string fileActingAsDir = Path.Combine(outerDir, "notadir");
|
||||
File.WriteAllText(fileActingAsDir, "block");
|
||||
|
||||
// Point the cert path inside the regular file — Directory.CreateDirectory will
|
||||
// throw because the parent path component is a file, not a directory.
|
||||
string certPath = Path.Combine(fileActingAsDir, "gw.pfx");
|
||||
string expectedTemp = certPath + ".tmp";
|
||||
|
||||
TlsOptions options = new() { SelfSignedCertPath = certPath };
|
||||
Assert.ThrowsAny<Exception>(() => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate());
|
||||
|
||||
// The .tmp file must not be left behind.
|
||||
Assert.False(File.Exists(expectedTemp), $"Leaked temp file: {expectedTemp}");
|
||||
}
|
||||
finally { Directory.Delete(outerDir, recursive: true); }
|
||||
}
|
||||
|
||||
private const string SubjectAltNameOid = "2.5.29.17";
|
||||
|
||||
private static string ReadSubjectAltNames(X509Certificate2 cert)
|
||||
=> cert.Extensions
|
||||
.First(e => e.Oid?.Value == SubjectAltNameOid)
|
||||
.Format(false);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
|
||||
Reference in New Issue
Block a user