diff --git a/cmd/metrics_exporter/app/metrics_exporter.go b/cmd/metrics_exporter/app/metrics_exporter.go index 37be8eef7..de16f0272 100644 --- a/cmd/metrics_exporter/app/metrics_exporter.go +++ b/cmd/metrics_exporter/app/metrics_exporter.go @@ -124,7 +124,7 @@ func Run() { log.Infof("Starting metrics exporter. Version:%s GitSHA:%s BuiltAt:%s\n", version.Version, version.GitSHA, version.BuiltAt) // Initialize k8s API clients - kubeClient, _, chopClient, _ := chop.GetClientset(kubeConfigFile, masterURL) + kubeClient, _, chopClient, _ := chop.GetClientset(kubeConfigFile, masterURL, chopConfigFile) // Create operator instance chop.New(kubeClient, chopClient, chopConfigFile) diff --git a/cmd/operator/app/thread_chi.go b/cmd/operator/app/thread_chi.go index bee87ca13..c2ac3e326 100644 --- a/cmd/operator/app/thread_chi.go +++ b/cmd/operator/app/thread_chi.go @@ -56,7 +56,7 @@ func initClickHouse(ctx context.Context) { } // Initialize k8s API clients - kubeClient, extClient, chopClient, dynamicClient := chop.GetClientset(kubeConfigFile, masterURL) + kubeClient, extClient, chopClient, dynamicClient := chop.GetClientset(kubeConfigFile, masterURL, chopConfigFile) // Create operator instance. The chopconf load inside chop.New gates on // clickhouse.security.kubernetes.allowInsecure BEFORE the first network call, diff --git a/cmd/operator/app/thread_keeper.go b/cmd/operator/app/thread_keeper.go index 053cd6cd4..1095833b2 100644 --- a/cmd/operator/app/thread_keeper.go +++ b/cmd/operator/app/thread_keeper.go @@ -75,7 +75,7 @@ func initKeeper(ctx context.Context) error { // Build the apiextensions client for CRD deletion checks during CHK cleanup. // Uses the same kubeConfigFile/masterURL package vars as the CHI thread. - _, extClient, _, _ := chop.GetClientset(kubeConfigFile, masterURL) + _, extClient, _, _ := chop.GetClientset(kubeConfigFile, masterURL, chopConfigFile) err = ctrlRuntime. NewControllerManagedBy(manager). diff --git a/pkg/apis/clickhouse.altinity.com/v1/type_configuration_chop.go b/pkg/apis/clickhouse.altinity.com/v1/type_configuration_chop.go index f9610f1ab..4b5764bd7 100644 --- a/pkg/apis/clickhouse.altinity.com/v1/type_configuration_chop.go +++ b/pkg/apis/clickhouse.altinity.com/v1/type_configuration_chop.go @@ -1530,6 +1530,20 @@ func (c *OperatorConfig) RequiresStrictK8sTLS() bool { c.Security.GetKubernetes().GetTLS().GetVerify() == TLSVerifyStrict } +// ResolveK8sTLSMinVersion returns the K8s-API TLS floor for GetClientset. Under +// hardened posture (policy=Enforced or fips.enforced) returns TLSMinVersion13; +// otherwise security.kubernetes.tls.minVersion. Callable on raw file-based config +// before applyEnforcedHardening runs. Empty = Go stdlib default. +func (c *OperatorConfig) ResolveK8sTLSMinVersion() TLSMinVersion { + if c == nil { + return TLSMinVersion("") + } + if c.Security.RequiresHardening() { + return TLSMinVersion13 + } + return c.Security.GetKubernetes().GetTLS().GetMinVersion() +} + // coerceTypedString one-way coerces a *types.String-valued config field (TLSVerify, // TLSMinVersion, IPCMode — all type aliases of types.String) to the FIPS-strict // target value and logs the change. Caller passes the address of the struct field diff --git a/pkg/apis/clickhouse.altinity.com/v1/type_security.go b/pkg/apis/clickhouse.altinity.com/v1/type_security.go index d501eda92..45b24ad29 100644 --- a/pkg/apis/clickhouse.altinity.com/v1/type_security.go +++ b/pkg/apis/clickhouse.altinity.com/v1/type_security.go @@ -63,10 +63,8 @@ type ClusterSecurityKubernetes struct { } // ClusterSecurityKubernetesTLS holds knobs for the operator's outbound -// Kubernetes API client. The k8s client-go respects whatever's in the -// kubeconfig; the operator never builds the kubeconfig's tls.Config itself, -// so these knobs are evaluated as a LOAD-TIME GATE — the operator refuses -// to start with a kubeconfig that doesn't meet the requested posture. +// Kubernetes API client. Verify is a load-time gate against the kubeconfig's +// Insecure flag; MinVersion is applied to the client transport by GetClientset. type ClusterSecurityKubernetesTLS struct { // Verify gates startup against the kubeconfig's TLSClientConfig.Insecure // field. Valid values are TLSVerifyStrict and TLSVerifyNone. @@ -77,12 +75,8 @@ type ClusterSecurityKubernetesTLS struct { // override the kubeconfig; it only refuses to load an insecure one. Verify *types.String `json:"verify,omitempty" yaml:"verify,omitempty"` // MinVersion floors TLS at the named protocol version. Valid values are - // TLSMinVersion12 and TLSMinVersion13. Nil = Go stdlib default. - // - // Declared for shape symmetry with ClickHouse/Zookeeper and coerced under - // FIPS, but not yet enforced on the operator's K8s API transport — a future - // enhancement will wire it into rest.Config when the operator wraps the - // kubeconfig with stricter TLS settings. + // TLSMinVersion12 and TLSMinVersion13. Nil = Go stdlib default. Coerced to + // 1.3 under FIPS/Enforced; applied on the K8s API transport by GetClientset. MinVersion *types.String `json:"minVersion,omitempty" yaml:"minVersion,omitempty"` } @@ -420,8 +414,7 @@ func (t *ClusterSecurityKubernetesTLS) GetVerify() TLSVerify { } // GetMinVersion returns the resolved TLSMinVersion for the operator's K8s client. -// Nil-safe; returns empty value when unset. Declared for shape consistency and -// FIPS coercion uniformity — not yet wired into the K8s API transport. +// Nil-safe; returns empty value when unset. func (t *ClusterSecurityKubernetesTLS) GetMinVersion() TLSMinVersion { if (t == nil) || (t.MinVersion == nil) || !t.MinVersion.HasValue() { return TLSMinVersion("") diff --git a/pkg/apis/clickhouse.altinity.com/v1/type_security_fips_test.go b/pkg/apis/clickhouse.altinity.com/v1/type_security_fips_test.go index 71f013252..c294a4903 100644 --- a/pkg/apis/clickhouse.altinity.com/v1/type_security_fips_test.go +++ b/pkg/apis/clickhouse.altinity.com/v1/type_security_fips_test.go @@ -51,6 +51,36 @@ func TestSecurity_GetFIPS_IsEnforced_NilSafe(t *testing.T) { require.True(t, (&OperatorConfigSecurity{FIPS: &OperatorConfigSecurityFIPS{Enforced: types.NewStringBool(true)}}).GetFIPS().IsEnforced()) } +// TestResolveK8sTLSMinVersion verifies hardened posture forces TLS 1.3 and that +// explicit security.kubernetes.tls.minVersion is honored when not hardened. +func TestResolveK8sTLSMinVersion(t *testing.T) { + require.Equal(t, TLSMinVersion(""), (*OperatorConfig)(nil).ResolveK8sTLSMinVersion()) + require.Equal(t, TLSMinVersion(""), (&OperatorConfig{}).ResolveK8sTLSMinVersion()) + + explicit12 := &OperatorConfig{} + explicit12.Security.Kubernetes = &ClusterSecurityKubernetes{ + TLS: &ClusterSecurityKubernetesTLS{MinVersion: types.NewString(string(TLSMinVersion12))}, + } + require.Equal(t, TLSMinVersion12, explicit12.ResolveK8sTLSMinVersion()) + + explicit13 := &OperatorConfig{} + explicit13.Security.Kubernetes = &ClusterSecurityKubernetes{ + TLS: &ClusterSecurityKubernetesTLS{MinVersion: types.NewString(string(TLSMinVersion13))}, + } + require.Equal(t, TLSMinVersion13, explicit13.ResolveK8sTLSMinVersion()) + + enforcedOver12 := &OperatorConfig{} + enforcedOver12.Security.Policy = types.NewString(string(SecurityPolicyEnforced)) + enforcedOver12.Security.Kubernetes = &ClusterSecurityKubernetes{ + TLS: &ClusterSecurityKubernetesTLS{MinVersion: types.NewString(string(TLSMinVersion12))}, + } + require.Equal(t, TLSMinVersion13, enforcedOver12.ResolveK8sTLSMinVersion()) + + fipsForced := &OperatorConfig{} + fipsForced.Security.FIPS = &OperatorConfigSecurityFIPS{Enforced: types.NewStringBool(true)} + require.Equal(t, TLSMinVersion13, fipsForced.ResolveK8sTLSMinVersion()) +} + // TestSecurity_RequiresHardening_NilSafe verifies the union accessor used to // gate per-CR security checks (plain-text ZK rejection, FIPS-bypass rejection, // ZK digest-auth rejection). Fires when EITHER security.policy=Enforced OR diff --git a/pkg/chop/kube_machinery.go b/pkg/chop/kube_machinery.go index 282a27a92..7cce3cb85 100644 --- a/pkg/chop/kube_machinery.go +++ b/pkg/chop/kube_machinery.go @@ -15,7 +15,9 @@ package chop import ( + "crypto/tls" "fmt" + "net/http" "os" "os/user" "path/filepath" @@ -30,6 +32,7 @@ import ( log "github.com/altinity/clickhouse-operator/pkg/announcer" "github.com/altinity/clickhouse-operator/pkg/apis/deployment" chopclientset "github.com/altinity/clickhouse-operator/pkg/client/clientset/versioned" + "github.com/altinity/clickhouse-operator/pkg/util/tlsutil" ) // lastKubeConfigInsecure records whether the most recently loaded kubeconfig @@ -80,8 +83,11 @@ func getKubeConfig(kubeConfigFile, masterURL string) (*kuberest.Config, error) { return captureInsecure(conf, nil) } -// GetClientset gets k8s API clients - both kube native client and our custom client -func GetClientset(kubeConfigFile, masterURL string) ( +// GetClientset gets k8s API clients - both kube native client and our custom client. +// chopConfigFile supplies the file-based chopconf path used to resolve the K8s-API +// TLS minVersion floor before the first network call (same timing as the +// insecure-kubeconfig gate in ConfigManager.Init). +func GetClientset(kubeConfigFile, masterURL, chopConfigFile string) ( *kube.Clientset, *apiextensions.Clientset, *chopclientset.Clientset, @@ -93,6 +99,10 @@ func GetClientset(kubeConfigFile, masterURL string) ( os.Exit(1) } + if minVer := tlsutil.VersionUint16(string(resolveK8sTLSMinVersion(chopConfigFile))); minVer != 0 { + applyK8sClientTLSMinVersion(kubeConfig, minVer) + } + // Layer on k8s client rate limiting overrides if specified in CHOP config. if maybeQps := os.Getenv(deployment.OPERATOR_K8S_CLIENT_QPS_LIMIT); maybeQps != "" { parsedQps, err := strconv.ParseFloat(maybeQps, 32) @@ -139,3 +149,37 @@ func GetClientset(kubeConfigFile, masterURL string) ( return kubeClientset, apiextensionsClientset, chopClientset, dynamicClientset } + +// resolveK8sTLSMinVersion reads the file-based chopconf and returns the effective +// K8s-API TLS floor ("1.2"|"1.3"|""). Uses a nil-client ConfigManager because +// file loading never touches the API. Errors yield "" (no floor). +func resolveK8sTLSMinVersion(chopConfigFile string) string { + cm := newConfigManager(nil, nil, chopConfigFile) + fileConfig, err := cm.getFileBasedConfig(chopConfigFile) + if err != nil || fileConfig == nil { + return "" + } + return string(fileConfig.ResolveK8sTLSMinVersion()) +} + +// applyK8sClientTLSMinVersion stamps MinVersion onto the rest.Config transport +// via rest.Config.Wrap, preserving client-go's TLS/proxy/HTTP2 setup. Warns if +// the built RoundTripper is not *http.Transport. +func applyK8sClientTLSMinVersion(cfg *kuberest.Config, minVer uint16) { + cfg.Wrap(func(rt http.RoundTripper) http.RoundTripper { + t, ok := rt.(*http.Transport) + if !ok { + log.F().Warning( + "k8s client TLS minVersion floor requested (0x%04x) but transport is %T, not *http.Transport — floor NOT applied", + minVer, rt, + ) + return rt + } + if t.TLSClientConfig == nil { + t.TLSClientConfig = &tls.Config{} + } + t.TLSClientConfig.MinVersion = minVer + log.F().Info("k8s client TLS minVersion floor applied: 0x%04x", minVer) + return rt + }) +}