From d25d8c6c261be98ab0ae4e7752b0979861529a0f Mon Sep 17 00:00:00 2001 From: akhil nittala Date: Fri, 9 Oct 2026 21:51:10 +0530 Subject: [PATCH] feat(tls): Configure TLS Curve Preferences for Redis HA Proxy Signed-off-by: akhil nittala --- argocd-operator/build/redis/haproxy.cfg.tpl | 5 + argocd-operator/common/defaults.go | 5 +- argocd-operator/controllers/argoutil/redis.go | 38 +++++++ .../controllers/argoutil/redis_test.go | 107 +++++++++++++++++- argocd-operator/pkg/tlsprofile/profile.go | 2 + build/redis/haproxy.cfg.tpl | 5 + cmd/main.go | 6 + 7 files changed, 164 insertions(+), 4 deletions(-) diff --git a/argocd-operator/build/redis/haproxy.cfg.tpl b/argocd-operator/build/redis/haproxy.cfg.tpl index 0c4e975c71b..4d78d7401d4 100644 --- a/argocd-operator/build/redis/haproxy.cfg.tpl +++ b/argocd-operator/build/redis/haproxy.cfg.tpl @@ -22,6 +22,11 @@ global ssl-default-server-ciphersuites {{.TLSCiphers}} {{- end}} {{- end}} + +{{- if .TLSCurves}} + ssl-default-bind-curves {{.TLSCurves}} + ssl-default-server-curves {{.TLSCurves}} +{{- end}} {{- end}} defaults REDIS diff --git a/argocd-operator/common/defaults.go b/argocd-operator/common/defaults.go index 711c7325af9..88e8c569c91 100644 --- a/argocd-operator/common/defaults.go +++ b/argocd-operator/common/defaults.go @@ -197,10 +197,11 @@ const ( ArgoCDDefaultRedisHAReplicas = int32(3) // ArgoCDDefaultRedisHAProxyImage is the default Redis HAProxy image to use when not specified. - ArgoCDDefaultRedisHAProxyImage = "public.ecr.aws/docker/library/haproxy" + // haproxytech builds use AWS-LC, which supports post-quantum TLS groups (e.g. X25519MLKEM768). + ArgoCDDefaultRedisHAProxyImage = "docker.io/haproxytech/haproxy-alpine" // ArgoCDDefaultRedisHAProxyVersion is the default Redis HAProxy image tag to use when not specified. - ArgoCDDefaultRedisHAProxyVersion = "sha256:e11f034e651603f10a365e5ad5a0321825e18eded9620e40c4f4d6ae58419bfe" // 3.0.8-alpine + ArgoCDDefaultRedisHAProxyVersion = "sha256:4b2da4adb652487a1aa1bb7fdacf5df1088d2c7d21302fcce738812d0d2fc453" // 3.3.6 // ArgoCDDefaultRedisImage is the Redis container image to use when not specified. ArgoCDDefaultRedisImage = "public.ecr.aws/docker/library/redis" diff --git a/argocd-operator/controllers/argoutil/redis.go b/argocd-operator/controllers/argoutil/redis.go index e1fbdfade74..724206d7b2a 100644 --- a/argocd-operator/controllers/argoutil/redis.go +++ b/argocd-operator/controllers/argoutil/redis.go @@ -186,6 +186,40 @@ func GetRedisInitScript(cr *argoproj.ArgoCD, useTLSForRedis bool) string { return script } +// haproxySupportedCurves maps OpenShift TLS group names to HAProxy/AWS-LC curve names. +// Classical groups use NIST aliases (secp256r1 -> P-256). Post-quantum hybrid groups +// are passed through for HAProxy 3.3+ with AWS-LC. +var haproxySupportedCurves = map[string]string{ + // Classical curves (OpenShift IANA name -> HAProxy NIST name) + "X25519": "X25519", + "secp256r1": "P-256", + "secp384r1": "P-384", + "secp521r1": "P-521", + "P-256": "P-256", + "P-384": "P-384", + "P-521": "P-521", + // Post-quantum hybrid groups (same name in OpenShift and HAProxy/AWS-LC) + "X25519MLKEM768": "X25519MLKEM768", + "SecP256r1MLKEM768": "SecP256r1MLKEM768", + "SecP384r1MLKEM1024": "SecP384r1MLKEM1024", +} + +// MapCurvePreferencesToHAProxyCurves converts OpenShift TLS group names to a +// colon-separated HAProxy curves list. Classical OpenShift names are converted +// to NIST aliases (e.g. secp256r1 -> P-256). Unknown groups are skipped. +func MapCurvePreferencesToHAProxyCurves(groups []string) string { + if len(groups) == 0 { + return "" + } + curves := make([]string, 0, len(groups)) + for _, group := range groups { + if curve, ok := haproxySupportedCurves[group]; ok { + curves = append(curves, curve) + } + } + return strings.Join(curves, ":") +} + // GetRedisHAProxyConfig will load the Redis HA Proxy configuration from a template on disk for the given ArgoCD. // If an error occurs, an empty string value will be returned. func GetRedisHAProxyConfig(cr *argoproj.ArgoCD, useTLSForRedis bool, centralTLSConfigProfile tlsprofile.TLSConfigProfile) string { @@ -203,6 +237,10 @@ func GetRedisHAProxyConfig(cr *argoproj.ArgoCD, useTLSForRedis bool, centralTLSC vars["TLSCiphers"] = strings.Join(centralTLSConfigProfile.Ciphers, ":") } + if curves := MapCurvePreferencesToHAProxyCurves(centralTLSConfigProfile.CurvePreferences); curves != "" { + vars["TLSCurves"] = curves + } + script, err := loadTemplateFile(path, vars) if err != nil { log.Error(err, "unable to load redis haproxy configuration") diff --git a/argocd-operator/controllers/argoutil/redis_test.go b/argocd-operator/controllers/argoutil/redis_test.go index 6d7fbe2ac33..8a75257b69b 100644 --- a/argocd-operator/controllers/argoutil/redis_test.go +++ b/argocd-operator/controllers/argoutil/redis_test.go @@ -18,8 +18,8 @@ import ( "github.com/argoproj-labs/gitops-operator/argocd-operator/pkg/tlsprofile" ) -// TestGetRedisHAProxyConfigRenderedTLSValues verifies that TLS minVersion and ciphers -// are correctly rendered in the final HAProxy configuration template output. +// TestGetRedisHAProxyConfigRenderedTLSValues verifies that TLS minVersion, ciphers, +// and curve preferences are correctly rendered in the final HAProxy configuration. func TestGetRedisHAProxyConfigRenderedTLSValues(t *testing.T) { wd, err := os.Getwd() require.NoError(t, err) @@ -50,6 +50,10 @@ func TestGetRedisHAProxyConfigRenderedTLSValues(t *testing.T) { "ssl-default-bind-ciphersuites ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "ssl-default-server-ciphersuites ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", }, + notExpectedInOutput: []string{ + "ssl-default-bind-curves", + "ssl-default-server-curves", + }, validatePattern: regexp.MustCompile(`ssl-min-ver\s+TLSv1\.2`), }, { @@ -90,6 +94,62 @@ func TestGetRedisHAProxyConfigRenderedTLSValues(t *testing.T) { "ssl-default-server-ciphers ", "ssl-default-bind-ciphersuites", "ssl-default-server-ciphersuites", + "ssl-default-bind-curves", + "ssl-default-server-curves", + }, + }, + { + name: "TLS with curve preferences maps OpenShift groups to HAProxy names", + useTLS: true, + centralTLSConfigProfile: tlsprofile.TLSConfigProfile{ + MinVersion: configv1.VersionTLS12, + CurvePreferences: []string{ + "X25519MLKEM768", + "X25519", + "secp256r1", + "secp384r1", + }, + }, + expectedInOutput: []string{ + "ssl-default-bind-options ssl-min-ver TLSv1.2", + "ssl-default-server-options ssl-min-ver TLSv1.2", + "ssl-default-bind-curves X25519MLKEM768:X25519:P-256:P-384", + "ssl-default-server-curves X25519MLKEM768:X25519:P-256:P-384", + }, + notExpectedInOutput: []string{ + "ssl-default-bind-ciphers ", + "ssl-default-server-ciphers ", + "ssl-default-bind-ciphersuites", + "ssl-default-server-ciphersuites", + "secp256r1", + "secp384r1", + }, + }, + { + name: "TLS 1.3 with ciphers and curve preferences", + useTLS: true, + centralTLSConfigProfile: tlsprofile.TLSConfigProfile{ + MinVersion: configv1.VersionTLS13, + Ciphers: []string{ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + }, + CurvePreferences: []string{ + "X25519", + "secp256r1", + }, + }, + expectedInOutput: []string{ + "ssl-default-bind-options ssl-min-ver TLSv1.3", + "ssl-default-server-options ssl-min-ver TLSv1.3", + "ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384", + "ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384", + "ssl-default-bind-curves X25519:P-256", + "ssl-default-server-curves X25519:P-256", + }, + notExpectedInOutput: []string{ + "ssl-default-bind-ciphers ", + "ssl-default-server-ciphers ", }, }, { @@ -104,6 +164,8 @@ func TestGetRedisHAProxyConfigRenderedTLSValues(t *testing.T) { "ssl-default-server-ciphers", "ssl-default-bind-ciphersuites", "ssl-default-server-ciphersuites", + "ssl-default-bind-curves", + "ssl-default-server-curves", }, }, } @@ -151,7 +213,48 @@ func TestGetRedisHAProxyConfigRenderedTLSValues(t *testing.T) { } else { assert.Empty(t, capturedVars["TLSCiphers"]) } + expectedCurves := MapCurvePreferencesToHAProxyCurves(tt.centralTLSConfigProfile.CurvePreferences) + if expectedCurves != "" { + assert.Equal(t, expectedCurves, capturedVars["TLSCurves"]) + } else { + assert.Empty(t, capturedVars["TLSCurves"]) + } } }) } } + +func TestMapCurvePreferencesToHAProxyCurves(t *testing.T) { + tests := []struct { + name string + input []string + expected string + }{ + { + name: "empty", + input: nil, + expected: "", + }, + { + name: "classical OpenShift groups", + input: []string{"X25519", "secp256r1", "secp384r1", "secp521r1"}, + expected: "X25519:P-256:P-384:P-521", + }, + { + name: "maps OpenShift groups including PQC", + input: []string{"X25519MLKEM768", "X25519", "SecP256r1MLKEM768", "secp256r1"}, + expected: "X25519MLKEM768:X25519:SecP256r1MLKEM768:P-256", + }, + { + name: "skips unknown groups", + input: []string{"UnknownGroup", "X25519"}, + expected: "X25519", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, MapCurvePreferencesToHAProxyCurves(tt.input)) + }) + } +} diff --git a/argocd-operator/pkg/tlsprofile/profile.go b/argocd-operator/pkg/tlsprofile/profile.go index 6b83c09b2fe..71d50b676a7 100644 --- a/argocd-operator/pkg/tlsprofile/profile.go +++ b/argocd-operator/pkg/tlsprofile/profile.go @@ -8,4 +8,6 @@ type TLSConfigProfile struct { MinVersion configv1.TLSProtocolVersion // Ciphers specifies the list of supported TLS cipher suites in cluster. Ciphers []string + // CurvePreferences specifies the list of supported TLS curve preferences in cluster. + CurvePreferences []string } diff --git a/build/redis/haproxy.cfg.tpl b/build/redis/haproxy.cfg.tpl index 0c4e975c71b..4d78d7401d4 100644 --- a/build/redis/haproxy.cfg.tpl +++ b/build/redis/haproxy.cfg.tpl @@ -22,6 +22,11 @@ global ssl-default-server-ciphersuites {{.TLSCiphers}} {{- end}} {{- end}} + +{{- if .TLSCurves}} + ssl-default-bind-curves {{.TLSCurves}} + ssl-default-server-curves {{.TLSCurves}} +{{- end}} {{- end}} defaults REDIS diff --git a/cmd/main.go b/cmd/main.go index a4a8a72bdce..a9763514c2e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -371,6 +371,11 @@ func main() { argocdprovisioner.Register(openshift.ReconcilerHook, openshift.BuilderHook) + curvePreferences := make([]string, len(profile.Groups)) + for i, group := range profile.Groups { + curvePreferences[i] = string(group) + } + if err = (&argocdprovisioner.ReconcileArgoCD{ Client: client, Scheme: mgr.GetScheme(), @@ -382,6 +387,7 @@ func main() { DisableClusterTLSProfile: disableClusterTLSProfile, MinVersion: profile.MinTLSVersion, Ciphers: profile.Ciphers, + CurvePreferences: curvePreferences, }, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Argo CD")