Skip to content

Commit b549266

Browse files
Merge pull request #1151 from jpeeler/cert-rotation
bug 1771811: make certificate updates live upon update
2 parents 4c7479c + 9e38619 commit b549266

33 files changed

+2872
-8
lines changed

cmd/catalog/main.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"crypto/tls"
56
"flag"
67
"fmt"
78
"net/http"
@@ -18,6 +19,7 @@ import (
1819

1920
"github.com/operator-framework/operator-lifecycle-manager/pkg/api/client"
2021
"github.com/operator-framework/operator-lifecycle-manager/pkg/controller/operators/catalog"
22+
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/filemonitor"
2123
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorclient"
2224
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorstatus"
2325
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/profile"
@@ -138,8 +140,20 @@ func main() {
138140
metricsMux := http.NewServeMux()
139141
metricsMux.Handle("/metrics", promhttp.Handler())
140142
if useTLS {
143+
tlsGetCertFn, err := filemonitor.OLMGetCertRotationFn(logger, *tlsCertPath, *tlsKeyPath)
144+
if err != nil {
145+
logger.Errorf("Certificate monitoring for metrics (https) failed: %v", err)
146+
}
147+
141148
go func() {
142-
err := http.ListenAndServeTLS(":8081", *tlsCertPath, *tlsKeyPath, metricsMux)
149+
httpsServer := &http.Server{
150+
Addr: ":8081",
151+
Handler: metricsMux,
152+
TLSConfig: &tls.Config{
153+
GetCertificate: tlsGetCertFn,
154+
},
155+
}
156+
err := httpsServer.ListenAndServeTLS("", "")
143157
if err != nil {
144158
logger.Errorf("Metrics (https) serving failed: %v", err)
145159
}

cmd/olm/main.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"crypto/tls"
56
"flag"
67
"fmt"
78
"net/http"
@@ -18,6 +19,7 @@ import (
1819

1920
"github.com/operator-framework/operator-lifecycle-manager/pkg/api/client"
2021
"github.com/operator-framework/operator-lifecycle-manager/pkg/controller/operators/olm"
22+
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/filemonitor"
2123
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorclient"
2224
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorstatus"
2325
"github.com/operator-framework/operator-lifecycle-manager/pkg/lib/profile"
@@ -139,8 +141,20 @@ func main() {
139141
metricsMux := http.NewServeMux()
140142
metricsMux.Handle("/metrics", promhttp.Handler())
141143
if useTLS {
144+
tlsGetCertFn, err := filemonitor.OLMGetCertRotationFn(logger, *tlsCertPath, *tlsKeyPath)
145+
if err != nil {
146+
logger.Errorf("Certificate monitoring for metrics (https) failed: %v", err)
147+
}
148+
142149
go func() {
143-
err := http.ListenAndServeTLS(":8081", *tlsCertPath, *tlsKeyPath, metricsMux)
150+
httpsServer := &http.Server{
151+
Addr: ":8081",
152+
Handler: metricsMux,
153+
TLSConfig: &tls.Config{
154+
GetCertificate: tlsGetCertFn,
155+
},
156+
}
157+
err := httpsServer.ListenAndServeTLS("", "")
144158
if err != nil {
145159
logger.Errorf("Metrics (https) serving failed: %v", err)
146160
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect
1111
github.com/emicklei/go-restful v2.9.6+incompatible // indirect
1212
github.com/evanphx/json-patch v4.5.0+incompatible // indirect
13+
github.com/fsnotify/fsnotify v1.4.7
1314
github.com/ghodss/yaml v1.0.0
1415
github.com/go-openapi/spec v0.19.2
1516
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b

go.sum

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -424,8 +424,6 @@ github.com/openshift/client-go v0.0.0-20190923180330-3b6373338c9b h1:E++qQ7W1/Ed
424424
github.com/openshift/client-go v0.0.0-20190923180330-3b6373338c9b/go.mod h1:6rzn+JTr7+WYS2E1TExP4gByoABxMznR6y2SnUIkmxk=
425425
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
426426
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
427-
github.com/operator-framework/operator-registry v1.4.0 h1:yhI7ZeF6d7G588i2egoFjgGi1eXhaU1aL0GMEMDuS/4=
428-
github.com/operator-framework/operator-registry v1.4.0/go.mod h1:3nJcOSX6TKPpAbmGOAVvDxakZWgccyF0WgiJsKjPAzQ=
429427
github.com/operator-framework/operator-registry v1.5.1 h1:8ruUOG6IBDVTAXYWKsv6hwr4yv/0SFPFPAYGCpcv97E=
430428
github.com/operator-framework/operator-registry v1.5.1/go.mod h1:agrQlkWOo1q8U1SAaLSS2WQ+Z9vswNT2M2HFib9iuLY=
431429
github.com/otiai10/copy v1.0.1 h1:gtBjD8aq4nychvRZ2CyJvFWAw0aja+VHazDdruZKGZA=
@@ -461,15 +459,11 @@ github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f h1:BVwpUVJ
461459
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
462460
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8=
463461
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
464-
github.com/prometheus/common v0.0.0-20190104105734-b1c43a6df3ae h1:iq3e1tH4dCzdqscIkWimcnzYt6Pkz0zOzHSgV9cb5DE=
465-
github.com/prometheus/common v0.0.0-20190104105734-b1c43a6df3ae/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
466462
github.com/prometheus/common v0.2.0 h1:kUZDBDTdBVBYBj5Tmh2NZLlF60mfjA27rM34b+cVwNU=
467463
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
468464
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
469465
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE=
470466
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
471-
github.com/prometheus/procfs v0.0.0-20190104112138-b1a0a9a36d74 h1:d1Xoc24yp/pXmWl2leBiBA+Tptce6cQsA+MMx/nOOcY=
472-
github.com/prometheus/procfs v0.0.0-20190104112138-b1a0a9a36d74/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
473467
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1 h1:/K3IL0Z1quvmJ7X0A1AwNEK7CRkVK3YwfOU/QAL4WGg=
474468
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
475469
github.com/quobyte/api v0.1.2/go.mod h1:jL7lIHrmqQ7yh05OJ+eEEdHr0u/kmT1Ff9iHd+4H6VI=

pkg/lib/filemonitor/cert_updater.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package filemonitor
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"crypto/x509"
7+
"fmt"
8+
"path/filepath"
9+
"sync"
10+
11+
"github.com/fsnotify/fsnotify"
12+
"github.com/sirupsen/logrus"
13+
)
14+
15+
type keystore struct {
16+
mutex sync.RWMutex
17+
cert *tls.Certificate
18+
tlsCrtPath string
19+
tlsKeyPath string
20+
}
21+
22+
type getCertFn = func(*tls.ClientHelloInfo) (*tls.Certificate, error)
23+
24+
// NewKeystore returns a store for storing the certificate data and the ability to retrieve it safely
25+
func NewKeystore(tlsCrt, tlsKey string) *keystore {
26+
cert, err := tls.LoadX509KeyPair(tlsCrt, tlsKey)
27+
if err != nil {
28+
panic(err)
29+
}
30+
return &keystore{
31+
mutex: sync.RWMutex{},
32+
cert: &cert,
33+
tlsCrtPath: tlsCrt,
34+
tlsKeyPath: tlsKey,
35+
}
36+
}
37+
38+
// HandleFilesystemUpdate is intended to be used as the OnUpdateFn for a watcher
39+
// and expects the certificate files to be in the same directory.
40+
func (k *keystore) HandleFilesystemUpdate(logger *logrus.Logger, event fsnotify.Event) {
41+
switch op := event.Op; op {
42+
case fsnotify.Create:
43+
logger.Debugf("got fs event for %v", event.Name)
44+
45+
if err := k.storeCertificate(k.tlsCrtPath, k.tlsKeyPath); err != nil {
46+
// this can happen if both certificates aren't updated at the same
47+
// time, but it's okay as replacement only occurs with a valid key pair
48+
logger.Debugf("certificates not in sync: %v", err)
49+
} else {
50+
info, err := x509.ParseCertificate(k.cert.Certificate[0])
51+
if err != nil {
52+
logger.Debugf("certificates refreshed, but parsing returned error: %v", err)
53+
} else {
54+
logger.Debugf("certificates refreshed: Subject=%v NotBefore=%v NotAfter=%v", info.Subject, info.NotBefore, info.NotAfter)
55+
}
56+
}
57+
}
58+
}
59+
60+
func (k *keystore) storeCertificate(tlsCrt, tlsKey string) error {
61+
cert, err := tls.LoadX509KeyPair(tlsCrt, tlsKey)
62+
if err == nil {
63+
k.mutex.Lock()
64+
defer k.mutex.Unlock()
65+
k.cert = &cert
66+
}
67+
return err
68+
}
69+
70+
func (k *keystore) GetCertificate(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
71+
k.mutex.RLock()
72+
defer k.mutex.RUnlock()
73+
return k.cert, nil
74+
}
75+
76+
// OLMGetCertRotationFn is a convenience function for OLM use only, but serves as an example for monitoring file system events
77+
func OLMGetCertRotationFn(logger *logrus.Logger, tlsCertPath, tlsKeyPath string) (getCertFn, error) {
78+
if filepath.Dir(tlsCertPath) != filepath.Dir(tlsKeyPath) {
79+
return nil, fmt.Errorf("certificates expected to be in same directory %v vs %v", tlsCertPath, tlsKeyPath)
80+
}
81+
82+
keystore := NewKeystore(tlsCertPath, tlsKeyPath)
83+
watcher, err := NewWatch(logger, []string{filepath.Dir(tlsCertPath)}, keystore.HandleFilesystemUpdate)
84+
if err != nil {
85+
return nil, err
86+
}
87+
watcher.Run(context.Background())
88+
89+
return keystore.GetCertificate, nil
90+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package filemonitor
2+
3+
import (
4+
"crypto/tls"
5+
"crypto/x509"
6+
"fmt"
7+
"html"
8+
"io/ioutil"
9+
"net"
10+
"net/http"
11+
"os"
12+
"path/filepath"
13+
"strconv"
14+
"testing"
15+
"time"
16+
17+
"k8s.io/apimachinery/pkg/util/wait"
18+
19+
"github.com/sirupsen/logrus"
20+
21+
"github.com/stretchr/testify/assert"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
func TestOLMGetCertRotationFn(t *testing.T) {
26+
logger := logrus.New()
27+
logger.SetLevel(logrus.DebugLevel)
28+
logger.SetFormatter(&logrus.TextFormatter{
29+
TimestampFormat: time.RFC3339Nano,
30+
})
31+
32+
testData := "testdata"
33+
monitorDir := "monitor"
34+
caCrt := filepath.Join(testData, "ca.crt")
35+
oldCrt := filepath.Join(testData, "server-old.crt")
36+
oldKey := filepath.Join(testData, "server-old.key")
37+
newCrt := filepath.Join(testData, "server-new.crt")
38+
newKey := filepath.Join(testData, "server-new.key")
39+
loadCrt := filepath.Join(monitorDir, "loaded.crt")
40+
loadKey := filepath.Join(monitorDir, "loaded.key")
41+
42+
// these values must match values specified in the testdata generation script
43+
expectedOldCN := "CN=127.0.0.1,OU=OpenShift,O=Red Hat,L=Columbia,ST=SC,C=US"
44+
expectedNewCN := "CN=127.0.0.1,OU=OpenShift,O=Red Hat,L=New York City,ST=NY,C=US"
45+
46+
// the directory is expected to contain exactly one keypair, so create an empty directory to swap the keys in
47+
err := os.RemoveAll(monitorDir) // this is for test development, shouldn't ever exist beforehand otherwise
48+
require.NoError(t, err)
49+
err = os.Mkdir(monitorDir, 0777)
50+
require.NoError(t, err)
51+
52+
// symlink old files to loading files
53+
err = os.Symlink(filepath.Join("..", oldCrt), loadCrt)
54+
require.NoError(t, err)
55+
err = os.Symlink(filepath.Join("..", oldKey), loadKey)
56+
require.NoError(t, err)
57+
58+
tlsGetCertFn, err := OLMGetCertRotationFn(logger, loadCrt, loadKey)
59+
require.NoError(t, err)
60+
61+
// find a free port to listen on and start server
62+
listener, err := net.Listen("tcp", ":0")
63+
require.NoError(t, err)
64+
freePort := listener.Addr().(*net.TCPAddr).Port
65+
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
66+
fmt.Fprintf(w, "Path: %q", html.EscapeString(r.URL.Path))
67+
})
68+
httpsServer := &http.Server{
69+
Addr: ":" + strconv.Itoa(freePort),
70+
TLSConfig: &tls.Config{
71+
GetCertificate: tlsGetCertFn,
72+
},
73+
}
74+
go func() {
75+
if err := httpsServer.ServeTLS(listener, "", ""); err != nil {
76+
panic(err)
77+
}
78+
}()
79+
80+
caCert, err := ioutil.ReadFile(caCrt)
81+
require.NoError(t, err)
82+
caCertPool := x509.NewCertPool()
83+
caCertPool.AppendCertsFromPEM(caCert)
84+
85+
client := &http.Client{
86+
Transport: &http.Transport{
87+
TLSClientConfig: &tls.Config{
88+
RootCAs: caCertPool,
89+
},
90+
},
91+
}
92+
93+
resp, err := client.Get(fmt.Sprintf("https://localhost:%v", freePort))
94+
require.NoError(t, err)
95+
assert.Equal(t, resp.StatusCode, http.StatusOK)
96+
assert.Equal(t, expectedOldCN, resp.TLS.PeerCertificates[0].Subject.String())
97+
resp.Body.Close()
98+
client.CloseIdleConnections()
99+
100+
// atomically switch out the symlink so the file contents are always seen in a consistent state
101+
// (the same idea is used in the atomic writer in kubernetes)
102+
atomicCrt := loadCrt + ".atomic-op"
103+
atomicKey := loadKey + ".atomic-op"
104+
err = os.Symlink(filepath.Join("..", newCrt), atomicCrt)
105+
require.NoError(t, err)
106+
err = os.Symlink(filepath.Join("..", newKey), atomicKey)
107+
require.NoError(t, err)
108+
109+
err = os.Rename(atomicCrt, loadCrt)
110+
require.NoError(t, err)
111+
err = os.Rename(atomicKey, loadKey)
112+
require.NoError(t, err)
113+
114+
// sometimes the the filesystem operations need time to catch up so the server cert is updated
115+
err = wait.PollImmediate(500*time.Millisecond, 10*time.Second, func() (bool, error) {
116+
currentCert, _ := tlsGetCertFn(nil)
117+
info, err := x509.ParseCertificate(currentCert.Certificate[0])
118+
if err != nil {
119+
return false, err
120+
}
121+
if info.Subject.String() == expectedNewCN {
122+
return true, nil
123+
}
124+
125+
return false, nil
126+
})
127+
require.NoError(t, err)
128+
129+
resp, err = client.Get(fmt.Sprintf("https://localhost:%v", freePort))
130+
require.NoError(t, err)
131+
assert.Equal(t, resp.StatusCode, http.StatusOK)
132+
assert.Equal(t, expectedNewCN, resp.TLS.PeerCertificates[0].Subject.String())
133+
134+
os.RemoveAll(monitorDir)
135+
}

pkg/lib/filemonitor/testdata/ca.crt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDCTCCAfGgAwIBAgIUZSqEGDsDVte9dO9YVGNd3n9/EakwDQYJKoZIhvcNAQEL
3+
BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTE5MTEyMzAyMDM1OFoXDTQ3MDQx
4+
MDAyMDM1OFowFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF
5+
AAOCAQ8AMIIBCgKCAQEA4Gwb6+HM2IJn/ZZIERVA/ngKQzk+Lk+36Yrn1ka8HOum
6+
vmWhozle3FwczCOQks5Jc2v5W6ulZHg0uQtNutw3lWkinVqGZzI34E0bxKchPniy
7+
egGnW3n9/dgIVd9ooPnOLJJXEwV4ZVIhubfB/EHib9rha2nIKVyYxihhrkCMU8aq
8+
qhYqOzJYk9eWzwB+mXAxW5AMmUAmVF0J9JvpG/FHliG+WgJNrxunuGuIa4XLw50H
9+
V5bJ5pXUdX/L0EPTQ1KWpmjByFD/dcOmFGc+pdw+x84CYUAH2DkxewUHAe0e6hlO
10+
RY3tuOXEigtfU6F0jUxD2RjC2pBp1WoH+UyyUiRQTQIDAQABo1MwUTAdBgNVHQ4E
11+
FgQU0jPw9F7SBqdbDbws33KFGPqKqb8wHwYDVR0jBBgwFoAU0jPw9F7SBqdbDbws
12+
33KFGPqKqb8wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAjDnW
13+
CiQ8X+Ncm09bF6SXjtTzwbSduJ7VEkVjVm+nW0/Rh56Lgw0Z/ceWy/GyqYzuhsUz
14+
6uQOrS3drlg+p2fn4ZqtKWmDByYaUQS709I/qDkBE9mXxjojOhnQEqgjdQ5yoIP7
15+
mXeJQlFlrSaf4yEW264XQjbasKGQi2QBejhHK+aHw6PsszaNxqeqvTr0K95OTPPv
16+
vl2L0A4grMKtXnws8LL6zazz9eqib0K9iyHOLxvtWnPjc1y3XmFPr8mjWmbc9pl3
17+
5UGohvZy/wBjPS0YPLMDoGGu0jajvbNqcoEMFJ6Zo6oPGJIpwpqCjgLAgwMpx9oL
18+
EhMnEidqcop3+Z28kA==
19+
-----END CERTIFICATE-----
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
DESTINATION:=../
2+
3+
all:
4+
./gen-certs.sh
5+
6+
clean:
7+
rm -f *.csr *.crt *.key *.srl
8+
9+
copy:
10+
cp ca.crt $(DESTINATION)
11+
cp server-old.crt $(DESTINATION)
12+
cp server-old.key $(DESTINATION)
13+
cp server-new.crt $(DESTINATION)
14+
cp server-new.key $(DESTINATION)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[ req ]
2+
default_bits = 2048
3+
prompt = no
4+
default_md = sha256
5+
req_extensions = req_ext
6+
distinguished_name = dn
7+
8+
[ dn ]
9+
C = US
10+
ST = NY
11+
L = New York City
12+
O = Red Hat
13+
OU = OpenShift
14+
CN = 127.0.0.1
15+
16+
[ req_ext ]
17+
subjectAltName = @alt_names
18+
19+
[ alt_names ]
20+
DNS.1 = localhost
21+
IP.1 = 127.0.0.1
22+
23+
[ v3_ext ]
24+
authorityKeyIdentifier=keyid,issuer:always
25+
basicConstraints=CA:FALSE
26+
keyUsage=keyEncipherment,dataEncipherment
27+
extendedKeyUsage=serverAuth,clientAuth
28+
subjectAltName=@alt_names

0 commit comments

Comments
 (0)