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:
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user