From b354742fe09fbaea7ba6a413db2f01882b6d1106 Mon Sep 17 00:00:00 2001 From: Jawed khelil Date: Mon, 16 Feb 2026 11:16:58 +0100 Subject: [PATCH] Add centrally managed TLS configuration for console-plugin nginx --- .../tektonconfig/console_plugin_reconciler.go | 126 +++++ .../console_plugin_reconciler_test.go | 445 ++++++++++++++++++ .../openshift/tektonconfig/extension.go | 14 + 3 files changed, 585 insertions(+) diff --git a/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler.go b/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler.go index 8cfe294677..79821ff736 100644 --- a/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler.go +++ b/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler.go @@ -29,6 +29,7 @@ import ( "github.com/tektoncd/operator/pkg/apis/operator/v1alpha1" "github.com/tektoncd/operator/pkg/client/clientset/versioned" "github.com/tektoncd/operator/pkg/reconciler/common" + occommon "github.com/tektoncd/operator/pkg/reconciler/openshift/common" "github.com/tektoncd/operator/pkg/reconciler/shared/hash" "go.uber.org/zap" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -64,6 +65,15 @@ type consolePluginReconciler struct { operatorVersion string pipelinesConsolePluginImage string manifest mf.Manifest + // tlsConfig holds the centrally resolved TLS profile (set on every reconcile). + // nil means central TLS is disabled; the nginx.conf is left unmodified. + tlsConfig *occommon.TLSEnvVars +} + +// SetTLSConfig stores the resolved central TLS configuration for use during the +// next reconcile cycle. Call this before reconcile() on every reconcile loop. +func (cpr *consolePluginReconciler) SetTLSConfig(tlsEnvVars *occommon.TLSEnvVars) { + cpr.tlsConfig = tlsEnvVars } // reconcile steps @@ -169,6 +179,7 @@ func (cpr *consolePluginReconciler) updateOnce(ctx context.Context) { "environmentVariable", PipelinesConsolePluginImageEnvironmentKey, ) } + }) } @@ -206,6 +217,8 @@ func (cpr *consolePluginReconciler) transform(ctx context.Context, manifest *mf. // updates "metadata.namespace" to targetNamespace common.ReplaceNamespace(tektonConfigCR.Spec.TargetNamespace), cpr.transformerConsolePlugin(tektonConfigCR.Spec.TargetNamespace), + // Add nginx TLS configuration transformer + cpr.transformerNginxTLS(), common.AddConfiguration(tektonConfigCR.Spec.Config), } @@ -234,3 +247,116 @@ func (cpr *consolePluginReconciler) transformerConsolePlugin(targetNamespace str return unstructured.SetNestedField(u.Object, targetNamespace, "spec", "backend", "service", "namespace") } } + +// transformerNginxTLS updates the nginx.conf ConfigMap with TLS directives +func (cpr *consolePluginReconciler) transformerNginxTLS() mf.Transformer { + return func(u *unstructured.Unstructured) error { + if u.GetKind() != "ConfigMap" || u.GetName() != "pipelines-console-plugin" { + return nil + } + + // Get the current nginx.conf + data, found, err := unstructured.NestedString(u.Object, "data", "nginx.conf") + if err != nil || !found { + return err + } + + // Generate the updated nginx.conf with TLS directives + updatedConf := cpr.generateNginxConfWithTLS(data) + + // Set the updated nginx.conf back + return unstructured.SetNestedField(u.Object, updatedConf, "data", "nginx.conf") + } +} + +// generateNginxConfWithTLS injects TLS directives into nginx configuration +func (cpr *consolePluginReconciler) generateNginxConfWithTLS(baseConf string) string { + // Build TLS directives + tlsDirectives := cpr.buildNginxTLSDirectives() + + // If no TLS directives to add, return original + if tlsDirectives == "" { + return baseConf + } + + // Inject TLS directives into the server block + // Find "server {" and inject after it + lines := strings.Split(baseConf, "\n") + var result strings.Builder + + for _, line := range lines { + result.WriteString(line) + result.WriteString("\n") + + // After "server {", inject TLS directives + if strings.Contains(line, "server {") { + // Add TLS directives with proper indentation + result.WriteString(tlsDirectives) + } + } + + return result.String() +} + +// buildNginxTLSDirectives generates nginx TLS directives from the centrally resolved +// TLS profile. Returns an empty string when no TLS config is available. +func (cpr *consolePluginReconciler) buildNginxTLSDirectives() string { + if cpr.tlsConfig == nil { + return "" + } + + var directives strings.Builder + + // ssl_protocols – derived from the minimum TLS version in the APIServer profile. + // TLSEnvVars.MinVersion is in Go crypto/tls format: "1.2" or "1.3". + // We always include TLSv1.3 so ML-KEM hybrid groups are available. + if cpr.tlsConfig.MinVersion != "" { + protocols := convertTLSVersionToNginx(cpr.tlsConfig.MinVersion) + directives.WriteString(fmt.Sprintf(" ssl_protocols %s;\n", protocols)) + + // Enable ML-KEM (X25519MLKEM768) hybrid key exchange for PQC readiness. + // ssl_conf_command passes OpenSSL configuration directly and is the only + // nginx mechanism that supports the post-quantum hybrid groups introduced + // in OpenSSL 3.x; ssl_ecdh_curve does not cover these groups. + // X25519MLKEM768 is tried first (PQC); X25519 is the classical fallback. + directives.WriteString(" ssl_conf_command Groups X25519MLKEM768:X25519;\n") + } + + // NOTE: IANA cipher suite names (TLS_ECDHE_RSA_…) cannot be used directly in + // nginx's ssl_ciphers directive (which uses OpenSSL names) or ssl_conf_command + // (which uses a different format). Relying on nginx's own TLS 1.3 defaults is + // simpler and equally secure; we intentionally skip cipher configuration here. + if cpr.tlsConfig.CipherSuites != "" { + cpr.logger.Debugw("TLS cipher suites provided but not applied to nginx (using nginx defaults)", + "reason", "IANA names are not directly usable in nginx ssl_ciphers", + ) + } + + // ssl_ecdh_curve – comma-separated curve names become colon-separated for nginx. + // This covers TLS 1.2 classical curves; ML-KEM hybrid groups are handled above + // via ssl_conf_command. + if cpr.tlsConfig.CurvePreferences != "" { + curves := strings.ReplaceAll(cpr.tlsConfig.CurvePreferences, ",", ":") + directives.WriteString(fmt.Sprintf(" ssl_ecdh_curve %s;\n", curves)) + } + + return directives.String() +} + +// convertTLSVersionToNginx converts the Go crypto/tls minimum version string +// ("1.2" or "1.3", as stored in TLSEnvVars.MinVersion) to the corresponding +// nginx ssl_protocols value. +func convertTLSVersionToNginx(minVersion string) string { + switch minVersion { + case "1.3": + return "TLSv1.3" + case "1.2": + return "TLSv1.2 TLSv1.3" + case "1.1": + return "TLSv1.1 TLSv1.2 TLSv1.3" + case "1.0": + return "TLSv1 TLSv1.1 TLSv1.2 TLSv1.3" + default: + return "TLSv1.2 TLSv1.3" + } +} diff --git a/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler_test.go b/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler_test.go index 0318dbc482..ae6907f437 100644 --- a/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler_test.go +++ b/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler_test.go @@ -26,6 +26,7 @@ import ( "github.com/tektoncd/operator/pkg/apis/operator/v1alpha1" "github.com/tektoncd/operator/pkg/client/clientset/versioned/fake" "github.com/tektoncd/operator/pkg/reconciler/common" + occommon "github.com/tektoncd/operator/pkg/reconciler/openshift/common" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -234,3 +235,447 @@ func TestPostReconcileManifest(t *testing.T) { }) } } + +func TestConvertTLSVersionToNginx(t *testing.T) { + tests := []struct { + name string + tlsVersion string + expectedOutput string + }{ + { + name: "1.3", + tlsVersion: "1.3", + expectedOutput: "TLSv1.3", + }, + { + name: "1.2", + tlsVersion: "1.2", + expectedOutput: "TLSv1.2 TLSv1.3", + }, + { + name: "1.1", + tlsVersion: "1.1", + expectedOutput: "TLSv1.1 TLSv1.2 TLSv1.3", + }, + { + name: "1.0", + tlsVersion: "1.0", + expectedOutput: "TLSv1 TLSv1.1 TLSv1.2 TLSv1.3", + }, + { + name: "unknown version defaults to safe", + tlsVersion: "UnknownVersion", + expectedOutput: "TLSv1.2 TLSv1.3", + }, + { + name: "empty version defaults to safe", + tlsVersion: "", + expectedOutput: "TLSv1.2 TLSv1.3", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := convertTLSVersionToNginx(test.tlsVersion) + require.Equal(t, test.expectedOutput, result) + }) + } +} + +func TestBuildNginxTLSDirectives(t *testing.T) { + ctx := context.TODO() + + tests := []struct { + name string + tlsConfig *occommon.TLSEnvVars + expectedContains []string + expectedNotContains []string + }{ + { + name: "all TLS settings provided (cipher suites skipped)", + tlsConfig: &occommon.TLSEnvVars{ + MinVersion: "1.3", + CipherSuites: "TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384", + CurvePreferences: "X25519,prime256v1", + }, + expectedContains: []string{ + "ssl_protocols TLSv1.3;", + "ssl_conf_command Groups X25519MLKEM768:X25519;", + "ssl_ecdh_curve X25519:prime256v1;", + }, + expectedNotContains: []string{ + "ssl_ciphers", + "ssl_prefer_server_ciphers", + }, + }, + { + name: "only min version provided - ML-KEM enabled", + tlsConfig: &occommon.TLSEnvVars{ + MinVersion: "1.2", + }, + expectedContains: []string{ + "ssl_protocols TLSv1.2 TLSv1.3;", + "ssl_conf_command Groups X25519MLKEM768:X25519;", + }, + expectedNotContains: []string{ + "ssl_ciphers", + "ssl_ecdh_curve", + }, + }, + { + name: "TLS 1.3 only - ML-KEM enabled", + tlsConfig: &occommon.TLSEnvVars{ + MinVersion: "1.3", + }, + expectedContains: []string{ + "ssl_protocols TLSv1.3;", + "ssl_conf_command Groups X25519MLKEM768:X25519;", + }, + expectedNotContains: []string{ + "ssl_ciphers", + "ssl_ecdh_curve", + }, + }, + { + name: "only cipher suites provided (skipped, no ssl_protocols output)", + tlsConfig: &occommon.TLSEnvVars{ + CipherSuites: "TLS_AES_128_GCM_SHA256", + }, + expectedContains: []string{}, + expectedNotContains: []string{ + "ssl_ciphers", + "ssl_prefer_server_ciphers", + "ssl_protocols", + }, + }, + { + name: "only curve preferences provided", + tlsConfig: &occommon.TLSEnvVars{ + CurvePreferences: "X25519", + }, + expectedContains: []string{ + "ssl_ecdh_curve X25519;", + }, + }, + { + name: "nil TLS config returns empty string", + tlsConfig: nil, + expectedContains: []string{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reconciler := &consolePluginReconciler{ + logger: logging.FromContext(ctx).Named("test-build-directives"), + } + reconciler.SetTLSConfig(test.tlsConfig) + + result := reconciler.buildNginxTLSDirectives() + + for _, expected := range test.expectedContains { + require.Contains(t, result, expected, "Expected directive not found") + } + for _, notExpected := range test.expectedNotContains { + require.NotContains(t, result, notExpected, "Unexpected directive found") + } + }) + } +} + +func TestGenerateNginxConfWithTLS(t *testing.T) { + ctx := context.TODO() + + baseNginxConf := `error_log /dev/stdout warn; +events {} +http { + access_log /dev/stdout; + include /etc/nginx/mime.types; + default_type application/octet-stream; + keepalive_timeout 65; + server { + listen 8443 ssl; + listen [::]:8443 ssl; + ssl_certificate /var/cert/tls.crt; + ssl_certificate_key /var/cert/tls.key; + root /usr/share/nginx/html; + } +}` + + tests := []struct { + name string + tlsConfig *occommon.TLSEnvVars + expectedContains []string + expectedNotContains []string + }{ + { + name: "with TLS configuration (cipher suites skipped, ML-KEM enabled)", + tlsConfig: &occommon.TLSEnvVars{ + MinVersion: "1.2", + CipherSuites: "TLS_AES_128_GCM_SHA256", + CurvePreferences: "X25519", + }, + expectedContains: []string{ + "server {", + "ssl_protocols TLSv1.2 TLSv1.3;", + "ssl_conf_command Groups X25519MLKEM768:X25519;", + "ssl_ecdh_curve X25519;", + "listen 8443 ssl;", + "ssl_certificate /var/cert/tls.crt;", + }, + expectedNotContains: []string{ + "ssl_ciphers", + "ssl_prefer_server_ciphers", + }, + }, + { + name: "nil TLS config returns original nginx.conf unchanged", + tlsConfig: nil, + expectedContains: []string{ + "server {", + "listen 8443 ssl;", + "ssl_certificate /var/cert/tls.crt;", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reconciler := &consolePluginReconciler{ + logger: logging.FromContext(ctx).Named("test-generate-conf"), + } + reconciler.SetTLSConfig(test.tlsConfig) + + result := reconciler.generateNginxConfWithTLS(baseNginxConf) + + // Verify TLS directives are injected after "server {" + for _, expected := range test.expectedContains { + require.Contains(t, result, expected, "Expected content not found in generated nginx.conf") + } + + // Check unexpected content + for _, notExpected := range test.expectedNotContains { + require.NotContains(t, result, notExpected, "Unexpected directive found in generated nginx.conf") + } + + // Verify TLS directives come after "server {" line + if test.tlsConfig != nil && test.tlsConfig.MinVersion != "" { + serverBlockStart := "server {" + sslProtocolsLine := "ssl_protocols" + serverIndex := len(result) + protocolsIndex := len(result) + + for i := 0; i < len(result)-len(serverBlockStart); i++ { + if result[i:i+len(serverBlockStart)] == serverBlockStart && serverIndex == len(result) { + serverIndex = i + } + } + + for i := 0; i < len(result)-len(sslProtocolsLine); i++ { + if result[i:i+len(sslProtocolsLine)] == sslProtocolsLine && protocolsIndex == len(result) { + protocolsIndex = i + } + } + + if serverIndex < len(result) && protocolsIndex < len(result) { + require.Greater(t, protocolsIndex, serverIndex, "ssl_protocols should appear after 'server {' block") + } + } + }) + } +} + +func TestTransformerNginxTLS(t *testing.T) { + ctx := context.TODO() + + tests := []struct { + name string + tlsConfig *occommon.TLSEnvVars + inputConfigMap *unstructured.Unstructured + expectedError bool + expectedContains []string + }{ + { + name: "transform nginx ConfigMap with TLS (cipher suites skipped)", + tlsConfig: &occommon.TLSEnvVars{ + MinVersion: "1.3", + CipherSuites: "TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384", + }, + inputConfigMap: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "pipelines-console-plugin", + "namespace": "openshift-pipelines", + }, + "data": map[string]interface{}{ + "nginx.conf": `server { + listen 8443 ssl; +}`, + }, + }, + }, + expectedContains: []string{ + "ssl_protocols TLSv1.3;", + }, + }, + { + name: "skip non-ConfigMap resources", + tlsConfig: &occommon.TLSEnvVars{ + MinVersion: "1.2", + }, + inputConfigMap: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test-deployment", + }, + }, + }, + expectedError: false, + }, + { + name: "skip other ConfigMaps", + tlsConfig: &occommon.TLSEnvVars{ + MinVersion: "1.2", + }, + inputConfigMap: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "other-configmap", + }, + "data": map[string]interface{}{ + "some-key": "some-value", + }, + }, + }, + expectedError: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reconciler := &consolePluginReconciler{ + logger: logging.FromContext(ctx).Named("test-transformer"), + } + reconciler.SetTLSConfig(test.tlsConfig) + + transformer := reconciler.transformerNginxTLS() + err := transformer(test.inputConfigMap) + + if test.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + // Verify transformed nginx.conf if it's the pipelines-console-plugin ConfigMap + if test.inputConfigMap.GetKind() == "ConfigMap" && test.inputConfigMap.GetName() == "pipelines-console-plugin" { + nginxConf, found, err := unstructured.NestedString(test.inputConfigMap.Object, "data", "nginx.conf") + require.NoError(t, err) + require.True(t, found) + + for _, expected := range test.expectedContains { + require.Contains(t, nginxConf, expected, "Expected TLS directive not found in transformed nginx.conf") + } + } + }) + } +} + +func TestNginxTLSIntegration(t *testing.T) { + ctx := context.TODO() + operatorFakeClientSet := fake.NewSimpleClientset() + operatorFakeClientSet.PrependReactor("create", "*", generateNameReactor) + + tests := []struct { + name string + tlsConfig *occommon.TLSEnvVars + expectedTLSInNginx []string + notExpected []string + }{ + { + name: "integration test with full TLS config (cipher suites skipped)", + tlsConfig: &occommon.TLSEnvVars{ + MinVersion: "1.2", + CipherSuites: "TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384", + CurvePreferences: "X25519,prime256v1", + }, + expectedTLSInNginx: []string{ + "ssl_protocols TLSv1.2 TLSv1.3;", + "ssl_ecdh_curve X25519:prime256v1;", + }, + }, + { + name: "integration test with nil TLS config leaves nginx.conf unchanged", + tlsConfig: nil, + notExpected: []string{ + "ssl_protocols", + "ssl_ecdh_curve", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reconciler := &consolePluginReconciler{ + logger: logging.FromContext(ctx).Named("integration-test"), + operatorClientSet: operatorFakeClientSet, + syncOnce: sync.Once{}, + resourcesYamlDirectory: "./testdata/postreconcile_manifest", + operatorVersion: "test-version", + } + reconciler.SetTLSConfig(test.tlsConfig) + + tektonConfigCR := &v1alpha1.TektonConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.ConfigResourceName, + }, + Spec: v1alpha1.TektonConfigSpec{ + CommonSpec: v1alpha1.CommonSpec{ + TargetNamespace: "openshift-pipelines", + }, + }, + } + + err := reconciler.reconcile(ctx, tektonConfigCR) + require.NoError(t, err) + + // Verify the InstallerSet was created + installerSetList, err := operatorFakeClientSet.OperatorV1alpha1().TektonInstallerSets().List( + ctx, + metav1.ListOptions{LabelSelector: fmt.Sprintf("operator.tekton.dev/created-by=%s", consolePluginReconcileLabelCreatedByValue)}, + ) + require.NoError(t, err) + require.Equal(t, 1, len(installerSetList.Items)) + + // Find the nginx ConfigMap in the manifests + installerSet := installerSetList.Items[0] + var nginxConfigMap *unstructured.Unstructured + for _, manifest := range installerSet.Spec.Manifests { + if manifest.GetKind() == "ConfigMap" && manifest.GetName() == "pipelines-console-plugin" { + nginxConfigMap = &manifest + break + } + } + + require.NotNil(t, nginxConfigMap, "nginx ConfigMap not found in InstallerSet manifests") + + // Extract nginx.conf and verify TLS directives + nginxConf, found, err := unstructured.NestedString(nginxConfigMap.Object, "data", "nginx.conf") + require.NoError(t, err) + require.True(t, found, "nginx.conf not found in ConfigMap") + + for _, expected := range test.expectedTLSInNginx { + require.Contains(t, nginxConf, expected, "Expected TLS directive not found in nginx.conf") + } + for _, notExpected := range test.notExpected { + require.NotContains(t, nginxConf, notExpected, "Unexpected TLS directive found in nginx.conf") + } + }) + } +} diff --git a/pkg/reconciler/openshift/tektonconfig/extension.go b/pkg/reconciler/openshift/tektonconfig/extension.go index 8d848f9fc7..43220acec4 100644 --- a/pkg/reconciler/openshift/tektonconfig/extension.go +++ b/pkg/reconciler/openshift/tektonconfig/extension.go @@ -201,6 +201,20 @@ func (oe openshiftExtension) PostReconcile(ctx context.Context, comp v1alpha1.Te } } + // Resolve the central TLS profile and push it into the console plugin reconciler + // so that the nginx.conf ConfigMap always reflects the current APIServer TLS settings. + if configInstance.Spec.Platforms.OpenShift.EnableCentralTLSConfig { + tlsConfig, err := occommon.ResolveCentralTLSToEnvVars(ctx, oe.tektonConfigLister) + if err != nil { + logger := logging.FromContext(ctx) + logger.Warnf("failed to resolve central TLS config for console plugin: %v", err) + } else { + oe.consolePluginReconciler.SetTLSConfig(tlsConfig) + } + } else { + oe.consolePluginReconciler.SetTLSConfig(nil) + } + // execute console plugin reconciler return oe.consolePluginReconciler.reconcile(ctx, configInstance) }