Skip to content

Commit 6fc19e0

Browse files
authored
Add APIServer TLS controller for OpenShift cluster-wide TLS configuration (#3739)
Implements a controller that watches the OpenShift APIServer resource and provides thread-safe access to TLS configuration for HTTPS servers/clients. The controller is modeled after the existing proxy controller. This is intended to be used by the OLM operator, Catalog operator and marketplace operator (but it will need to be copied). It will also be used by the downstream PSM component. Assisted-By: Claude Signed-off-by: Todd Short <todd.short@me.com>
1 parent 01449fd commit 6fc19e0

17 files changed

Lines changed: 2350 additions & 29 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/onsi/gomega v1.39.0
2121
github.com/openshift/api v0.0.0-20251111193948-50e2ece149d7
2222
github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235
23+
github.com/openshift/library-go v0.0.0-20260108135436-db8dbd64c462
2324
github.com/operator-framework/api v0.37.0
2425
github.com/operator-framework/operator-registry v1.61.0
2526
github.com/otiai10/copy v1.14.1

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
223223
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
224224
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
225225
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
226+
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
226227
github.com/hashicorp/golang-lru/arc/v2 v2.0.7 h1:QxkVTxwColcduO+LP7eJO56r2hFiG8zEbfAAzRv52KQ=
227228
github.com/hashicorp/golang-lru/arc/v2 v2.0.7/go.mod h1:Pe7gBlGdc8clY5LJ0LpJXMt5AmgmWNH1g+oFFVUHOEc=
228229
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -322,6 +323,8 @@ github.com/openshift/api v0.0.0-20251111193948-50e2ece149d7 h1:MemawsK6SpxEaE5y0
322323
github.com/openshift/api v0.0.0-20251111193948-50e2ece149d7/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY=
323324
github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235 h1:9JBeIXmnHlpXTQPi7LPmu1jdxznBhAE7bb1K+3D8gxY=
324325
github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235/go.mod h1:L49W6pfrZkfOE5iC1PqEkuLkXG4W0BX4w8b+L2Bv7fM=
326+
github.com/openshift/library-go v0.0.0-20260108135436-db8dbd64c462 h1:zX9Od4Jg8sVmwQLwk6Vd+BX7tcyC/462FVvDdzHEPPk=
327+
github.com/openshift/library-go v0.0.0-20260108135436-db8dbd64c462/go.mod h1:nIzWQQE49XbiKizVnVOip9CEB7HJ0hoJwNi3g3YKnKc=
325328
github.com/operator-framework/api v0.37.0 h1:2XCMWitBnumtJTqzip6LQKUwpM2pXVlt3gkpdlkbaCE=
326329
github.com/operator-framework/api v0.37.0/go.mod h1:NZs4vB+Jiamyv3pdPDjZtuC4U7KX0eq4z2r5hKY5fUA=
327330
github.com/operator-framework/operator-registry v1.61.0 h1:LgX6lP5hUHfpMTMygsnySc7PKxibzqIoqWUm6NPWl2M=

pkg/controller/operators/olm/operator.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import (
5757
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/event"
5858
index "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/index"
5959
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/labeler"
60+
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/openshiftconfig"
6061
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorclient"
6162
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorlister"
6263
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/ownerutil"
@@ -852,19 +853,20 @@ func newOperatorWithConfig(ctx context.Context, config *operatorConfig) (*Operat
852853
return nil, err
853854
}
854855

855-
// setup proxy env var injection policies
856+
// Check if OpenShift config API is available (used by proxy and apiserver controllers)
856857
discovery := config.operatorClient.KubernetesInterface().Discovery()
857-
proxyAPIExists, err := proxy.IsAPIAvailable(discovery)
858+
openshiftConfigAPIExists, err := openshiftconfig.IsAPIAvailable(discovery)
858859
if err != nil {
859-
op.logger.Errorf("error happened while probing for Proxy API support - %v", err)
860+
op.logger.Errorf("error happened while probing for OpenShift config API support - %v", err)
860861
return nil, err
861862
}
862863

864+
// setup proxy env var injection policies
863865
proxyQuerierInUse := proxy.NoopQuerier()
864-
if proxyAPIExists {
866+
if openshiftConfigAPIExists {
865867
op.logger.Info("OpenShift Proxy API available - setting up watch for Proxy type")
866868

867-
proxyInformer, proxySyncer, proxyQuerier, err := proxy.NewSyncer(op.logger, config.configClient, discovery)
869+
proxyInformer, proxySyncer, proxyQuerier, err := proxy.NewSyncer(op.logger, config.configClient)
868870
if err != nil {
869871
err = fmt.Errorf("failed to initialize syncer for Proxy type - %v", err)
870872
return nil, err

pkg/lib/apiserver/querier.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package apiserver
2+
3+
import (
4+
"crypto/tls"
5+
"fmt"
6+
)
7+
8+
// NoopQuerier returns an instance of noopQuerier. It's used for upstream where
9+
// we don't have any apiserver.config.openshift.io/cluster resource.
10+
func NoopQuerier() Querier {
11+
return &noopQuerier{}
12+
}
13+
14+
// Querier is an interface that wraps the QueryTLSConfig method.
15+
//
16+
// QueryTLSConfig updates the provided TLS configuration with cluster-wide
17+
// TLS security profile settings (MinVersion, CipherSuites, PreferServerCipherSuites).
18+
type Querier interface {
19+
QueryTLSConfig(config *tls.Config) error
20+
}
21+
22+
type noopQuerier struct {
23+
}
24+
25+
// QueryTLSConfig applies secure default TLS settings to the provided config.
26+
// This is used on non-OpenShift clusters where there is no apiserver.config.openshift.io/cluster resource,
27+
// but we still want to ensure secure TLS configuration.
28+
func (*noopQuerier) QueryTLSConfig(config *tls.Config) error {
29+
if config == nil {
30+
return fmt.Errorf("tls.Config cannot be nil")
31+
}
32+
33+
// Apply secure defaults for non-OpenShift clusters
34+
return ApplySecureDefaults(config)
35+
}

pkg/lib/apiserver/querier_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package apiserver_test
2+
3+
import (
4+
"crypto/tls"
5+
"testing"
6+
7+
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/apiserver"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestNoopQuerier_QueryTLSConfig(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
config *tls.Config
16+
expectError bool
17+
errorMsg string
18+
}{
19+
{
20+
name: "WithNilConfig",
21+
config: nil,
22+
expectError: true,
23+
errorMsg: "tls.Config cannot be nil",
24+
},
25+
{
26+
name: "WithEmptyConfig",
27+
config: &tls.Config{},
28+
expectError: false,
29+
},
30+
{
31+
name: "WithPartialConfig",
32+
config: &tls.Config{
33+
MinVersion: tls.VersionTLS12,
34+
},
35+
expectError: false,
36+
},
37+
}
38+
39+
for _, tt := range tests {
40+
t.Run(tt.name, func(t *testing.T) {
41+
querier := apiserver.NoopQuerier()
42+
err := querier.QueryTLSConfig(tt.config)
43+
44+
if tt.expectError {
45+
require.Error(t, err)
46+
assert.Contains(t, err.Error(), tt.errorMsg)
47+
} else {
48+
require.NoError(t, err)
49+
// Verify secure defaults are applied
50+
assert.NotZero(t, tt.config.MinVersion, "MinVersion should be set to a default")
51+
assert.NotEmpty(t, tt.config.CipherSuites, "CipherSuites should be set to defaults")
52+
assert.True(t, tt.config.PreferServerCipherSuites, "PreferServerCipherSuites should be true")
53+
}
54+
})
55+
}
56+
}
57+
58+
func TestNoopQuerier_AppliesSecureDefaults(t *testing.T) {
59+
querier := apiserver.NoopQuerier()
60+
config := &tls.Config{}
61+
62+
err := querier.QueryTLSConfig(config)
63+
require.NoError(t, err)
64+
65+
// Verify secure defaults
66+
assert.GreaterOrEqual(t, config.MinVersion, uint16(tls.VersionTLS12), "Should use at least TLS 1.2")
67+
assert.NotEmpty(t, config.CipherSuites, "Should have cipher suites configured")
68+
69+
// Verify cipher suites are valid
70+
for _, cipher := range config.CipherSuites {
71+
assert.NotZero(t, cipher, "Cipher suite should not be zero")
72+
}
73+
}
74+
75+
func TestNoopQuerier_DoesNotOverwriteNonZeroMinVersion(t *testing.T) {
76+
querier := apiserver.NoopQuerier()
77+
config := &tls.Config{
78+
MinVersion: tls.VersionTLS13,
79+
}
80+
81+
err := querier.QueryTLSConfig(config)
82+
require.NoError(t, err)
83+
84+
// MinVersion should be preserved if already set
85+
assert.Equal(t, uint16(tls.VersionTLS13), config.MinVersion, "Should preserve existing MinVersion")
86+
}

pkg/lib/apiserver/syncer.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package apiserver
2+
3+
import (
4+
"crypto/tls"
5+
"fmt"
6+
"sync"
7+
"time"
8+
9+
"github.com/openshift/client-go/config/informers/externalversions"
10+
11+
apiconfigv1 "github.com/openshift/api/config/v1"
12+
configv1client "github.com/openshift/client-go/config/clientset/versioned"
13+
configv1 "github.com/openshift/client-go/config/informers/externalversions/config/v1"
14+
"github.com/sirupsen/logrus"
15+
"k8s.io/client-go/tools/cache"
16+
)
17+
18+
const (
19+
// This is the cluster level global apiserver.config.openshift.io/cluster object name.
20+
globalAPIServerName = "cluster"
21+
22+
// default sync interval
23+
defaultSyncInterval = 30 * time.Minute
24+
)
25+
26+
// NewSyncer returns informer and sync functions to enable watch of the apiserver.config.openshift.io/cluster resource.
27+
func NewSyncer(logger *logrus.Logger, client configv1client.Interface) (apiServerInformer configv1.APIServerInformer, syncer *Syncer, querier Querier, factory externalversions.SharedInformerFactory, err error) {
28+
factory = externalversions.NewSharedInformerFactoryWithOptions(client, defaultSyncInterval)
29+
apiServerInformer = factory.Config().V1().APIServers()
30+
s := &Syncer{
31+
logger: logger,
32+
currentConfig: newTLSConfigHolder(),
33+
}
34+
35+
syncer = s
36+
querier = s
37+
return
38+
}
39+
40+
// RegisterEventHandlers registers event handlers for apiserver.config.openshift.io/cluster resource changes.
41+
// This is a convenience function to set up Add/Update/Delete handlers that call
42+
// the syncer's SyncAPIServer and HandleAPIServerDelete methods.
43+
func RegisterEventHandlers(informer configv1.APIServerInformer, syncer *Syncer) {
44+
informer.Informer().AddEventHandler(&cache.ResourceEventHandlerFuncs{
45+
AddFunc: func(obj interface{}) {
46+
if err := syncer.SyncAPIServer(obj); err != nil {
47+
syncer.logger.WithError(err).Error("error syncing APIServer on add")
48+
}
49+
},
50+
UpdateFunc: func(_, newObj interface{}) {
51+
if err := syncer.SyncAPIServer(newObj); err != nil {
52+
syncer.logger.WithError(err).Error("error syncing APIServer on update")
53+
}
54+
},
55+
DeleteFunc: func(obj interface{}) {
56+
syncer.HandleAPIServerDelete(obj)
57+
},
58+
})
59+
}
60+
61+
// Syncer deals with watching APIServer type(s) on the cluster and let the caller
62+
// query for cluster scoped APIServer TLS configuration.
63+
type Syncer struct {
64+
logger *logrus.Logger
65+
currentConfig *tlsConfigHolder
66+
}
67+
68+
// tlsConfigHolder holds TLS configuration in a thread-safe manner.
69+
// It always contains a valid configuration with secure defaults.
70+
type tlsConfigHolder struct {
71+
mu sync.RWMutex
72+
config tls.Config
73+
}
74+
75+
// newTLSConfigHolder creates a new holder initialized with secure defaults.
76+
func newTLSConfigHolder() *tlsConfigHolder {
77+
h := &tlsConfigHolder{}
78+
// Initialize with secure defaults
79+
_ = ApplySecureDefaults(&h.config)
80+
return h
81+
}
82+
83+
// update atomically updates the stored TLS configuration.
84+
func (h *tlsConfigHolder) update(minVersion uint16, cipherSuites []uint16) {
85+
h.mu.Lock()
86+
defer h.mu.Unlock()
87+
88+
h.config.MinVersion = minVersion
89+
// Make a defensive copy of the slice
90+
h.config.CipherSuites = make([]uint16, len(cipherSuites))
91+
copy(h.config.CipherSuites, cipherSuites)
92+
h.config.PreferServerCipherSuites = true
93+
}
94+
95+
// copyTo atomically copies the cached TLS settings to the provided config.
96+
// All reading and copying happens under the read lock, ensuring thread safety.
97+
func (h *tlsConfigHolder) copyTo(config *tls.Config) {
98+
h.mu.RLock()
99+
defer h.mu.RUnlock()
100+
101+
// Copy all fields while holding the lock
102+
config.MinVersion = h.config.MinVersion
103+
config.CipherSuites = make([]uint16, len(h.config.CipherSuites))
104+
copy(config.CipherSuites, h.config.CipherSuites)
105+
config.PreferServerCipherSuites = h.config.PreferServerCipherSuites
106+
}
107+
108+
// QueryTLSConfig queries the global cluster level APIServer object and updates
109+
// the provided TLS configuration with the cluster-wide security profile settings.
110+
func (w *Syncer) QueryTLSConfig(config *tls.Config) error {
111+
if config == nil {
112+
return fmt.Errorf("tls.Config cannot be nil")
113+
}
114+
115+
// Copy the current cached config atomically
116+
// This always succeeds because currentConfig always has a valid value
117+
w.currentConfig.copyTo(config)
118+
return nil
119+
}
120+
121+
// SyncAPIServer is invoked when a cluster scoped APIServer object is added or modified.
122+
func (w *Syncer) SyncAPIServer(object interface{}) error {
123+
apiserver, ok := object.(*apiconfigv1.APIServer)
124+
if !ok {
125+
w.logger.Error("wrong type in APIServer syncer")
126+
return nil
127+
}
128+
129+
// Convert the TLS security profile to get new settings
130+
minVersion, cipherSuites := GetSecurityProfileConfig(apiserver.Spec.TLSSecurityProfile)
131+
132+
// Check if configuration changed (before updating)
133+
changed := w.hasConfigChanged(minVersion, cipherSuites)
134+
135+
// Update the stored configuration atomically
136+
w.currentConfig.update(minVersion, cipherSuites)
137+
138+
// Log if configuration changed
139+
if changed {
140+
profileName := getProfileName(apiserver.Spec.TLSSecurityProfile)
141+
w.logger.Infof("APIServer TLS configuration changed: profile=%s, minVersion=%s, cipherCount=%d",
142+
profileName,
143+
tlsVersionToString(minVersion),
144+
len(cipherSuites))
145+
}
146+
147+
return nil
148+
}
149+
150+
// HandleAPIServerDelete is invoked when a cluster scoped APIServer object is deleted.
151+
func (w *Syncer) HandleAPIServerDelete(object interface{}) {
152+
_, ok := object.(*apiconfigv1.APIServer)
153+
if !ok {
154+
w.logger.Error("wrong type in APIServer delete syncer")
155+
return
156+
}
157+
158+
// Reset to secure defaults (Intermediate profile)
159+
w.currentConfig.update(GetSecurityProfileConfig(nil))
160+
161+
w.logger.Info("APIServer TLS configuration deleted, reverted to secure defaults")
162+
return
163+
}
164+
165+
// hasConfigChanged checks if the new TLS settings differ from the current cached settings.
166+
func (w *Syncer) hasConfigChanged(minVersion uint16, cipherSuites []uint16) bool {
167+
w.currentConfig.mu.RLock()
168+
defer w.currentConfig.mu.RUnlock()
169+
170+
if w.currentConfig.config.MinVersion != minVersion {
171+
return true
172+
}
173+
if len(w.currentConfig.config.CipherSuites) != len(cipherSuites) {
174+
return true
175+
}
176+
for i := range cipherSuites {
177+
if w.currentConfig.config.CipherSuites[i] != cipherSuites[i] {
178+
return true
179+
}
180+
}
181+
return false
182+
}
183+
184+
// getProfileName returns the TLS security profile name for logging.
185+
func getProfileName(profile *apiconfigv1.TLSSecurityProfile) string {
186+
if profile == nil {
187+
return "Intermediate (default)"
188+
}
189+
190+
profileType := string(profile.Type)
191+
if profileType == "" {
192+
return "Intermediate (default)"
193+
}
194+
195+
return profileType
196+
}
197+
198+
// tlsVersionToString converts a TLS version number to a string
199+
func tlsVersionToString(version uint16) string {
200+
switch version {
201+
case tls.VersionTLS10:
202+
return "TLS 1.0"
203+
case tls.VersionTLS11:
204+
return "TLS 1.1"
205+
case tls.VersionTLS12:
206+
return "TLS 1.2"
207+
case tls.VersionTLS13:
208+
return "TLS 1.3"
209+
default:
210+
return "unknown"
211+
}
212+
}

0 commit comments

Comments
 (0)