feat(mxgw-go): add -require-certificate-validation for strict TLS (#199)

Over TLS without -ca-cert the CLI was always skip-verify, with no way to ask
for verification against the system roots even though the library supports
it. The new common flag is wired to Options.RequireCertificateValidation in
both the gateway and Galaxy dialers and echoed in the redacted JSON options.

Test: tls_flag_test.go starts a self-signed TLS gRPC gateway; the default
open-session succeeds (positive control) and the same call with the flag
fails to dial (fails on the old code with 'flag provided but not defined').
This commit is contained in:
Joseph Doherty
2026-11-06 16:10:39 -05:00
parent 9351ec1d2c
commit 574369b666
3 changed files with 130 additions and 13 deletions
+5 -1
View File
@@ -432,7 +432,11 @@ the gateway:
Every subcommand wired into the CLI. All accept the common flags
(`-endpoint`, `-plaintext`, `-api-key` / `-api-key-env`, `-ca-cert`,
`-server-name-override`, `-call-timeout`) and most accept `-json`.
`-server-name-override`, `-call-timeout`, `-require-certificate-validation`) and
most accept `-json`. Over TLS without `-ca-cert` the CLI accepts any gateway
certificate (the self-signed default); `-require-certificate-validation` sets
`Options.RequireCertificateValidation` so the certificate must chain to the
system trust roots instead.
| Command | Purpose |
|---|---|
+20 -12
View File
@@ -40,6 +40,10 @@ type commonOptions struct {
CACertFile string `json:"caCertFile,omitempty"`
ServerName string `json:"serverNameOverride,omitempty"`
CallTimeout string `json:"callTimeout,omitempty"`
// RequireCertificateValidation verifies the gateway certificate against the
// system trust roots when no -ca-cert is pinned, instead of the lenient
// self-signed default.
RequireCertificateValidation bool `json:"requireCertificateValidation,omitempty"`
apiKeyValue string
timeout time.Duration
@@ -1297,6 +1301,8 @@ func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
flags.StringVar(&common.CACertFile, "ca-cert", "", "CA certificate file")
flags.StringVar(&common.ServerName, "server-name-override", "", "TLS server name override")
flags.StringVar(&common.CallTimeout, "call-timeout", "30s", "per-call timeout")
flags.BoolVar(&common.RequireCertificateValidation, "require-certificate-validation", false,
"verify the gateway TLS certificate against the system trust roots when no -ca-cert is set (default: accept any certificate)")
return common
}
@@ -1307,12 +1313,13 @@ func dialForCommand(ctx context.Context, common *commonOptions) (*mxgateway.Clie
}
client, err := mxgateway.Dial(ctx, mxgateway.Options{
Endpoint: options.Endpoint,
APIKey: options.apiKeyValue,
Plaintext: options.Plaintext,
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
Endpoint: options.Endpoint,
APIKey: options.apiKeyValue,
Plaintext: options.Plaintext,
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
RequireCertificateValidation: options.RequireCertificateValidation,
})
return client, options, err
}
@@ -1523,12 +1530,13 @@ func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgatewa
}
client, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: options.Endpoint,
APIKey: options.apiKeyValue,
Plaintext: options.Plaintext,
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
Endpoint: options.Endpoint,
APIKey: options.apiKeyValue,
Plaintext: options.Plaintext,
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
RequireCertificateValidation: options.RequireCertificateValidation,
})
return client, options, err
}
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"strings"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// #199 — `--tls` without a CA used to be skip-verify with no way out from the
// CLI. -require-certificate-validation must make the CLI reject a certificate
// the system roots do not trust (the gateway's own self-signed one).
type openSessionFakeGateway struct {
pb.UnimplementedMxAccessGatewayServer
}
func (g *openSessionFakeGateway) OpenSession(context.Context, *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
return &pb.OpenSessionReply{
SessionId: "tls-session",
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}, nil
}
func startSelfSignedTLSGateway(t *testing.T) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})))
pb.RegisterMxAccessGatewayServer(server, &openSessionFakeGateway{})
go func() { _ = server.Serve(listener) }()
t.Cleanup(func() {
server.Stop()
_ = listener.Close()
})
return listener.Addr().String()
}
func TestRequireCertificateValidationFlagRejectsSelfSignedGateway(t *testing.T) {
endpoint := startSelfSignedTLSGateway(t)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
base := []string{"open-session", "-endpoint", endpoint, "-api-key", "test", "-call-timeout", "5s"}
// Positive control: the lenient default accepts the self-signed certificate.
var okOut, okErr bytes.Buffer
if err := runWithIO(ctx, base, &okOut, &okErr); err != nil {
t.Fatalf("default TLS open-session error = %v; stderr = %s", err, okErr.String())
}
if got := strings.TrimSpace(okOut.String()); got != "tls-session" {
t.Fatalf("default TLS open-session output = %q", got)
}
// Strict: the handshake never verifies, so the connected-dial wait runs out.
// The dial error (not a flag-parse error) is what proves the flag reached the
// TLS configuration; the positive control above proves the endpoint is live.
strictCtx, strictCancel := context.WithTimeout(t.Context(), 2*time.Second)
defer strictCancel()
var strictOut, strictErr bytes.Buffer
err := runWithIO(strictCtx, append(append([]string{}, base...), "-require-certificate-validation"), &strictOut, &strictErr)
if err == nil {
t.Fatalf("strict TLS open-session succeeded against a self-signed gateway; stdout = %s", strictOut.String())
}
if !strings.Contains(err.Error(), "dial failed") {
t.Fatalf("strict TLS open-session error = %v; want a dial failure (stderr = %s)", err, strictErr.String())
}
}