From 4dd9e412d547d610efa13c307cb4929bfce4d96e 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 | 122 +++++ .../console_plugin_reconciler_test.go | 460 ++++++++++++++++++ .../openshift/tektonconfig/extension.go | 17 + 3 files changed, 599 insertions(+) diff --git a/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler.go b/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler.go index 72949e93e4..50ef7d6698 100644 --- a/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler.go +++ b/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler.go @@ -65,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 @@ -184,6 +193,7 @@ func (cpr *consolePluginReconciler) updateOnce(ctx context.Context) { "environmentVariable", envKey, ) } + }) } @@ -221,6 +231,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), } @@ -249,3 +261,113 @@ 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. +// Directives are always produced (ssl_protocols + ML-KEM ssl_conf_command) so +// this function never returns the unmodified base configuration. +func (cpr *consolePluginReconciler) generateNginxConfWithTLS(baseConf string) string { + tlsDirectives := cpr.buildNginxTLSDirectives() + + // 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. When no explicit profile is configured (cluster uses the "Default" +// profile), secure Intermediate-equivalent defaults are applied so that PQC +// directives are always present regardless of cluster configuration. +func (cpr *consolePluginReconciler) buildNginxTLSDirectives() string { + var directives strings.Builder + + // ssl_protocols – derived from the minimum TLS version in the APIServer profile. + // Fall back to "1.2" (Intermediate) when no central TLS config is present, which + // is the OpenShift default for clusters without an explicit tlsSecurityProfile. + minVersion := "1.2" + if cpr.tlsConfig != nil && cpr.tlsConfig.MinVersion != "" { + minVersion = cpr.tlsConfig.MinVersion + } + protocols := convertTLSVersionToNginx(minVersion) + directives.WriteString(fmt.Sprintf(" ssl_protocols %s;\n", protocols)) + + // Always 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 for + // clients that do not yet support ML-KEM. + 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 != nil && 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 Groups. + if cpr.tlsConfig != nil && 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 c1e7a7ca64..7de1be16bf 100644 --- a/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler_test.go +++ b/pkg/reconciler/openshift/tektonconfig/console_plugin_reconciler_test.go @@ -280,3 +280,463 @@ 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", + }, + }, + { + // MinVersion is empty but we have cipher suites — the default "1.2" + // fallback still produces ssl_protocols and the ML-KEM directive. + // ssl_ciphers is never set (IANA names not usable in nginx). + name: "only cipher suites provided — default ssl_protocols and ML-KEM still emitted", + tlsConfig: &occommon.TLSEnvVars{ + CipherSuites: "TLS_AES_128_GCM_SHA256", + }, + expectedContains: []string{ + "ssl_protocols TLSv1.2 TLSv1.3;", + "ssl_conf_command Groups X25519MLKEM768:X25519;", + }, + expectedNotContains: []string{ + "ssl_ciphers", + "ssl_prefer_server_ciphers", + }, + }, + { + name: "only curve preferences provided", + tlsConfig: &occommon.TLSEnvVars{ + CurvePreferences: "X25519", + }, + expectedContains: []string{ + "ssl_ecdh_curve X25519;", + }, + }, + { + // With nil tlsConfig (cluster "Default" profile), secure defaults apply: + // ssl_protocols TLSv1.2 TLSv1.3 (Intermediate-equivalent) + ML-KEM. + name: "nil TLS config uses Intermediate defaults and always enables ML-KEM", + tlsConfig: nil, + expectedContains: []string{ + "ssl_protocols TLSv1.2 TLSv1.3;", + "ssl_conf_command Groups X25519MLKEM768:X25519;", + }, + }, + } + + 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", + }, + }, + { + // With nil tlsConfig (cluster "Default" profile), defaults are injected so + // ML-KEM is always enabled even without an explicit TLS profile. + name: "nil TLS config injects Intermediate defaults and ML-KEM into nginx.conf", + tlsConfig: nil, + expectedContains: []string{ + "server {", + "ssl_protocols TLSv1.2 TLSv1.3;", + "ssl_conf_command Groups X25519MLKEM768:X25519;", + "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;", + }, + }, + { + // nil tlsConfig (cluster "Default" profile) — Intermediate defaults and + // ML-KEM are always injected so PQC is available on fresh installs. + name: "integration test with nil TLS config injects Intermediate defaults and ML-KEM", + tlsConfig: nil, + expectedTLSInNginx: []string{ + "ssl_protocols TLSv1.2 TLSv1.3;", + "ssl_conf_command Groups X25519MLKEM768:X25519;", + }, + }, + } + + 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 91caf1de61..4bdd5aeaf7 100644 --- a/pkg/reconciler/openshift/tektonconfig/extension.go +++ b/pkg/reconciler/openshift/tektonconfig/extension.go @@ -171,6 +171,22 @@ func (oe openshiftExtension) PreReconcile(ctx context.Context, tc v1alpha1.Tekto } // -------------------- + // Resolve the central TLS profile once per reconcile cycle and cache it in the + // console plugin reconciler. PostReconcile consumes the cached value without + // re-reading the APIServer. The APIServer watch in controller.go ensures that + // a TLS profile change triggers a new reconcile, so the cache is always fresh. + if config.Spec.Platforms.OpenShift.EnableCentralTLSConfig != nil && + *config.Spec.Platforms.OpenShift.EnableCentralTLSConfig { + tlsConfig, err := occommon.ResolveCentralTLSToEnvVars(ctx, oe.tektonConfigLister) + if err != nil { + logging.FromContext(ctx).Warnf("failed to resolve central TLS config for console plugin: %v", err) + } else { + oe.consolePluginReconciler.SetTLSConfig(tlsConfig) + } + } else { + oe.consolePluginReconciler.SetTLSConfig(nil) + } + return r.createResources(ctx) } @@ -202,6 +218,7 @@ func (oe openshiftExtension) PostReconcile(ctx context.Context, comp v1alpha1.Te } // execute console plugin reconciler + // TLS config was already resolved and cached in PreReconcile via SetTLSConfig. return oe.consolePluginReconciler.reconcile(ctx, configInstance) }