From d92c0f6b3653da24401b73f4e57b1bdfd0ed50e4 Mon Sep 17 00:00:00 2001 From: Todd Short Date: Wed, 15 Apr 2026 14:54:01 -0400 Subject: [PATCH] test: add TLS profile unit and e2e tests - Unit tests in tlsprofiles package verify cipher negotiation, cipher rejection, min-version enforcement, and curve acceptance/rejection by starting a local TLS server with a custom profile and connecting to it with a restricted client config. - e2e feature (tls.feature) patches the catalogd deployment with specific custom TLS settings for each scenario, asserts the expected connection behaviour, then restores the original args on cleanup. Covers min-version enforcement (TLSv1.3), cipher negotiation and rejection (TLS 1.2 + ECDHE_ECDSA), and curve enforcement (prime256v1 accepted, secp521r1 rejected). - GODOG_ARGS variable added to the e2e Makefile target so a single feature file can be run with: make test-e2e GODOG_ARGS=features/tls.feature Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Todd Short --- .../tlsprofiles_connection_test.go | 256 ++++++++++ test/e2e/features/tls.feature | 30 ++ test/e2e/steps/hooks.go | 26 + test/e2e/steps/steps.go | 12 + test/e2e/steps/tls_steps.go | 478 ++++++++++++++++++ 5 files changed, 802 insertions(+) create mode 100644 internal/shared/util/tlsprofiles/tlsprofiles_connection_test.go create mode 100644 test/e2e/features/tls.feature create mode 100644 test/e2e/steps/tls_steps.go diff --git a/internal/shared/util/tlsprofiles/tlsprofiles_connection_test.go b/internal/shared/util/tlsprofiles/tlsprofiles_connection_test.go new file mode 100644 index 000000000..771cc921c --- /dev/null +++ b/internal/shared/util/tlsprofiles/tlsprofiles_connection_test.go @@ -0,0 +1,256 @@ +package tlsprofiles + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// generateSelfSignedCert generates a self-signed ECDSA P-256 certificate for use in tests. +func generateSelfSignedCert(t *testing.T) tls.Certificate { + t.Helper() + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{Organization: []string{"Test"}}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + privDER, err := x509.MarshalECPrivateKey(priv) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER}) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + require.NoError(t, err) + return cert +} + +// startTLSServer starts a TLS listener with cfgFn applied and serves connections in +// the background. The listener is closed when the test completes. +func startTLSServer(t *testing.T, cfgFn func(*tls.Config)) string { + t.Helper() + + serverCfg := &tls.Config{ + Certificates: []tls.Certificate{generateSelfSignedCert(t)}, + MinVersion: tls.VersionTLS12, // baseline; cfgFn will raise this if the profile requires it + } + cfgFn(serverCfg) + + ln, err := tls.Listen("tcp", "127.0.0.1:0", serverCfg) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return // listener closed + } + go func() { + defer conn.Close() + _ = conn.(*tls.Conn).Handshake() + }() + } + }() + + return ln.Addr().String() +} + +// dialTLS connects to addr with the given config and returns the negotiated +// ConnectionState. The caller must check err before using the state. +func dialTLS(addr string, clientCfg *tls.Config) (tls.ConnectionState, error) { + conn, err := tls.Dial("tcp", addr, clientCfg) + if err != nil { + return tls.ConnectionState{}, err + } + defer conn.Close() + return conn.ConnectionState(), nil +} + +// setCustomProfile configures the package-level custom TLS profile for the duration +// of the test and restores the original state via t.Cleanup. +func setCustomProfile(t *testing.T, cipherNames []string, curveNames []string, minVersion string) { + t.Helper() + + origProfile := configuredProfile + origCustom := customTLSProfile + t.Cleanup(func() { + configuredProfile = origProfile + customTLSProfile = origCustom + }) + + configuredProfile = "custom" + customTLSProfile = tlsProfile{ + ciphers: cipherSlice{}, + curves: curveSlice{}, + } + + for _, name := range cipherNames { + require.NoError(t, customTLSProfile.ciphers.Append(name)) + } + for _, name := range curveNames { + require.NoError(t, customTLSProfile.curves.Append(name)) + } + if minVersion != "" { + require.NoError(t, customTLSProfile.minTLSVersion.Set(minVersion)) + } +} + +// TestCustomTLSProfileCipherNegotiation verifies that when a custom profile +// specifies a single cipher suite, that cipher is actually negotiated. +func TestCustomTLSProfileCipherNegotiation(t *testing.T) { + const cipher = "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" + cipherID := cipherSuiteId(cipher) + require.NotZero(t, cipherID) + + setCustomProfile(t, []string{cipher}, []string{"prime256v1"}, "TLSv1.2") + + cfgFn, err := GetTLSConfigFunc() + require.NoError(t, err) + + addr := startTLSServer(t, cfgFn) + + // Client is restricted to TLS 1.2 with the same single cipher. + clientCfg := &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{cipherID}, + } + + state, err := dialTLS(addr, clientCfg) + require.NoError(t, err) + require.Equal(t, cipherID, state.CipherSuite, "expected cipher %s to be negotiated", cipher) +} + +// TestCustomTLSProfileCipherRejection verifies that the server rejects a +// connection when the client offers only a cipher not in the custom profile. +func TestCustomTLSProfileCipherRejection(t *testing.T) { + // Server is configured with AES-256 only. + setCustomProfile(t, + []string{"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"}, + []string{"prime256v1"}, + "TLSv1.2", + ) + + cfgFn, err := GetTLSConfigFunc() + require.NoError(t, err) + + addr := startTLSServer(t, cfgFn) + + // Client offers only AES-128, which the server does not allow. + cipherID := cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") + require.NotZero(t, cipherID, "cipher suite must be available on this platform") + clientCfg := &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{cipherID}, + } + + _, err = dialTLS(addr, clientCfg) + require.Error(t, err, "connection should fail when client offers no cipher from the custom profile") +} + +// TestCustomTLSProfileMinVersionEnforcement verifies that a custom profile +// configured with a TLS 1.3 minimum rejects TLS 1.2-only clients. +func TestCustomTLSProfileMinVersionEnforcement(t *testing.T) { + setCustomProfile(t, + []string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}, + []string{"prime256v1"}, + "TLSv1.3", + ) + + cfgFn, err := GetTLSConfigFunc() + require.NoError(t, err) + + addr := startTLSServer(t, cfgFn) + + // Client advertises TLS 1.2 as its maximum; server requires TLS 1.3. + clientCfg := &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests + MaxVersion: tls.VersionTLS12, + } + + _, err = dialTLS(addr, clientCfg) + require.Error(t, err, "connection should fail when server requires TLS 1.3 and client only supports TLS 1.2") +} + +// TestCustomTLSProfileCurveNegotiation verifies that a connection succeeds when +// the client's curve preferences overlap with the custom profile's curve list. +func TestCustomTLSProfileCurveNegotiation(t *testing.T) { + // Server allows only prime256v1 (P-256). + setCustomProfile(t, + []string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}, + []string{"prime256v1"}, + "TLSv1.2", + ) + + cfgFn, err := GetTLSConfigFunc() + require.NoError(t, err) + + addr := startTLSServer(t, cfgFn) + + // Client also only uses prime256v1 — there is an overlap. + cipherID := cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") + require.NotZero(t, cipherID, "cipher suite must be available on this platform") + clientCfg := &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests + MaxVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.CurveP256}, + CipherSuites: []uint16{cipherID}, + } + + _, err = dialTLS(addr, clientCfg) + require.NoError(t, err) +} + +// TestCustomTLSProfileCurveRejection verifies that a connection fails when the +// client's supported curves do not overlap with the custom profile's curve list. +// TLS 1.2 is used because the curve negotiation failure is deterministic there; +// TLS 1.3 can fall back via HelloRetryRequest. +func TestCustomTLSProfileCurveRejection(t *testing.T) { + // Server allows only prime256v1 (P-256). + setCustomProfile(t, + []string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"}, + []string{"prime256v1"}, + "TLSv1.2", + ) + + cfgFn, err := GetTLSConfigFunc() + require.NoError(t, err) + + addr := startTLSServer(t, cfgFn) + + // Client only supports X25519, which is not in the server's curve list. + cipherID := cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") + require.NotZero(t, cipherID, "cipher suite must be available on this platform") + clientCfg := &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests + MaxVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.X25519}, + CipherSuites: []uint16{cipherID}, + } + + _, err = dialTLS(addr, clientCfg) + require.Error(t, err, "connection should fail when client and server share no common curve") +} diff --git a/test/e2e/features/tls.feature b/test/e2e/features/tls.feature new file mode 100644 index 000000000..77293c12f --- /dev/null +++ b/test/e2e/features/tls.feature @@ -0,0 +1,30 @@ +Feature: TLS profile enforcement on metrics endpoints + + Background: + Given OLM is available + + # Each scenario patches the deployment with the TLS settings under test and + # restores the original configuration during cleanup, so scenarios are independent. + + # All three scenarios test catalogd only: the enforcement logic lives in the shared + # tlsprofiles package, so one component is sufficient. TLS 1.2 is used for cipher + # and curve enforcement because Go's crypto/tls does not allow the server to restrict + # TLS 1.3 cipher suites — CipherSuites config only applies to TLS 1.2. The e2e cert + # uses ECDSA, so ECDHE_ECDSA cipher families are required. + @TLSProfile + Scenario: catalogd metrics endpoint enforces configured minimum TLS version + Given the "catalogd" deployment is configured with custom TLS minimum version "TLSv1.3" + Then the "catalogd" metrics endpoint accepts a TLS 1.3 connection + And the "catalogd" metrics endpoint rejects a TLS 1.2 connection + + @TLSProfile + Scenario: catalogd metrics endpoint negotiates and enforces configured cipher suite + Given the "catalogd" deployment is configured with custom TLS version "TLSv1.2", ciphers "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", and curves "prime256v1" + Then the "catalogd" metrics endpoint negotiates cipher "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" over TLS 1.2 + And the "catalogd" metrics endpoint rejects a TLS 1.2 connection offering only cipher "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" + + @TLSProfile + Scenario: catalogd metrics endpoint enforces configured curve preferences + Given the "catalogd" deployment is configured with custom TLS version "TLSv1.2", ciphers "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", and curves "prime256v1" + Then the "catalogd" metrics endpoint accepts a TLS 1.2 connection with cipher "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" and curve "prime256v1" + And the "catalogd" metrics endpoint rejects a TLS 1.2 connection with cipher "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" and only curve "secp521r1" diff --git a/test/e2e/steps/hooks.go b/test/e2e/steps/hooks.go index 5d73bcbed..556a99638 100644 --- a/test/e2e/steps/hooks.go +++ b/test/e2e/steps/hooks.go @@ -27,6 +27,14 @@ type resource struct { namespace string } +// deploymentRestore records the original container args of a deployment so that +// it can be patched back to its pre-test state during scenario cleanup. +type deploymentRestore struct { + namespace string + deploymentName string + originalArgs []string +} + type scenarioContext struct { id string namespace string @@ -38,6 +46,7 @@ type scenarioContext struct { backGroundCmds []*exec.Cmd metricsResponse map[string]string leaderPods map[string]string // component name -> leader pod name + deploymentRestores []deploymentRestore extensionObjects []client.Object } @@ -182,6 +191,23 @@ func ScenarioCleanup(ctx context.Context, _ *godog.Scenario, err error) (context _ = p.Kill() } } + + // Always restore deployments whose args were modified during the scenario, + // even when the scenario failed, so that a misconfigured TLS profile does + // not leak into subsequent scenarios. Restore in reverse order so that + // multiple patches to the same deployment unwind back to the true original. + for i := len(sc.deploymentRestores) - 1; i >= 0; i-- { + dr := sc.deploymentRestores[i] + if err2 := patchDeploymentArgs(dr.namespace, dr.deploymentName, dr.originalArgs); err2 != nil { + logger.Info("Error restoring deployment args", "name", dr.deploymentName, "error", err2) + continue + } + if _, err2 := k8sClient("rollout", "status", "-n", dr.namespace, + fmt.Sprintf("deployment/%s", dr.deploymentName), "--timeout=2m"); err2 != nil { + logger.Info("Timeout waiting for deployment rollout after restore", "name", dr.deploymentName) + } + } + if err != nil { return ctx, err } diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index c9748d99d..9a6a63fa3 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -150,6 +150,18 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)the current ClusterExtension is tracked for cleanup$`, TrackCurrentClusterExtensionForCleanup) + // TLS profile enforcement steps — deployment configuration + sc.Step(`^(?i)the "([^"]+)" deployment is configured with custom TLS minimum version "([^"]+)"$`, ConfigureDeploymentWithCustomTLSVersion) + sc.Step(`^(?i)the "([^"]+)" deployment is configured with custom TLS version "([^"]+)", ciphers "([^"]+)", and curves "([^"]+)"$`, ConfigureDeploymentWithCustomTLSFull) + + // TLS profile enforcement steps — connection assertions + sc.Step(`^(?i)the "([^"]+)" metrics endpoint accepts a TLS 1\.3 connection$`, MetricsEndpointAcceptsTLS13) + sc.Step(`^(?i)the "([^"]+)" metrics endpoint rejects a TLS 1\.2 connection$`, MetricsEndpointRejectsTLS12) + sc.Step(`^(?i)the "([^"]+)" metrics endpoint negotiates cipher "([^"]+)" over TLS 1\.2$`, MetricsEndpointNegotiatesTLS12Cipher) + sc.Step(`^(?i)the "([^"]+)" metrics endpoint rejects a TLS 1\.2 connection offering only cipher "([^"]+)"$`, MetricsEndpointRejectsTLS12ConnectionWithCipher) + sc.Step(`^(?i)the "([^"]+)" metrics endpoint accepts a TLS 1\.2 connection with cipher "([^"]+)" and curve "([^"]+)"$`, MetricsEndpointAcceptsTLS12ConnectionWithCurve) + sc.Step(`^(?i)the "([^"]+)" metrics endpoint rejects a TLS 1\.2 connection with cipher "([^"]+)" and only curve "([^"]+)"$`, MetricsEndpointRejectsTLS12ConnectionWithCurve) + // Upgrade-specific steps sc.Step(`^(?i)the latest stable OLM release is installed$`, LatestStableOLMReleaseIsInstalled) sc.Step(`^(?i)OLM is upgraded$`, OLMIsUpgraded) diff --git a/test/e2e/steps/tls_steps.go b/test/e2e/steps/tls_steps.go new file mode 100644 index 000000000..8c35c2515 --- /dev/null +++ b/test/e2e/steps/tls_steps.go @@ -0,0 +1,478 @@ +package steps + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "strings" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +// tlsCipherSuiteByName is a name→ID map that covers TLS 1.3 cipher suites +// (not returned by tls.CipherSuites) as well as all TLS 1.2 suites. +var tlsCipherSuiteByName = func() map[string]uint16 { + m := map[string]uint16{ + // TLS 1.3 suites are not included in tls.CipherSuites(); add them explicitly. + "TLS_AES_128_GCM_SHA256": tls.TLS_AES_128_GCM_SHA256, + "TLS_AES_256_GCM_SHA384": tls.TLS_AES_256_GCM_SHA384, + "TLS_CHACHA20_POLY1305_SHA256": tls.TLS_CHACHA20_POLY1305_SHA256, + } + for _, c := range tls.CipherSuites() { + m[c.Name] = c.ID + } + for _, c := range tls.InsecureCipherSuites() { + m[c.Name] = c.ID + } + return m +}() + +// tlsCipherSuiteName returns a human-readable name for a cipher suite ID, +// including TLS 1.3 suites that tls.CipherSuiteName does not recognise. +func tlsCipherSuiteName(id uint16) string { + for name, cid := range tlsCipherSuiteByName { + if cid == id { + return name + } + } + return fmt.Sprintf("0x%04X", id) +} + +// curveIDByName maps the curve names used in --tls-custom-curves flags to Go CurveID values. +var curveIDByName = map[string]tls.CurveID{ + "X25519MLKEM768": tls.X25519MLKEM768, + "X25519": tls.X25519, + "prime256v1": tls.CurveP256, + "secp384r1": tls.CurveP384, + "secp521r1": tls.CurveP521, +} + +// getMetricsServiceEndpoint returns the namespace and metrics port for the named component service. +func getMetricsServiceEndpoint(component string) (string, int32, error) { + serviceName := fmt.Sprintf("%s-service", component) + serviceNs, err := k8sClient("get", "service", "-A", "-o", + fmt.Sprintf(`jsonpath={.items[?(@.metadata.name=="%s")].metadata.namespace}`, serviceName)) + if err != nil { + return "", 0, fmt.Errorf("failed to find namespace for service %s: %w", serviceName, err) + } + serviceNs = strings.TrimSpace(serviceNs) + if serviceNs == "" { + return "", 0, fmt.Errorf("service %s not found in any namespace", serviceName) + } + + raw, err := k8sClient("get", "service", "-n", serviceNs, serviceName, "-o", "json") + if err != nil { + return "", 0, fmt.Errorf("failed to get service %s: %w", serviceName, err) + } + var svc corev1.Service + if err := json.Unmarshal([]byte(raw), &svc); err != nil { + return "", 0, fmt.Errorf("failed to unmarshal service %s: %w", serviceName, err) + } + for _, p := range svc.Spec.Ports { + if p.Name == "metrics" { + return serviceNs, p.Port, nil + } + } + return "", 0, fmt.Errorf("no port named 'metrics' found on service %s", serviceName) +} + +// withMetricsPortForward starts a kubectl port-forward to the component's metrics service, +// waits until a basic TLS connection succeeds (confirming the port-forward is ready), +// then calls fn with the local address. The port-forward is torn down when fn returns. +func withMetricsPortForward(ctx context.Context, component string, fn func(addr string) error) error { + ns, metricsPort, err := getMetricsServiceEndpoint(component) + if err != nil { + return err + } + + localPort, err := randomAvailablePort() + if err != nil { + return fmt.Errorf("failed to find a free local port: %w", err) + } + + serviceName := fmt.Sprintf("%s-service", component) + pfCmd := exec.Command(k8sCli, "port-forward", "-n", ns, //nolint:gosec + fmt.Sprintf("service/%s", serviceName), + fmt.Sprintf("%d:%d", localPort, metricsPort)) + pfCmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeconfigPath)) + if err := pfCmd.Start(); err != nil { + return fmt.Errorf("failed to start port-forward to %s: %w", serviceName, err) + } + defer func() { + if p := pfCmd.Process; p != nil { + _ = p.Kill() + _ = pfCmd.Wait() + } + }() + + addr := fmt.Sprintf("127.0.0.1:%d", localPort) + + // Wait until the port-forward is accepting connections. A plain TLS dial (no version + // restrictions) serves as the readiness probe; any successful TLS handshake confirms + // the tunnel is up. A short per-attempt timeout prevents the dial from blocking + // indefinitely if the local port is open but the upstream handshake stalls. + waitFor(ctx, func() bool { + dialer := &net.Dialer{Timeout: 3 * time.Second} + conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec + if err != nil { + return false + } + conn.Close() + return true + }) + + return fn(addr) +} + +// MetricsEndpointAcceptsTLS13 verifies that the component's metrics endpoint accepts +// connections negotiated at TLS 1.3. +func MetricsEndpointAcceptsTLS13(ctx context.Context, component string) error { + return withMetricsPortForward(ctx, component, func(addr string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert in e2e + MinVersion: tls.VersionTLS13, + }) + if err != nil { + return fmt.Errorf("%s metrics endpoint rejected a TLS 1.3 connection: %w", component, err) + } + conn.Close() + return nil + }) +} + +// MetricsEndpointRejectsTLS12 verifies that the component's metrics endpoint refuses +// connections from clients that advertise TLS 1.2 as their maximum supported version. +func MetricsEndpointRejectsTLS12(ctx context.Context, component string) error { + return withMetricsPortForward(ctx, component, func(addr string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert in e2e + MaxVersion: tls.VersionTLS12, + }) + if err == nil { + conn.Close() + return fmt.Errorf("%s metrics endpoint accepted a TLS 1.2 connection but its profile requires TLS 1.3", component) + } + return nil + }) +} + +// MetricsEndpointAcceptsConnectionUsingCurve verifies that the component's metrics +// endpoint accepts a connection from a client restricted to a single named curve. +func MetricsEndpointAcceptsConnectionUsingCurve(ctx context.Context, component, curveName string) error { + curveID, ok := curveIDByName[curveName] + if !ok { + return fmt.Errorf("unknown curve name %q", curveName) + } + return withMetricsPortForward(ctx, component, func(addr string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert in e2e + CurvePreferences: []tls.CurveID{curveID}, + }) + if err != nil { + return fmt.Errorf("%s metrics endpoint rejected a connection offering only curve %s: %w", component, curveName, err) + } + conn.Close() + return nil + }) +} + +// componentDeploymentName maps the short component name used in feature files to +// the actual Kubernetes Deployment name. +func componentDeploymentName(component string) (string, error) { + switch component { + case "operator-controller": + return "operator-controller-controller-manager", nil + case "catalogd": + return "catalogd-controller-manager", nil + default: + return "", fmt.Errorf("unknown component %q: expected operator-controller or catalogd", component) + } +} + +// getDeploymentContainerArgs returns the args list of the container named "manager" +// inside the named deployment. +func getDeploymentContainerArgs(ns, name string) ([]string, error) { + raw, err := k8sClient("get", "deployment", name, "-n", ns, "-o", "json") + if err != nil { + return nil, fmt.Errorf("getting deployment %s/%s: %w", ns, name, err) + } + var deploy appsv1.Deployment + if err := json.Unmarshal([]byte(raw), &deploy); err != nil { + return nil, fmt.Errorf("parsing deployment %s/%s: %w", ns, name, err) + } + for _, c := range deploy.Spec.Template.Spec.Containers { + if c.Name == "manager" { + return c.Args, nil + } + } + return nil, fmt.Errorf("no container named 'manager' in deployment %s/%s", ns, name) +} + +// getDeploymentContainerIndex returns the index of the container named "manager" +// inside the named deployment. +func getDeploymentContainerIndex(ns, name string) (int, error) { + raw, err := k8sClient("get", "deployment", name, "-n", ns, "-o", "json") + if err != nil { + return -1, fmt.Errorf("getting deployment %s/%s: %w", ns, name, err) + } + var deploy appsv1.Deployment + if err := json.Unmarshal([]byte(raw), &deploy); err != nil { + return -1, fmt.Errorf("parsing deployment %s/%s: %w", ns, name, err) + } + for i, c := range deploy.Spec.Template.Spec.Containers { + if c.Name == "manager" { + return i, nil + } + } + return -1, fmt.Errorf("no container named 'manager' in deployment %s/%s", ns, name) +} + +// patchDeploymentArgs replaces the args of the "manager" container in the named +// deployment using a JSON patch targeting that container's actual index. +func patchDeploymentArgs(ns, name string, args []string) error { + idx, err := getDeploymentContainerIndex(ns, name) + if err != nil { + return err + } + argsJSON, err := json.Marshal(args) + if err != nil { + return err + } + patch := fmt.Sprintf(`[{"op":"replace","path":"/spec/template/spec/containers/%d/args","value":%s}]`, idx, string(argsJSON)) + _, err = k8sClient("patch", "deployment", name, "-n", ns, "--type=json", "-p", patch) + return err +} + +// buildCustomTLSArgs strips any existing --tls-profile / --tls-custom-* flags from +// baseArgs and appends a fresh custom-profile configuration. +func buildCustomTLSArgs(baseArgs []string, version, ciphers, curves string) []string { + filtered := make([]string, 0, len(baseArgs)+4) + for _, arg := range baseArgs { + switch { + case strings.HasPrefix(arg, "--tls-profile="), + strings.HasPrefix(arg, "--tls-custom-version="), + strings.HasPrefix(arg, "--tls-custom-ciphers="), + strings.HasPrefix(arg, "--tls-custom-curves="): + // drop — will be replaced below + default: + filtered = append(filtered, arg) + } + } + filtered = append(filtered, "--tls-profile=custom", "--tls-custom-version="+version) + if ciphers != "" { + filtered = append(filtered, "--tls-custom-ciphers="+ciphers) + } + if curves != "" { + filtered = append(filtered, "--tls-custom-curves="+curves) + } + return filtered +} + +// configureDeploymentCustomTLS saves the current deployment args for cleanup, +// patches the deployment with a custom TLS profile, and waits for the rollout. +func configureDeploymentCustomTLS(ctx context.Context, component, version, ciphers, curves string) error { + deploymentName, err := componentDeploymentName(component) + if err != nil { + return err + } + + origArgs, err := getDeploymentContainerArgs(olmNamespace, deploymentName) + if err != nil { + return err + } + + sc := scenarioCtx(ctx) + sc.deploymentRestores = append(sc.deploymentRestores, deploymentRestore{ + namespace: olmNamespace, + deploymentName: deploymentName, + originalArgs: origArgs, + }) + + newArgs := buildCustomTLSArgs(origArgs, version, ciphers, curves) + if err := patchDeploymentArgs(olmNamespace, deploymentName, newArgs); err != nil { + return fmt.Errorf("patching %s with custom TLS args: %w", deploymentName, err) + } + + waitFor(ctx, func() bool { + _, err := k8sClient("rollout", "status", "-n", olmNamespace, + fmt.Sprintf("deployment/%s", deploymentName), "--timeout=10s") + return err == nil + }) + return nil +} + +// ConfigureDeploymentWithCustomTLSVersion configures the component deployment with a +// custom TLS profile that only sets the minimum TLS version (no cipher or curve override). +func ConfigureDeploymentWithCustomTLSVersion(ctx context.Context, component, version string) error { + return configureDeploymentCustomTLS(ctx, component, version, "", "") +} + +// ConfigureDeploymentWithCustomTLSFull configures the component deployment with a +// custom TLS profile specifying version, cipher suite list, and curve list. +func ConfigureDeploymentWithCustomTLSFull(ctx context.Context, component, version, ciphers, curves string) error { + return configureDeploymentCustomTLS(ctx, component, version, ciphers, curves) +} + +// MetricsEndpointNegotiatesTLS12Cipher connects to the metrics endpoint, forces TLS 1.2, +// restricts the client to a single cipher, and asserts that cipher is what was negotiated. +func MetricsEndpointNegotiatesTLS12Cipher(ctx context.Context, component, cipherName string) error { + cipherID, ok := tlsCipherSuiteByName[cipherName] + if !ok { + return fmt.Errorf("unknown cipher %q", cipherName) + } + return withMetricsPortForward(ctx, component, func(addr string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert in e2e + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{cipherID}, + }) + if err != nil { + return fmt.Errorf("%s rejected TLS 1.2 connection using cipher %s: %w", component, cipherName, err) + } + defer conn.Close() + state := conn.ConnectionState() + if state.CipherSuite != cipherID { + return fmt.Errorf("%s negotiated cipher %s instead of the expected %s", + component, tlsCipherSuiteName(state.CipherSuite), cipherName) + } + return nil + }) +} + +// MetricsEndpointRejectsTLS12ConnectionWithCipher connects with TLS 1.2 and a single +// cipher that is NOT in the server's configured cipher list, expecting a handshake failure. +func MetricsEndpointRejectsTLS12ConnectionWithCipher(ctx context.Context, component, cipherName string) error { + cipherID, ok := tlsCipherSuiteByName[cipherName] + if !ok { + return fmt.Errorf("unknown cipher %q", cipherName) + } + return withMetricsPortForward(ctx, component, func(addr string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert in e2e + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{cipherID}, + }) + if err == nil { + conn.Close() + return fmt.Errorf("%s accepted TLS 1.2 with cipher %s but should have rejected it (not in configured cipher list)", component, cipherName) + } + return nil + }) +} + +// MetricsEndpointAcceptsTLS12ConnectionWithCurve connects with TLS 1.2, a specific cipher, +// and a single curve, asserting the connection succeeds (curve is in server's preferences). +func MetricsEndpointAcceptsTLS12ConnectionWithCurve(ctx context.Context, component, cipherName, curveName string) error { + cipherID, ok := tlsCipherSuiteByName[cipherName] + if !ok { + return fmt.Errorf("unknown cipher %q", cipherName) + } + curveID, ok := curveIDByName[curveName] + if !ok { + return fmt.Errorf("unknown curve %q", curveName) + } + return withMetricsPortForward(ctx, component, func(addr string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert in e2e + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{cipherID}, + CurvePreferences: []tls.CurveID{curveID}, + }) + if err != nil { + return fmt.Errorf("%s rejected TLS 1.2 with cipher %s and curve %s: %w", component, cipherName, curveName, err) + } + conn.Close() + return nil + }) +} + +// MetricsEndpointRejectsTLS12ConnectionWithCurve connects with TLS 1.2, a specific cipher, +// and a single curve that is NOT in the server's curve preferences, expecting failure. +func MetricsEndpointRejectsTLS12ConnectionWithCurve(ctx context.Context, component, cipherName, curveName string) error { + cipherID, ok := tlsCipherSuiteByName[cipherName] + if !ok { + return fmt.Errorf("unknown cipher %q", cipherName) + } + curveID, ok := curveIDByName[curveName] + if !ok { + return fmt.Errorf("unknown curve %q", curveName) + } + return withMetricsPortForward(ctx, component, func(addr string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert in e2e + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{cipherID}, + CurvePreferences: []tls.CurveID{curveID}, + }) + if err == nil { + conn.Close() + return fmt.Errorf("%s accepted TLS 1.2 with cipher %s and curve %s but should have rejected it (curve not in configured preferences)", component, cipherName, curveName) + } + return nil + }) +} + +// MetricsEndpointNegotiatesCipherIn connects to the component's metrics endpoint, +// completes a TLS handshake, and asserts that the negotiated cipher suite is one of +// the comma-separated names in cipherList. +// +// Note: Go's crypto/tls does not allow restricting TLS 1.3 cipher suites on either +// side of a connection; the suite is chosen by the server based on AES hardware +// availability (TLS_AES_128_GCM_SHA256 preferred with AES-NI, +// TLS_CHACHA20_POLY1305_SHA256 otherwise). This step therefore validates observed +// negotiation behaviour rather than server-side enforcement. +func MetricsEndpointNegotiatesCipherIn(ctx context.Context, component, cipherList string) error { + expectedIDs := map[uint16]bool{} + for _, name := range strings.Split(cipherList, ",") { + name = strings.TrimSpace(name) + id, ok := tlsCipherSuiteByName[name] + if !ok { + return fmt.Errorf("unknown cipher name %q in expected list", name) + } + expectedIDs[id] = true + } + + return withMetricsPortForward(ctx, component, func(addr string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert in e2e + }) + if err != nil { + return fmt.Errorf("failed to connect to %s metrics endpoint: %w", component, err) + } + defer conn.Close() + + state := conn.ConnectionState() + if !expectedIDs[state.CipherSuite] { + return fmt.Errorf("%s negotiated cipher %s, which is not in the expected set [%s]", + component, tlsCipherSuiteName(state.CipherSuite), cipherList) + } + return nil + }) +} + +// MetricsEndpointRejectsConnectionUsingOnlyCurve verifies that the component's metrics +// endpoint refuses a connection from a client whose only supported curve is not in +// the server's configured curve preferences. +func MetricsEndpointRejectsConnectionUsingOnlyCurve(ctx context.Context, component, curveName string) error { + curveID, ok := curveIDByName[curveName] + if !ok { + return fmt.Errorf("unknown curve name %q", curveName) + } + return withMetricsPortForward(ctx, component, func(addr string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // self-signed cert in e2e + CurvePreferences: []tls.CurveID{curveID}, + }) + if err == nil { + conn.Close() + return fmt.Errorf("%s metrics endpoint accepted a connection offering only curve %s, but that curve is not in its configured preferences", component, curveName) + } + return nil + }) +}