Skip to content

Commit b95cf3b

Browse files
authored
Add support for TLS certificate hot-reload (oliver006#526)
* Hot-reload of TLS server certificate * Hot-reload of TLS client certificate * Improve TLS testing with generated certificates
1 parent cc66b19 commit b95cf3b

File tree

8 files changed

+180
-38
lines changed

8 files changed

+180
-38
lines changed

.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ src/
99
.idea
1010
.vscode/
1111
*.rdb
12+
contrib/tls/ca.crt
13+
contrib/tls/ca.key
14+
contrib/tls/ca.txt
15+
contrib/tls/redis.crt
16+
contrib/tls/redis.key

Makefile

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ docker-test:
2121

2222

2323
.PHONY: test
24-
test:
24+
test:
25+
contrib/tls/gen-test-certs.sh
2526
TEST_REDIS_URI="redis://redis6:6379" \
2627
TEST_REDIS5_URI="redis://redis5:6383" \
2728
TEST_REDIS6_URI="redis://redis6:6379" \

contrib/tls/gen-test-certs.sh

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/bin/bash
2+
3+
# Generate test certificates:
4+
#
5+
# ca.{crt,key} Self signed CA certificate.
6+
# redis.{crt,key} A certificate with no key usage/policy restrictions.
7+
8+
dir=`dirname $0`
9+
10+
# Generate CA
11+
openssl genrsa -out ${dir}/ca.key 4096
12+
openssl req \
13+
-x509 -new -nodes -sha256 \
14+
-key ${dir}/ca.key \
15+
-days 3650 \
16+
-subj '/O=redis_exporter/CN=Certificate Authority' \
17+
-out ${dir}/ca.crt
18+
19+
# Generate cert
20+
openssl genrsa -out ${dir}/redis.key 2048
21+
openssl req \
22+
-new -sha256 \
23+
-subj "/O=redis_exporter/CN=localhost" \
24+
-key ${dir}/redis.key | \
25+
openssl x509 \
26+
-req -sha256 \
27+
-CA ${dir}/ca.crt \
28+
-CAkey ${dir}/ca.key \
29+
-CAserial ${dir}/ca.txt \
30+
-CAcreateserial \
31+
-days 3650 \
32+
-out ${dir}/redis.crt

exporter/exporter.go

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package exporter
22

33
import (
4-
"crypto/tls"
5-
"crypto/x509"
64
"fmt"
75
"net/http"
86
"runtime"
@@ -61,8 +59,9 @@ type Options struct {
6159
MaxDistinctKeyGroups int64
6260
CountKeys string
6361
LuaScript []byte
64-
ClientCertificates []tls.Certificate
65-
CaCertificates *x509.CertPool
62+
ClientCertFile string
63+
ClientKeyFile string
64+
CaCertFile string
6665
InclSystemMetrics bool
6766
SkipTLSVerification bool
6867
SetClientName bool

exporter/redis.go

+6-7
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
11
package exporter
22

33
import (
4-
"crypto/tls"
54
"strings"
65

76
"github.com/gomodule/redigo/redis"
87
log "github.com/sirupsen/logrus"
98
)
109

1110
func (e *Exporter) connectToRedis() (redis.Conn, error) {
11+
tlsConfig, err := e.CreateClientTLSConfig()
12+
if err != nil {
13+
return nil, err
14+
}
15+
1216
options := []redis.DialOption{
1317
redis.DialConnectTimeout(e.options.ConnectionTimeouts),
1418
redis.DialReadTimeout(e.options.ConnectionTimeouts),
1519
redis.DialWriteTimeout(e.options.ConnectionTimeouts),
16-
17-
redis.DialTLSConfig(&tls.Config{
18-
InsecureSkipVerify: e.options.SkipTLSVerification,
19-
Certificates: e.options.ClientCertificates,
20-
RootCAs: e.options.CaCertificates,
21-
}),
20+
redis.DialTLSConfig(tlsConfig),
2221
}
2322

2423
if e.options.User != "" {

exporter/tls.go

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package exporter
2+
3+
import (
4+
"crypto/tls"
5+
"crypto/x509"
6+
"io/ioutil"
7+
8+
log "github.com/sirupsen/logrus"
9+
)
10+
11+
// CreateClientTLSConfig verifies configured files and return a prepared tls.Config
12+
func (e *Exporter) CreateClientTLSConfig() (*tls.Config, error) {
13+
tlsConfig := tls.Config{
14+
InsecureSkipVerify: e.options.SkipTLSVerification,
15+
}
16+
17+
if e.options.ClientCertFile != "" && e.options.ClientKeyFile != "" {
18+
cert, err := LoadKeyPair(e.options.ClientCertFile, e.options.ClientKeyFile)
19+
if err != nil {
20+
return nil, err
21+
}
22+
tlsConfig.Certificates = []tls.Certificate{*cert}
23+
}
24+
25+
if e.options.CaCertFile != "" {
26+
log.Debugf("Load CA cert: %s", e.options.CaCertFile)
27+
caCert, err := ioutil.ReadFile(e.options.CaCertFile)
28+
if err != nil {
29+
return nil, err
30+
}
31+
certificates := x509.NewCertPool()
32+
certificates.AppendCertsFromPEM(caCert)
33+
tlsConfig.RootCAs = certificates
34+
}
35+
36+
return &tlsConfig, nil
37+
}
38+
39+
// GetServerCertificateFunc returns a function for tls.Config.GetCertificate
40+
func GetServerCertificateFunc(certFile, keyFile string) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
41+
return func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
42+
return LoadKeyPair(certFile, keyFile)
43+
}
44+
}
45+
46+
// LoadKeyPair reads and parses a public/private key pair from a pair of files.
47+
// The files must contain PEM encoded data.
48+
func LoadKeyPair(certFile, keyFile string) (*tls.Certificate, error) {
49+
log.Debugf("Load key pair: %s %s", certFile, keyFile)
50+
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
51+
if err != nil {
52+
return nil, err
53+
}
54+
return &cert, nil
55+
}

exporter/tls_test.go

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package exporter
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestCreateClientTLSConfig(t *testing.T) {
8+
for _, test := range []struct {
9+
name string
10+
options Options
11+
expectSuccess bool
12+
}{
13+
// positive tests
14+
{"no_options", Options{}, true},
15+
{"skip_verificaton", Options{
16+
SkipTLSVerification: true}, true},
17+
{"load_client_keypair", Options{
18+
ClientCertFile: "../contrib/tls/redis.crt",
19+
ClientKeyFile: "../contrib/tls/redis.key"}, true},
20+
{"load_ca_cert", Options{
21+
CaCertFile: "../contrib/tls/ca.crt"}, true},
22+
23+
// negative tests
24+
{"nonexisting_client_files", Options{
25+
ClientCertFile: "/nonexisting/file",
26+
ClientKeyFile: "/nonexisting/file"}, false},
27+
{"nonexisting_ca_file", Options{
28+
CaCertFile: "/nonexisting/file"}, false},
29+
} {
30+
t.Run(test.name, func(t *testing.T) {
31+
e := getTestExporterWithOptions(test.options)
32+
33+
_, err := e.CreateClientTLSConfig()
34+
if test.expectSuccess && err != nil {
35+
t.Errorf("Expected success for test: %s, got err: %s", test.name, err)
36+
return
37+
}
38+
})
39+
}
40+
}
41+
42+
func TestGetServerCertificateFunc(t *testing.T) {
43+
// positive test
44+
_, err := GetServerCertificateFunc("../contrib/tls/ca.crt", "../contrib/tls/ca.key")(nil)
45+
if err != nil {
46+
t.Errorf("GetServerCertificateFunc() err: %s", err)
47+
}
48+
49+
// negative test
50+
_, err = GetServerCertificateFunc("/nonexisting/file", "/nonexisting/file")(nil)
51+
if err == nil {
52+
t.Errorf("Expected GetServerCertificateFunc() to fail")
53+
}
54+
}

main.go

+23-26
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package main
22

33
import (
44
"crypto/tls"
5-
"crypto/x509"
65
"flag"
76
"io/ioutil"
87
"net/http"
@@ -120,28 +119,6 @@ func main() {
120119
log.Fatalf("Couldn't parse connection timeout duration, err: %s", err)
121120
}
122121

123-
var tlsClientCertificates []tls.Certificate
124-
if (*tlsClientKeyFile != "") != (*tlsClientCertFile != "") {
125-
log.Fatal("TLS client key file and cert file should both be present")
126-
}
127-
if *tlsClientKeyFile != "" && *tlsClientCertFile != "" {
128-
cert, err := tls.LoadX509KeyPair(*tlsClientCertFile, *tlsClientKeyFile)
129-
if err != nil {
130-
log.Fatalf("Couldn't load TLS client key pair, err: %s", err)
131-
}
132-
tlsClientCertificates = append(tlsClientCertificates, cert)
133-
}
134-
135-
var tlsCaCertificates *x509.CertPool
136-
if *tlsCaCertFile != "" {
137-
caCert, err := ioutil.ReadFile(*tlsCaCertFile)
138-
if err != nil {
139-
log.Fatalf("Couldn't load TLS Ca certificate, err: %s", err)
140-
}
141-
tlsCaCertificates = x509.NewCertPool()
142-
tlsCaCertificates.AppendCertsFromPEM(caCert)
143-
}
144-
145122
passwordMap := make(map[string]string)
146123
if *redisPwd == "" && *redisPwdFile != "" {
147124
passwordMap, err = exporter.LoadPwdFile(*redisPwdFile)
@@ -185,8 +162,9 @@ func main() {
185162
ExportClientList: *exportClientList,
186163
ExportClientsInclPort: *exportClientPort,
187164
SkipTLSVerification: *skipTLSVerification,
188-
ClientCertificates: tlsClientCertificates,
189-
CaCertificates: tlsCaCertificates,
165+
ClientCertFile: *tlsClientCertFile,
166+
ClientKeyFile: *tlsClientKeyFile,
167+
CaCertFile: *tlsCaCertFile,
190168
ConnectionTimeouts: to,
191169
MetricsPath: *metricPath,
192170
RedisMetricsOnly: *redisMetricsOnly,
@@ -203,11 +181,30 @@ func main() {
203181
log.Fatal(err)
204182
}
205183

184+
// Verify that initial client keypair and CA are accepted
185+
if (*tlsClientCertFile != "") != (*tlsClientKeyFile != "") {
186+
log.Fatal("TLS client key file and cert file should both be present")
187+
}
188+
_, err = exp.CreateClientTLSConfig()
189+
if err != nil {
190+
log.Fatal(err)
191+
}
192+
206193
log.Infof("Providing metrics at %s%s", *listenAddress, *metricPath)
207194
log.Debugf("Configured redis addr: %#v", *redisAddr)
208195
if *tlsServerCertFile != "" && *tlsServerKeyFile != "" {
209196
log.Debugf("Bind as TLS using cert %s and key %s", *tlsServerCertFile, *tlsServerKeyFile)
210-
log.Fatal(http.ListenAndServeTLS(*listenAddress, *tlsServerCertFile, *tlsServerKeyFile, exp))
197+
198+
// Verify that the initial key pair is accepted
199+
_, err := exporter.LoadKeyPair(*tlsServerCertFile, *tlsServerKeyFile)
200+
if err != nil {
201+
log.Fatalf("Couldn't load TLS server key pair, err: %s", err)
202+
}
203+
server := &http.Server{
204+
Addr: *listenAddress,
205+
TLSConfig: &tls.Config{GetCertificate: exporter.GetServerCertificateFunc(*tlsServerCertFile, *tlsServerKeyFile)},
206+
Handler: exp}
207+
log.Fatal(server.ListenAndServeTLS("", ""))
211208
} else {
212209
log.Fatal(http.ListenAndServe(*listenAddress, exp))
213210
}

0 commit comments

Comments
 (0)