diff --git a/clients/dotnet/DotnetClientDesign.md b/clients/dotnet/DotnetClientDesign.md
index fd913b5..4124f3d 100644
--- a/clients/dotnet/DotnetClientDesign.md
+++ b/clients/dotnet/DotnetClientDesign.md
@@ -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:
diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md
index 333df1f..a4db520 100644
--- a/clients/dotnet/README.md
+++ b/clients/dotnet/README.md
@@ -287,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:
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs
new file mode 100644
index 0000000..ac6fde9
--- /dev/null
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs
@@ -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
+{
+ ///
+ /// 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.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verifies that when RequireCertificateValidation is true, the callback is left null
+ /// so the OS trust store performs validation.
+ ///
+ [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
+{
+ ///
+ /// 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.
+ ///
+ [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));
+ }
+
+ ///
+ /// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null
+ /// so the OS trust store performs validation.
+ ///
+ [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);
+ }
+}
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs
index ac5029b..7d05638 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs
@@ -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;
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs
index 2ae95ff..6d9ab58 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs
@@ -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;
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs
index f66b56e..df93c3b 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs
@@ -27,6 +27,14 @@ public sealed class MxGatewayClientOptions
///
public string? CaCertificatePath { get; init; }
+ ///
+ /// When true, TLS connections without a pinned
+ /// 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.
+ ///
+ public bool RequireCertificateValidation { get; init; }
+
///
/// Gets the server name override for SNI during TLS handshake.
///
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj
index 590435d..bf34ef7 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj
@@ -27,4 +27,10 @@
+
+
+ <_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests
+
+
+
diff --git a/clients/go/GoClientDesign.md b/clients/go/GoClientDesign.md
index f7d2682..dd0d51b 100644
--- a/clients/go/GoClientDesign.md
+++ b/clients/go/GoClientDesign.md
@@ -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:
diff --git a/clients/go/README.md b/clients/go/README.md
index 07beb00..b6ab95c 100644
--- a/clients/go/README.md
+++ b/clients/go/README.md
@@ -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
diff --git a/clients/go/mxgateway/client.go b/clients/go/mxgateway/client.go
index 2aac029..9bf2370 100644
--- a/clients/go/mxgateway/client.go
+++ b/clients/go/mxgateway/client.go
@@ -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.
diff --git a/clients/go/mxgateway/client_tls_test.go b/clients/go/mxgateway/client_tls_test.go
new file mode 100644
index 0000000..7a88ccc
--- /dev/null
+++ b/clients/go/mxgateway/client_tls_test.go
@@ -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)")
+ }
+}
diff --git a/clients/go/mxgateway/options.go b/clients/go/mxgateway/options.go
index 12b0e34..732c662 100644
--- a/clients/go/mxgateway/options.go
+++ b/clients/go/mxgateway/options.go
@@ -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
diff --git a/clients/java/JavaClientDesign.md b/clients/java/JavaClientDesign.md
index b21ba41..d300ca8 100644
--- a/clients/java/JavaClientDesign.md
+++ b/clients/java/JavaClientDesign.md
@@ -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:
diff --git a/clients/java/README.md b/clients/java/README.md
index 36f4486..c6abd36 100644
--- a/clients/java/README.md
+++ b/clients/java/README.md
@@ -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
diff --git a/clients/java/settings.gradle b/clients/java/settings.gradle
index 9b5a97f..b8e5f5a 100644
--- a/clients/java/settings.gradle
+++ b/clients/java/settings.gradle
@@ -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 {
diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java
index 0aa4c35..8021011 100644
--- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java
+++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java
@@ -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 withDeadline(T stub) {
if (options.callTimeout().isNegative()) {
return stub;
diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java
index 2beac84..2f5642f 100644
--- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java
+++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java
@@ -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.
*
diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java
new file mode 100644
index 0000000..42fe0b7
--- /dev/null
+++ b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java
@@ -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.
+ *
+ *
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 responseObserver) {
+ responseObserver.onNext(OpenSessionReply.newBuilder()
+ .setSessionId("tls-test-session")
+ .setProtocolStatus(ProtocolStatus.newBuilder()
+ .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
+ .build())
+ .build());
+ responseObserver.onCompleted();
+ }
+ }
+}
diff --git a/clients/python/PythonClientDesign.md b/clients/python/PythonClientDesign.md
index 5865e8c..9eb0d5c 100644
--- a/clients/python/PythonClientDesign.md
+++ b/clients/python/PythonClientDesign.md
@@ -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
diff --git a/clients/python/README.md b/clients/python/README.md
index 3ffa16c..70b59d9 100644
--- a/clients/python/README.md
+++ b/clients/python/README.md
@@ -230,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:
diff --git a/clients/python/src/zb_mom_ww_mxgateway/options.py b/clients/python/src/zb_mom_ww_mxgateway/options.py
index 060d50c..29caf29 100644
--- a/clients/python/src/zb_mom_ww_mxgateway/options.py
+++ b/clients/python/src/zb_mom_ww_mxgateway/options.py
@@ -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,
diff --git a/clients/python/tests/test_auth_options.py b/clients/python/tests/test_auth_options.py
index d8242ce..fa6c36c 100644
--- a/clients/python/tests/test_auth_options.py
+++ b/clients/python/tests/test_auth_options.py
@@ -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),
+ ],
+ ),
+ ]
diff --git a/clients/python/tests/test_tls.py b/clients/python/tests/test_tls.py
new file mode 100644
index 0000000..d7c1c4b
--- /dev/null
+++ b/clients/python/tests/test_tls.py
@@ -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)
diff --git a/clients/rust/README.md b/clients/rust/README.md
index b31dc8a..ccb3397 100644
--- a/clients/rust/README.md
+++ b/clients/rust/README.md
@@ -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,
diff --git a/clients/rust/RustClientDesign.md b/clients/rust/RustClientDesign.md
index fc94e4d..d6c1385 100644
--- a/clients/rust/RustClientDesign.md
+++ b/clients/rust/RustClientDesign.md
@@ -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>`. Dropping the
diff --git a/clients/rust/crates/mxgw-cli/src/main.rs b/clients/rust/crates/mxgw-cli/src/main.rs
index 343081e..2f98d6f 100644
--- a/clients/rust/crates/mxgw-cli/src/main.rs
+++ b/clients/rust/crates/mxgw-cli/src/main.rs
@@ -426,6 +426,11 @@ struct ConnectionArgs {
ca_file: Option,
#[arg(long)]
server_name_override: Option,
+ /// 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
}
diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs
index 1078262..bdac543 100644
--- a/clients/rust/src/client.rs
+++ b/clients/rust/src/client.rs
@@ -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)?;
}
diff --git a/clients/rust/src/galaxy.rs b/clients/rust/src/galaxy.rs
index 8aee0dd..8ae97d7 100644
--- a/clients/rust/src/galaxy.rs
+++ b/clients/rust/src/galaxy.rs
@@ -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)?;
}
diff --git a/clients/rust/src/options.rs b/clients/rust/src/options.rs
index b797552..4031809 100644
--- a/clients/rust/src/options.rs
+++ b/clients/rust/src/options.rs
@@ -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,
plaintext: bool,
ca_file: Option,
+ require_certificate_validation: bool,
server_name_override: Option,
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) -> 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