Skip to content

Commit e3e6b03

Browse files
authored
🌱 Add support for CA/certificate rotation (#1062)
* Add support for CA/certificate rotation Mounted secrets are automatically updated into pods, but... * It doesn't work with `subPath` mountings * When `subPath` is not used, then a bunch of directories are mounted * And one of those directories is a symlink, so `IsDir()` returns false * And a watch is needed to notice the change So, update the certificate volume patch, which requires a change in how we look for certificates in the CA cert directory. Add a watch, so when the certs do change, we update the cert pool. Also look at validity dates of certificates, and error on expired certs. The default cert-manager certificates have 90 days validities. Signed-off-by: Todd Short <[email protected]> * fixup! Add support for CA/certificate rotation * fixup! Add support for CA/certificate rotation Signed-off-by: Todd Short <[email protected]> * fixup! Add support for CA/certificate rotation Signed-off-by: Todd Short <[email protected]> --------- Signed-off-by: Todd Short <[email protected]>
1 parent 58c5776 commit e3e6b03

File tree

13 files changed

+314
-34
lines changed

13 files changed

+314
-34
lines changed

cmd/manager/main.go

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"flag"
2222
"fmt"
23+
"net/http"
2324
"os"
2425
"path/filepath"
2526
"time"
@@ -206,16 +207,16 @@ func main() {
206207
os.Exit(1)
207208
}
208209

209-
certPool, err := httputil.NewCertPool(caCertDir)
210+
certPoolWatcher, err := httputil.NewCertPoolWatcher(caCertDir, ctrl.Log.WithName("cert-pool"))
210211
if err != nil {
211212
setupLog.Error(err, "unable to create CA certificate pool")
212213
os.Exit(1)
213214
}
214215
unpacker := &source.ImageRegistry{
215216
BaseCachePath: filepath.Join(cachePath, "unpack"),
216217
// TODO: This needs to be derived per extension via ext.Spec.InstallNamespace
217-
AuthNamespace: systemNamespace,
218-
CaCertPool: certPool,
218+
AuthNamespace: systemNamespace,
219+
CertPoolWatcher: certPoolWatcher,
219220
}
220221

221222
clusterExtensionFinalizers := crfinalizer.NewFinalizers()
@@ -240,18 +241,15 @@ func main() {
240241
}
241242

242243
cl := mgr.GetClient()
243-
httpClient, err := httputil.BuildHTTPClient(certPool)
244-
if err != nil {
245-
setupLog.Error(err, "unable to create catalogd http client")
246-
os.Exit(1)
247-
}
248244

249245
catalogsCachePath := filepath.Join(cachePath, "catalogs")
250246
if err := os.MkdirAll(catalogsCachePath, 0700); err != nil {
251247
setupLog.Error(err, "unable to create catalogs cache directory")
252248
os.Exit(1)
253249
}
254-
catalogClient := catalogclient.New(cache.NewFilesystemCache(catalogsCachePath, httpClient))
250+
catalogClient := catalogclient.New(cache.NewFilesystemCache(catalogsCachePath, func() (*http.Client, error) {
251+
return httputil.BuildHTTPClient(certPoolWatcher)
252+
}))
255253

256254
resolver := &resolve.CatalogResolver{
257255
WalkCatalogsFunc: resolve.CatalogWalker(
@@ -273,7 +271,6 @@ func main() {
273271
Unpacker: unpacker,
274272
InstalledBundleGetter: &controllers.DefaultInstalledBundleGetter{ActionClientGetter: acg},
275273
Finalizers: clusterExtensionFinalizers,
276-
CaCertPool: certPool,
277274
Preflights: preflights,
278275
}).SetupWithManager(mgr); err != nil {
279276
setupLog.Error(err, "unable to create controller", "controller", "ClusterExtension")

config/components/tls/patches/manager_deployment_cert.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
value: {"name":"olmv1-certificate", "secret":{"secretName":"olmv1-cert", "optional": false, "items": [{"key": "ca.crt", "path": "olm-ca.crt"}]}}
44
- op: add
55
path: /spec/template/spec/containers/0/volumeMounts/-
6-
value: {"name":"olmv1-certificate", "readOnly": true, "mountPath":"/var/certs/olm-ca.crt", "subPath":"olm-ca.crt"}
6+
value: {"name":"olmv1-certificate", "readOnly": true, "mountPath":"/var/certs/"}
77
- op: add
88
path: /spec/template/spec/containers/0/args/-
99
value: "--ca-certs-dir=/var/certs"

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/Masterminds/semver/v3 v3.2.1
88
github.com/blang/semver/v4 v4.0.0
99
github.com/containerd/containerd v1.7.20
10+
github.com/fsnotify/fsnotify v1.7.0
1011
github.com/go-logr/logr v1.4.2
1112
github.com/google/go-cmp v0.6.0
1213
github.com/google/go-containerregistry v0.20.1
@@ -112,7 +113,6 @@ require (
112113
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
113114
github.com/fatih/color v1.15.0 // indirect
114115
github.com/felixge/httpsnoop v1.0.4 // indirect
115-
github.com/fsnotify/fsnotify v1.7.0 // indirect
116116
github.com/go-errors/errors v1.4.2 // indirect
117117
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
118118
github.com/go-git/go-billy/v5 v5.5.0 // indirect

internal/catalogmetadata/cache/cache.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ var _ client.Fetcher = &filesystemCache{}
2525
// - IF cached it will verify the cache is up to date. If it is up to date it will return
2626
// the cached contents, if not it will fetch the new contents from the catalogd HTTP
2727
// server and update the cached contents.
28-
func NewFilesystemCache(cachePath string, client *http.Client) client.Fetcher {
28+
func NewFilesystemCache(cachePath string, clientFunc func() (*http.Client, error)) client.Fetcher {
2929
return &filesystemCache{
3030
cachePath: cachePath,
3131
mutex: sync.RWMutex{},
32-
client: client,
32+
getClient: clientFunc,
3333
cacheDataByCatalogName: map[string]cacheData{},
3434
}
3535
}
@@ -50,7 +50,7 @@ type cacheData struct {
5050
type filesystemCache struct {
5151
mutex sync.RWMutex
5252
cachePath string
53-
client *http.Client
53+
getClient func() (*http.Client, error)
5454
cacheDataByCatalogName map[string]cacheData
5555
}
5656

@@ -95,7 +95,11 @@ func (fsc *filesystemCache) FetchCatalogContents(ctx context.Context, catalog *c
9595
return nil, fmt.Errorf("error forming request: %v", err)
9696
}
9797

98-
resp, err := fsc.client.Do(req)
98+
client, err := fsc.getClient()
99+
if err != nil {
100+
return nil, fmt.Errorf("error getting HTTP client: %w", err)
101+
}
102+
resp, err := client.Do(req)
99103
if err != nil {
100104
return nil, fmt.Errorf("error performing request: %v", err)
101105
}

internal/catalogmetadata/cache/cache_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ func TestFilesystemCache(t *testing.T) {
214214
maps.Copy(tt.tripper.content, tt.contents)
215215
httpClient := http.DefaultClient
216216
httpClient.Transport = tt.tripper
217-
c := cache.NewFilesystemCache(cacheDir, httpClient)
217+
c := cache.NewFilesystemCache(cacheDir, func() (*http.Client, error) {
218+
return httpClient, nil
219+
})
218220

219221
actualFS, err := c.FetchCatalogContents(ctx, tt.catalog)
220222
if !tt.wantErr {

internal/controllers/clusterextension_controller.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ package controllers
1919
import (
2020
"bytes"
2121
"context"
22-
"crypto/x509"
2322
"errors"
2423
"fmt"
2524
"io"
@@ -89,7 +88,6 @@ type ClusterExtensionReconciler struct {
8988
cache cache.Cache
9089
InstalledBundleGetter InstalledBundleGetter
9190
Finalizers crfinalizer.Finalizers
92-
CaCertPool *x509.CertPool
9391
Preflights []Preflight
9492
}
9593

internal/httputil/certpoolwatcher.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package httputil
2+
3+
import (
4+
"crypto/x509"
5+
"fmt"
6+
"os"
7+
"sync"
8+
"time"
9+
10+
"github.com/fsnotify/fsnotify"
11+
"github.com/go-logr/logr"
12+
)
13+
14+
type CertPoolWatcher struct {
15+
generation int
16+
dir string
17+
mx sync.RWMutex
18+
pool *x509.CertPool
19+
log logr.Logger
20+
watcher *fsnotify.Watcher
21+
done chan bool
22+
}
23+
24+
// Returns the current CertPool and the generation number
25+
func (cpw *CertPoolWatcher) Get() (*x509.CertPool, int, error) {
26+
cpw.mx.RLock()
27+
defer cpw.mx.RUnlock()
28+
if cpw.pool == nil {
29+
return nil, 0, fmt.Errorf("no certificate pool available")
30+
}
31+
return cpw.pool.Clone(), cpw.generation, nil
32+
}
33+
34+
func (cpw *CertPoolWatcher) Done() {
35+
cpw.done <- true
36+
}
37+
38+
func NewCertPoolWatcher(caDir string, log logr.Logger) (*CertPoolWatcher, error) {
39+
pool, err := NewCertPool(caDir, log)
40+
if err != nil {
41+
return nil, err
42+
}
43+
watcher, err := fsnotify.NewWatcher()
44+
if err != nil {
45+
return nil, err
46+
}
47+
if err = watcher.Add(caDir); err != nil {
48+
return nil, err
49+
}
50+
51+
cpw := &CertPoolWatcher{
52+
generation: 1,
53+
dir: caDir,
54+
pool: pool,
55+
log: log,
56+
watcher: watcher,
57+
done: make(chan bool),
58+
}
59+
go func() {
60+
for {
61+
select {
62+
case <-watcher.Events:
63+
cpw.drainEvents()
64+
cpw.update()
65+
case err := <-watcher.Errors:
66+
log.Error(err, "error watching certificate dir")
67+
os.Exit(1)
68+
case <-cpw.done:
69+
err := watcher.Close()
70+
if err != nil {
71+
log.Error(err, "error closing watcher")
72+
}
73+
return
74+
}
75+
}
76+
}()
77+
return cpw, nil
78+
}
79+
80+
func (cpw *CertPoolWatcher) update() {
81+
cpw.log.Info("updating certificate pool")
82+
pool, err := NewCertPool(cpw.dir, cpw.log)
83+
if err != nil {
84+
cpw.log.Error(err, "error updating certificate pool")
85+
os.Exit(1)
86+
}
87+
cpw.mx.Lock()
88+
defer cpw.mx.Unlock()
89+
cpw.pool = pool
90+
cpw.generation++
91+
}
92+
93+
// Drain as many events as possible before doing anything
94+
// Otherwise, we will be hit with an event for _every_ entry in the
95+
// directory, and end up doing an update for each one
96+
func (cpw *CertPoolWatcher) drainEvents() {
97+
for {
98+
drainTimer := time.NewTimer(time.Millisecond * 50)
99+
select {
100+
case <-drainTimer.C:
101+
return
102+
case <-cpw.watcher.Events:
103+
}
104+
if !drainTimer.Stop() {
105+
<-drainTimer.C
106+
}
107+
}
108+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package httputil_test
2+
3+
import (
4+
"context"
5+
"crypto/ecdsa"
6+
"crypto/elliptic"
7+
"crypto/rand"
8+
"crypto/x509"
9+
"crypto/x509/pkix"
10+
"encoding/pem"
11+
"math/big"
12+
"os"
13+
"path/filepath"
14+
"testing"
15+
"time"
16+
17+
"github.com/stretchr/testify/require"
18+
"sigs.k8s.io/controller-runtime/pkg/log"
19+
20+
"github.com/operator-framework/operator-controller/internal/httputil"
21+
)
22+
23+
func createCert(t *testing.T, name string) {
24+
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
25+
require.NoError(t, err)
26+
27+
notBefore := time.Now()
28+
notAfter := notBefore.Add(time.Hour)
29+
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
30+
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
31+
require.NoError(t, err)
32+
33+
template := x509.Certificate{
34+
SerialNumber: serialNumber,
35+
Subject: pkix.Name{
36+
Organization: []string{name},
37+
},
38+
NotBefore: notBefore,
39+
NotAfter: notAfter,
40+
41+
IsCA: true,
42+
43+
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
44+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
45+
46+
BasicConstraintsValid: true,
47+
}
48+
49+
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
50+
require.NoError(t, err)
51+
52+
certOut, err := os.Create(name)
53+
require.NoError(t, err)
54+
55+
err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
56+
require.NoError(t, err)
57+
58+
err = certOut.Close()
59+
require.NoError(t, err)
60+
61+
// ignore the key
62+
}
63+
64+
func TestCertPoolWatcher(t *testing.T) {
65+
// create a temporary directory
66+
tmpDir, err := os.MkdirTemp("", "cert-pool")
67+
require.NoError(t, err)
68+
defer os.RemoveAll(tmpDir)
69+
70+
// create the first cert
71+
certName := filepath.Join(tmpDir, "test1.pem")
72+
t.Logf("Create cert file at %q\n", certName)
73+
createCert(t, certName)
74+
75+
// Create the cert pool watcher
76+
cpw, err := httputil.NewCertPoolWatcher(tmpDir, log.FromContext(context.Background()))
77+
require.NoError(t, err)
78+
defer cpw.Done()
79+
80+
// Get the original pool
81+
firstPool, firstGen, err := cpw.Get()
82+
require.NoError(t, err)
83+
require.NotNil(t, firstPool)
84+
85+
// Create a second cert
86+
certName = filepath.Join(tmpDir, "test2.pem")
87+
t.Logf("Create cert file at %q\n", certName)
88+
createCert(t, certName)
89+
90+
require.Eventually(t, func() bool {
91+
secondPool, secondGen, err := cpw.Get()
92+
if err != nil {
93+
return false
94+
}
95+
return secondGen != firstGen && !firstPool.Equal(secondPool)
96+
}, 30*time.Second, time.Second)
97+
}

0 commit comments

Comments
 (0)